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()