Les 5 types de verrous en Python pour le threading

Sécurité des threads

La sécurité des threads est un concept fondamental en programmation multi‑threads ou multi‑processus. Dans un programme où plusieurs threads accèdent à des données partagées, un code thread‑safe utilise des mécanismes de synchronisation pour garantir que chaque thread s’exécute correctement, sans corruption des données.

Le problème principal vient des changements de contexte. Imaginons une pièce (processus) contenant 10 bonbons (ressource) et trois personnes (1 thread principal, 2 threads fils). Si la personne A mange 3 bonbons puis est mise en pause, elle pense qu’il en reste 7. Pendant ce temps, la personne B travaille et en mange 3. Lorsque A reprend, elle croit qu’il reste 7 bonbons, alors qu’il n’y en a plus que 4.

Cet exemple illustre une désynchronisation des données entre threads, ce qui peut entraîner des conséquences graves. Voici une démonstration concrète :

import threading

valeur = 0

def incremente():
    global valeur
    for _ in range(10_000_000):
        valeur += 1

def decremente():
    global valeur
    for _ in range(10_000_000):
        valeur -= 1

if __name__ == "__main__":
    t1 = threading.Thread(target=incremente)
    t2 = threading.Thread(target=decremente)

    t1.start()
    t2.start()

    t1.join()
    t2.join()

    print("Résultat final : %s" % valeur)

# Exécutions :
# Résultat final : 669214
# Résultat final : -1849179
# Résultat final : -525674

Ceci montre clairement le besoin d’un verrou pour contrôler les changements de contexte.

Remarque : en Python, les types de base list, tuple et dict sont déjà thread‑safes. Il n’est donc pas nécessaire d’ajouter des verrous lorsqu’on les manipule.

Rôle des verrous

Les verrous permettent de contrôler manuellement les bascules de threads. En rendant les transitions ordonnées, on peut garantir que l’accès et la modification des données partagées restent cohérents.

Le module threading propose cinq types de verrous principaux :

  • Verrou de synchronisation (Lock) – un seul thread à la fois
  • Verrou récursif (RLock) – un seul thread à la fois, mais réentrant
  • Verrou conditionnel (Condition) – peut libérer un nombre arbitraire de threads en attente
  • Verrou d’événement (Event) – libère tous les threads en attente en une fois
  • Sémaphore (Semaphore) – libère un nombre spécifique de threads à la fois

1. Lock() – Verrou de synchronisation

Présentation
Un Lock assure une exclusion mutuelle : une seule ressource peut être accédée par un seul thread à un instant donné. Il ne garantit pas l’ordre d’accès, uniquement l’exclusivité.

Utilisation
Un verrou ne laisse passer qu’un thread à la fois. Le thread qui a acquis le verrou ne cède pas la main tant qu’il ne l’a pas relâché.

import threading

valeur = 0
verrou = threading.Lock()

def incremente():
    verrou.acquire()
    global valeur
    for _ in range(10_000_000):
        valeur += 1
    verrou.release()

def decremente():
    verrou.acquire()
    global valeur
    for _ in range(10_000_000):
        valeur -= 1
    verrou.release()

if __name__ == "__main__":
    t1 = threading.Thread(target=incremente)
    t2 = threading.Thread(target=decremente)
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print("Résultat : %s" % valeur)
# Toujours 0

Interblocage (deadlock)
Un Lock ne permet pas d’acquérir plusieurs fois de suite sans relâcher :

def incremente():
    verrou.acquire()
    verrou.acquire()  # Deadlock !
    global valeur
    for _ in range(10_000_000):
        valeur += 1
    verrou.release()
    verrou.release()

Gestion contextuelle (with)
Lock implémente __enter__ et __exit__, donc vous pouvez utiliser with :

def incremente():
    with verrou:
        global valeur
        for _ in range(10_000_000):
            valeur += 1

2. RLock() – Verrou récursif

Le RLock permet d’acquérir le même verrou plusieurs fois par le même thread (compteur interne), à condition de le relâcher autant de fois. Sans cela, deadlock.

import threading

valeur = 0
verrou = threading.RLock()

def incremente():
    verrou.acquire()
    verrou.acquire()  # OK avec RLock
    global valeur
    for _ in range(10_000_000):
        valeur += 1
    verrou.release()
    verrou.release()

