En programmation multitheradée, la gestion de l’accès concurrent aux ressources partagées est cruciale pour éviter les conditions de course, les incohérences de données et les comportements imprévisibles. Python fournit plusieurs primitives de synchronisation intégrées dans le module threading, chacune adaptée à des scénarios spécifiques.
Le verrou standard (Lock)
Le verrou exclusif — ou mutex — garantit qu’un seul thread à la fois peut exécuter une section critique. Il ne supporte pas les acquisitions répétées depuis le même thread : une tentative d’acquisition successive sans libération préalable entraîne un blocage indéfini.
Exemple : mise à jour sécurisée d’une liste partagée par 10 threads :
import threading
import time
shared_items = []
mutex = threading.Lock()
def append_and_read(index):
mutex.acquire()
try:
shared_items.append(index)
time.sleep(0.005) # Simule un traitement
last_value = shared_items[-1]
print(f"Thread {index}: ajouté → {last_value}")
finally:
mutex.release()
for i in range(10):
t = threading.Thread(target=append_and_read, args=(i,))
t.start()
Le verrou réentrant (Rlock)
Contrairement au Lock, le Rlock (Reentrant Lock) autorise plusieurs appels à acquire() depuis le même thread, à condition que chaque acquisition soit compensée par un release() correspondant. Cela évite les deadlocks internes lors d’appels imbriqués ou récursifs.
import threading
counter = 0
reentrant_lock = threading.RLock()
def nested_increment():
reentrant_lock.acquire()
reentrant_lock.acquire() # Autorisé
global counter
counter += 1
reentrant_lock.release()
reentrant_lock.release() # Deux releases requises
t = threading.Thread(target=nested_increment)
t.start()
t.join()
print(f"Compteur final: {counter}")
Évitement des deadlocks avec les verrous hiérarchiques
Un daedlock survient lorsque deux threads détiennent chacun une ressource tout en attendant l’autre. Par exemple, si Thread A verrouille noodle_lock puis attend fork_lock, tandis que Thread B verrouille fork_lock puis attend noodle_lock, aucun ne progresse.
Une solution robuste consiste à imposer un ordre d’acquisition cohérent ou à utiliser un Rlock partagé — mais cette dernière approche doit être appliquée avec prudence, car elle masque plutôt qu’elle ne résout le problème fondamental de conception.
Sémaphores bornés (BoundedSemaphore)
Ce type de verrou limite le nombre simultané de threads pouvant entrer dans une zone critique. Il est utile pour modéliser des ressources finies, comme une piscine de connexions ou un ensemble limité de licences.
import threading
import time
resource_pool = threading.BoundedSemaphore(value=2) # Max 2 threads actifs
def use_resource(identifier):
resource_pool.acquire()
print(f"[{identifier}] Ressource obtenue")
time.sleep(1.5)
print(f"[{identifier}] Ressource libérée")
resource_pool.release()
for idx in range(6):
threading.Thread(target=use_resource, args=(idx,)).start()
Conditions de synchronisation (Condition)
Un objet Condition permet de suspendre des threads jusqu’à ce qu’un événement spécifique se produise, souvent combiné avec un verrou sous-jacent. Il prend en charge des notifications ciblées via notify(n) ou notify_all().
import threading
import time
gate = threading.Condition()
ready_count = 0
def wait_for_signal(worker_id):
with gate:
print(f"Travailleur {worker_id} en attente...")
gate.wait() # Bloque jusqu'à notification
print(f"Travailleur {worker_id} débloqué")
# Démarrage de 5 travailleurs
for i in range(5):
threading.Thread(target=wait_for_signal, args=(i,)).start()
time.sleep(0.1)
time.sleep(1)
with gate:
gate.notify(3) # Réveille 3 threads
Événements globaux (Event)
L’objet Event agit comme un signal binaire : set() active le signal, clear() le désactive, et wait() bloque tant qu’il n’est pas activé. Il convient aux scénarios où tous les threads doivent attendre un déclencheur commun.
import threading
import time
trigger = threading.Event()
def waiter(id):
print(f"Attendant {id} : en attente du signal...")
trigger.wait()
print(f"Attendant {id} : signal reçu !")
for i in range(4):
threading.Thread(target=waiter, args=(i,)).start()
time.sleep(1)
trigger.set() # Libère tous les threads bloqués
Considérations sur la sécurité des structures natives
Bien que les opérations élémentaires sur les listes et les dictionnaires soient atomiques dans CPython (grâce au GIL), cela ne garantit pas la sécurité dans des contextes complexes impliquant plusieurs étapes (ex. lecture-modification-écriture). Le GIL protège uniquement l’interpréteur lui-même, pas vos données métier. Ainsi, toute logique non atomique nécessite un verrou explicite.