In questo articolo continueremo il discorso del Multithreading, introducendo un altro importantissimo strumento: i Lock. Grazie ad essi, si può gestire in maniera più efficiente la sincronizzazione tra i vari thread. Inoltre parleremo di un’altra problematica comune nel mondo dei thread: i deadlock.
- Thread in Python – Threading (parte 1)
- Thread in Python – Join (parte 2)
- Thread in Python – Multithreading (parte 3)
I Lock e la Basic Synchronization
Abbiamo visto nell’esempio precedente un caso semplicissimo di race condition. Il modulo threading ci fornisce alcuni strumenti per evitare, o risolvere, gli eventi di race condition. Uno di questi sono i Lock.
Un Lock è un oggetto che agisce come un permesso. Il lock verrà assegnato ad un solo thread alla volta. Gli altri thread rimarranno in attesa che il thread proprietario del lock termini il suo compito e lo restituisca. Grazie al meccanismo dei Lock sarà possibile controllare la competizione tra i vari thread, assicurandoci che ognuno di essi svolga le sue attività senza le interferenze non volute degli altri thread.
Nel nostro esempio, ogni thread dovrà ricevere il lock, effettuare le operazioni di read-calculate-write e poi restituire il lock agli altri. In questo modo avremo il controllo sulla corretta sequenza di eventi tra i vari thread eliminando la race condition.
Per utilizare i lock all’interno di un programma esistono una serie di funzioni. Le funzioni acquire() e release() , per esempio, hanno il compito di far acquisire e rilasciare il lock. Se il lock fosse già in possesso di un altro thread, il thread chiamante rimarrà in attesa finchè il lock non verrà rilasciato.
Attenzione, fin qui sembra tutto positivo. Ci potrebbe essere il caso che un thread che ha ricevuto un lock, per qualche ragione, non lo restituisca mai. In questo caso il programma rimarrebbe completamente bloccato.
Per evitare questi casi, i Lock del modulo threading sono stati progettati in modo da funzionare anche come context manager, all’interno di una dichiarazione with. Con questo sistema, il lock verrà comunque rilasciato automaticamente quando il block with terminerà. In questo modo vedremo che non sarà necessario chiamare le funzioni acquire() e release(), ma sarà tutto gestito dal context manager.
Vediamo tutti questi concetti nell’esempio seguente.
Importiamo nel codice precedente il modulo threading
import threading
Poi all’interno della classe FakeDatabase, aggiungiamo un Lock.
class FakeDatabase: def __init__(self): self.value = 0 self._lock = threading.Lock()
Adesso invece di utilizzare acquire() e release(), utilizziamo il costrutto with creando un context manager che gestirà in modo efficiente il lock.
Scriviamo quindi nella funzione che implementa il thread, il context manager e all’interno di esso inseriamo tutto il codice che genererebbe la race condition.
with self._lock: print("Thread ", name , "has received the lock") … print("Thread ", name, " is releasing the lock")
Come possiamo vedere all’interno non vengono utilizzare le funzioni acquire() e release() per l’acquisizione e il rilascio del lock, ma viene gestito in maniera implicita ed efficiente dal context manager strutturato con with.
Al termine di tutte le modifiche e aggiunte il codice finale è il seguente:
import concurrent.futures import time import threading class FakeDatabase: def __init__(self): self.value = 0 self._lock = threading.Lock() def update(self, name): print("Thread ", name , "is reading the DB value") with self._lock: print("Thread ", name , "has received the lock") local_copy = self.value local_copy += 1 time.sleep(0.1) self.value = local_copy print("Thread ", name ,"has modified the DB value") print("Thread ", name, " is releasing the lock") database = FakeDatabase() with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor: for index in range(3): executor.submit(database.update, index+1) print("DB Value is ", database.value)
Eseguendolo, otterremo il seguente risultato
Dall’output si può vedere che ora i thread lavorano correttamente. Il thread 1 è il primo a ricevere il lock, poi solo quando avrà modificato il valore nel DB, il lock verrà rilasciato e passato al thread 2, e così via, in modo sequenziale (mutua esclusione).
Alla fine il valore presente sul DB è correttamente 3.
I Deadlock
L’utilizzo dei lock è una soluzione ottimale per evitare problemi di race condition, ma a volte, soprattutto se si utilizzano al di fuori del costrutto with possono creare alcuni problemi.
Abbiamo già detto che in questo caso sarà necessario gestire i lock con i metodi acquire() e release(), e una volta che il lock è stato acquisito da un thread, tutti gli altri thread dovranno attendere il suo rilascio prima di proseguire oltre. Questo, se non ben gestito, può portare al deadlock. Cioè, se per un qualche motivo il thread con il lock non lo rilascerà mai, il programma rimarrà bloccato.
import threading l = threading.Lock() ... l.acquire() ... l.release()
I deadlock normalmente avvengono in uno dei seguenti casi:
- Una cattiva implementazione dei thread dove un Lock non viene rilasciato propriamente
- Uno problema nello schema di progettazione di un programma che non prevede in modo corretto tutte le possibili chiamate da parte dei thread bloccati in attesa del lock, e che sono necessarie per il completamento del thread con il lock.
La prima situazione può essere abbastanza comune, ma usando un Lock come context manager riduce di molto la possibilità di finire in un deadlock. Si raccomanda quindi, dove possibile, di utilizzare sempre i context manager.
Per quanto riguarda il secondo caso, cioè i problemi con la progettazione, esiste nel modulo threading un’oggetto chiamato RLock, progettato per risolvere alcune di queste situazioni, non tutte.
Conclusioni
In questa quarta parte abbiamo visto altri aspetti del Multithreading come per esempio l’uso dei Lock e la problematica dei Deadlock. Nella parte seguente introdurremo il modello Producer-Consumer, che fa uso dei thread ed è molto comune nel mondo dello sviluppo informatico.