Idem avec with.

3. Condition() – Verrou conditionnel

Un Condition permet à des threads de se mettre en attente (wait()) et d’être réveillés par d’autres threads via notify(n) ou notify_all().

import threading

nb_threads_actifs = 0
MAX_THREADS = 10
cond = threading.Condition()

def tache():
    global nb_threads_actifs
    nom = threading.current_thread().name
    with cond:
        print("Démarré et en attente : %s" % nom)
        cond.wait()                      # mise en pause
        nb_threads_actifs += 1
        print("Repris : %s" % nom)

if __name__ == "__main__":
    for _ in range(MAX_THREADS):
        threading.Thread(target=tache).start()

    while nb_threads_actifs < MAX_THREADS:
        nb = int(input("Nombre de threads à réveiller : "))
        with cond:
            cond.notify(nb)

Utilisation avec with (possible car Condition implémente le protocole de gestion de contexte).

4. Event() – Verrou d’événement

Un Event agit comme un feu tricolore : tous les threads en attente sont libérés en une fois lorsque l’événement est passé à l’état « vert » (set()).

import threading
import time

event = threading.Event()

def feu(event):
    print("Feu rouge – 5 secondes")
    time.sleep(5)
    print("Feu vert !")
    event.set()

def voiture(event, nom):
    print("Voiture %s attend le feu vert" % nom)
    event.wait()
    print("Voiture %s démarre" % nom)

if __name__ == "__main__":
    t = threading.Thread(target=feu, args=(event,))
    t.start()
    for lettre in "ABCDE":
        threading.Thread(target=voiture, args=(event, lettre)).start()

5. Semaphore() – Sémaphore

Un sémaphore limite le nombre de threads pouvant entrer dans une section critique simultanément. Ici, nous autorisons 2 threads à la fois :

import threading
import time

sema = threading.Semaphore(2)

def tache():
    with sema:
        print("Thread %s en cours" % threading.current_thread().name)
        time.sleep(3)

if __name__ == "__main__":
    for i in range(6):
        threading.Thread(target=tache).start()

Relations entre les verrous

Tous ces verrous sont construits sur le Lock de base. Par exemple, RLock maintient un compteur interne (_count) et un propriétaire (_owner). Condition utilise un RLock en interne, qui repose lui-même sur un Lock.

Exercices pratiques

Application du Condition

Remplir une liste avec des nombres de 1 à 100 dans l’ordre en alternant entre un thread qui ajoute les nombres pairs et un qui ajoute les nombres impairs.

import threading

resultat = []
cond = threading.Condition()

def pairs():
    with cond:
        for i in range(2, 101, 2):
            if len(resultat) % 2 != 0:
                resultat.append(i)
                cond.notify()
                cond.wait()
            else:
                cond.wait()
                resultat.append(i)
                cond.notify()
        cond.notify()

def impairs():
    with cond:
        for i in range(1, 101, 2):
            if len(resultat) % 2 == 0:
                resultat.append(i)
                cond.notify()
                cond.wait()
        cond.notify()

if __name__ == "__main__":
    t1 = threading.Thread(target=pairs)
    t2 = threading.Thread(target=impairs)
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(resultat)

Application de l’Event

Simuler un dialogue entre Li Bai et Du Fu en alternant les répliques à l’aide d’un événement.

import threading

event = threading.Event()

def li_bai():
    event.wait()
    print("Li Bai : Mon vieux Du, je n'en peux plus, arrêtons là !")
    event.set()
    event.clear()
    event.wait()
    print("Li Bai : Ronfle... ronfle... endormi.")

def du_fu():
    print("Du Fu : Vieux Li, viens boire un verre !")
    event.set()
    event.clear()
    event.wait()
    print("Du Fu : Vieux Li, encore une tournée ?")
    print("Du Fu : ... Vieux Li ?")
    event.set()

if __name__ == "__main__":
    t1 = threading.Thread(target=li_bai)
    t2 = threading.Thread(target=du_fu)
    t1.start()
    t2.start()
    t1.join()
    t2.join()

Étiquettes: Python threading verrou synchronisation Lock

Publié le 17 juin à 23h06