Introduzione
Quando si lavora con multithreading in Python, uno degli errori più comuni è pensare che basti “lanciare più thread” per ottenere un programma più veloce e corretto. In realtà, i thread condividono la stessa memoria del processo e questo significa che possono leggere e modificare gli stessi dati contemporaneamente. Se non si gestisce bene l’accesso alle risorse condivise, si rischiano race condition, dati incoerenti e bug difficili da riprodurre.
In questo tutorial vedremo un sotto-argomento molto pratico e spesso sottovalutato: la sincronizzazione dei thread con i lock, in particolare con Lock e RLock. È un tema fondamentale perché rappresenta la base per proteggere contatori, cache, liste condivise, configurazioni in memoria e qualsiasi stato comune accessibile da più thread.
Capire bene questo argomento ti aiuta a scrivere codice concorrente più affidabile, evitando soluzioni fragili come l’uso ingenuo di variabili globali o controlli manuali non protetti.
Codice completo
import threading
import time
import random
class ContatoreSicuro:
def __init__(self):
self.valore = 0
self.lock = threading.Lock()
def incrementa(self):
# Protegge la sezione critica: solo un thread alla volta entra qui
with self.lock:
valore_attuale = self.valore
time.sleep(random.uniform(0.001, 0.01)) # simula lavoro reale
self.valore = valore_attuale + 1
def decrementa(self):
with self.lock:
valore_attuale = self.valore
time.sleep(random.uniform(0.001, 0.01))
self.valore = valore_attuale - 1
class RegistroOperazioni:
def __init__(self):
self.operazioni = []
self.lock = threading.RLock()
def aggiungi_operazione(self, testo):
# RLock permette allo stesso thread di acquisire il lock più volte
with self.lock:
self._aggiungi_con_timestamp(testo)
def _aggiungi_con_timestamp(self, testo):
with self.lock:
timestamp = time.strftime("%H:%M:%S")
self.operazioni.append(f"[{timestamp}] {testo}")
def lavora_su_contatore(contatore, n):
for _ in range(n):
if random.choice([True, False]):
contatore.incrementa()
else:
contatore.decrementa()
def lavora_sul_registro(registro, n, nome_thread):
for i in range(n):
registro.aggiungi_operazione(f"{nome_thread} ha eseguito l´operazione {i}")
time.sleep(random.uniform(0.001, 0.005))
def main():
# ESEMPIO 1: contatore condiviso protetto da Lock
contatore = ContatoreSicuro()
thread_contatore = []
for _ in range(10):
t = threading.Thread(target=lavora_su_contatore, args=(contatore, 100))
thread_contatore.append(t)
t.start()
for t in thread_contatore:
t.join()
print("Valore finale del contatore:", contatore.valore)
# ESEMPIO 2: registro condiviso protetto da RLock
registro = RegistroOperazioni()
thread_registro = []
for i in range(3):
t = threading.Thread(
target=lavora_sul_registro,
args=(registro, 5, f"Thread-{i}")
)
thread_registro.append(t)
t.start()
for t in thread_registro:
t.join()
print("nOperazioni registrate:")
for operazione in registro.operazioni:
print(operazione)
if __name__ == "__main__":
main() Spiegazione
Il codice mostra due scenari tipici in cui la sincronizzazione è indispensabile.
1. Proteggere un contatore condiviso con Lock
Nella classe ContatoreSicuro abbiamo una variabile valore condivisa tra più thread. Il metodo incrementa() e il metodo decrementa() leggono il valore corrente, fanno una piccola pausa e poi lo riscrivono aggiornato.
Questa operazione sembra banale, ma in realtà non è atomica. Senza lock, due thread potrebbero leggere lo stesso valore nello stesso momento e sovrascrivere il lavoro dell’altro. Il risultato sarebbe un valore finale sbagliato.
La soluzione è usare threading.Lock() e racchiudere la sezione critica dentro with self.lock:. In questo modo, solo un thread alla volta può entrare nel blocco. Il contatore viene aggiornato in maniera coerente.
2. Gestire accessi annidati con RLock
La classe RegistroOperazioni usa threading.RLock(), cioè un lock rientrante. Serve quando lo stesso thread potrebbe acquisire il lock più volte lungo una catena di chiamate.
Nel nostro esempio, aggiungi_operazione() acquisisce il lock e chiama un metodo interno, _aggiungi_con_timestamp(), che prova ad acquisire di nuovo lo stesso lock. Con un semplice Lock, questo causerebbe un deadlock: il thread resterebbe bloccato aspettando un lock che già possiede.
Con RLock, invece, lo stesso thread può entrare più volte nella sezione protetta. Il lock tiene traccia di quante volte è stato acquisito e viene rilasciato correttamente solo quando tutte le acquisizioni corrispondenti vengono chiuse.
3. Perché usare with
Usare with lock: è una best practice importante. Garantisce che il lock venga rilasciato anche se si verifica un’eccezione. Se invece scrivessi manualmente acquire() e release(), un errore nel mezzo potrebbe lasciare il lock bloccato e fermare gli altri thread.
4. Il ruolo di join()
Nel main() usiamo join() per aspettare che tutti i thread terminino prima di stampare i risultati. Senza join(), il programma principale potrebbe finire troppo presto, mostrando dati incompleti o uscendo prima che il lavoro concorrente sia terminato.
Best practice
- Proteggi solo la sezione critica: non tenere il lock più a lungo del necessario. Più tempo un lock resta acquisito, più aumentano i blocchi tra thread.
- Preferisci
with lock:: riduce errori e rende il codice più leggibile. - Usa
Lockper casi semplici: se non hai acquisizioni annidate,Lockè la scelta più leggera e chiara. - Usa
RLocksolo quando serve davvero: è più flessibile, ma anche più facile da abusare. Non sostituireLockconRLock“per sicurezza” senza motivo. - Evita operazioni lente dentro il lock: chiamate di rete, I/O su file o sleep artificiale dovrebbero stare fuori dalla sezione critica, se possibile.
- Riduci lo stato condiviso: meno dati condivisi ci sono, meno sincronizzazione serve. Spesso una buona progettazione vale più di un lock in più.
- Attenzione ai deadlock: se usi più lock, definisci un ordine coerente di acquisizione e rispettalo sempre.
Riepilogo
La sincronizzazione è uno degli aspetti più importanti del multithreading in Python. I thread sono utili, ma quando condividono dati in memoria devono coordinarsi per evitare corruzione dello stato.
In questo tutorial abbiamo visto due strumenti fondamentali:
- Lock, ideale per proteggere sezioni critiche semplici e impedire accessi simultanei a una risorsa condivisa.
- RLock, utile quando lo stesso thread deve acquisire lo stesso lock più volte, per esempio in chiamate annidate.
Il messaggio principale è semplice: il multithreading non è solo “far lavorare più thread”, ma soprattutto farli collaborare in modo sicuro. Se impari a usare bene i lock, eviterai molti bug difficili da diagnosticare e scriverai codice concorrente molto più robusto.
Approfondisci con risorse ufficiali
- Documentazione ufficiale Python su
threading: ottima per approfondire Lock, RLock, Condition, Event e Semaphore. - Documentazione ufficiale Python su
concurrent.futures: utile per gestire thread e processi con un’API più alta livello. - Documentazione ufficiale Python sulla
queue: fondamentale per lo scambio sicuro di dati tra thread. - PEP e guide sulla concorrenza: utili per comprendere meglio limiti, trade-off e comportamento del GIL in CPython.
