· Andrea Pollini · materiale didattico · 20 min read
Tecniche di sincronizzazione
Tecniche di sincronizzazione per i thread
La sincronizzazione è un processo critico nella programmazione concorrente che consente ai thread o ai processi di accedere in modo sicuro alle risorse condivise. Ci sono diverse tecniche di sincronizzazione utilizzate per evitare i problemi di concorrenza critica, come ad esempio:
- Semafori: un semaforo è un oggetto che consente a un numero limitato di thread o processi di accedere contemporaneamente a una risorsa condivisa. Il numero di thread o processi che possono accedere alla risorsa è determinato dal valore del semaforo.
- Mutex (Mutual Exclusion): un mutex è un oggetto che consente l’accesso esclusivo alle risorse condivise tra i thread o i processi. Solo un thread o processo può acquisire il mutex alla volta, impedendo così l’accesso concorrente alle risorse condivise.
- Variabili condizionali: una variabile condizionale è un oggetto che consente ai thread o ai processi di attendere fino a quando una certa condizione non viene soddisfatta. È spesso utilizzato insieme ad un mutex per implementare meccanismi di sincronizzazione più complessi.
Queste tecniche di sincronizzazione sono utilizzate per evitare i problemi di concorrenza critica, come la perdita di aggiornamenti e l’inconsistenza dei dati, quando si accede contemporaneamente alle stesse risorse condivise da più thread o processi.
Esempi di contesti con problemi di sincronizzazione
Accesso condiviso a risorse
quando più thread o processi devono accedere contemporaneamente alle stesse risorse condivise, come ad esempio un database o un file, è necessario utilizzare tecniche di sincronizzazione per evitare problemi di concorrenza critica. Ad esempio, si potrebbe utilizzare un mutex per garantire che solo un thread alla volta possa accedere al database e modificare i suoi valori.
Gestione di file
quando più processi devono accedere contemporaneamente allo stesso file, è necessario utilizzare tecniche di sincronizzazione per evitare problemi di concorrenza critica come la perdita di aggiornamenti o l’inconsistenza dei dati. Ad esempio, si potrebbe utilizzare un semaforo per garantire che solo un processo alla volta possa scrivere nel file.
Download paralleli
quando si scaricano più file contemporaneamente, è possibile utilizzare la concorrenza per aumentare la velocità di download. Ad esempio, si potrebbe utilizzare una pool di thread per scaricare più file contemporaneamente, con ciascun thread che gestisce il download di un singolo file.
Calcolo parallelo
quando si esegue un calcolo complesso su grandi quantità di dati, è possibile utilizzare la concorrenza per aumentare la velocità di elaborazione. Ad esempio, si potrebbe suddividere i dati in parti più piccole e assegnare ad ogni parte una parte del calcolo da eseguire su un thread separato.
La concorrenza può essere utilizzata in qualsiasi situazione in cui sia necessario eseguire più operazioni contemporaneamente per aumentare l’efficienza o la velocità di elaborazione. Tuttavia, è importante tenere presente che l’utilizzo della concorrenza richiede una buona gestione delle risorse condivise e una corretta implementazione delle tecniche di sincronizzazione per evitare problemi di concorrenza critica.
Problematiche relative all’utilizzo della concorrenza
La concorrenza è una caratteristica importante della programmazione moderna, che consente l’esecuzione simultanea di più operazioni su risorse condivise. Tuttavia, l’utilizzo della concorrenza può portare a problemi e limitazioni come il consumo di risorse, la gestione della sincronizzazione, l’aumento della complessità del codice, i deadlocks e il controllo della priorità.
Il consumo di risorse è un problema comune quando si utilizzano più thread o processi contemporaneamente. Ciò può portare a un aumento del consumo di memoria e CPU, con una possibile diminuzione delle prestazioni del sistema.
La gestione della sincronizzazione tra i thread o i processi può essere complessa e difficile da implementare correttamente. Se non viene gestita correttamente, possono verificarsi problemi di concorrenza critica come la perdita di aggiornamenti o l’inconsistenza dei dati.
L’utilizzo della concorrenza può rendere il codice più difficile da leggere e mantenere. Ciò è dovuto al fatto che i thread o i processi possono eseguire operazioni in modo asincrono, il che rende più difficile tracciare il flusso di esecuzione del programma.
I deadlocks si verificano quando due o più thread o processi si bloccano a vicenda, ciascuno in attesa di una risorsa detenuta dall’altro. Ciò può portare a un blocco completo del sistema e richiedere l’intervento dell’utente per risolvere il problema.
Il controllo della priorità è un altro problema comune quando si utilizzano più thread o processi contemporaneamente. Se non viene gestito correttamente, possono verificarsi problemi di priorità se alcuni thread o processi hanno una priorità più alta rispetto ad altri.
L’utilizzo della concorrenza può aumentare le prestazioni del sistema ma richiede una buona gestione delle risorse e una corretta implementazione delle tecniche di sincronizzazione per evitare problemi di concorrenza critica.
Race condition e dedalock
Ecco alcuni esempi classici di race condition e deadlock, insieme ad esempi di codice Python che li illustrano:
Race Condition:
Una race condition si verifica quando due o più thread accedono contemporaneamente a una risorsa condivisa senza alcuna sincronizzazione, causando risultati imprevisti.
Esempio classico: il problema dei filosofi a cena
Il problema dei filosofi a cena è un classico esempio di race condition e deadlock nella programmazione concorrente. Si tratta N filosofi che siedono intorno a un tavolo rotondo con un piatto di spaghetti al centro. Ogni filosofo ha un bicchiere di vino alla sua sinistra e uno alla sua destra. I filosofi passano il loro tempo mangiando e bevendo, ma ogni volta che un filosofo vuole bere, deve prendere entrambi i bicchieri contemporaneamente.
Il problema sorge quando tutti i filosofi vogliono bere contemporaneamente: poiché ogni filosofo deve prendere entrambi i bicchieri per poter bere, nessuno di loro può farlo. In questo modo si crea una situazione di deadlock, in cui tutti i filosofi sono bloccati e non possono proseguire con la loro cena.
Il problema dei filosofi a cena può essere risolto utilizzando diverse tecniche di sincronizzazione, come ad esempio l’utilizzo di semafori o lock con timeout. In questo modo si può evitare che tutti i filosofi cerchino di prendere i bicchieri contemporaneamente e si crei una situazione di deadlock.
In sintesi, il problema dei filosofi a cena è un esempio classico di race condition e deadlock nella programmazione concorrente, che può essere risolto utilizzando tecniche di sincronizzazione come semafori o lock con timeout.
Deadlock:
Un deadlock si verifica quando due o più thread sono bloccati a vicenda nell’attesa di una risorsa che non verrà mai rilasciata.
Esempio classico: il problema dei produttori e dei consumatori
Il problema dei produttori e consumatori è un esempio classico di race condition nella programmazione concorrente. Si tratta di una situazione in cui più thread, detti produttori, producono elementi che vengono inseriti in una coda condivisa, mentre altri thread, detti consumatori, prelevano gli elementi dalla coda e li elaborano.
Il problema sorge quando i produttori e i consumatori non sono sincronizzati correttamente. Ad esempio, se un produttore inserisce un elemento nella coda mentre un consumatore sta cercando di prelevare un elemento dalla stessa coda, può verificarsi una situazione di race condition in cui il consumatore preleva l’elemento appena inserito dal produttore invece che quello già presente nella coda.
Inoltre, se i produttori producono elementi più velocemente dei consumatori, la coda può riempirsi e bloccare i produttori nell’attesa che gli elementi vengano prelevati. Viceversa, se i consumatori elaborano gli elementi più velocemente dei produttori, la coda può svuotarsi e bloccare i consumatori nell’attesa che nuovi elementi vengano inseriti.
Per evitare queste situazioni di race condition e garantire una corretta sincronizzazione tra produttori e consumatori, è necessario utilizzare tecniche di sincronizzazione come semafori o lock con timeout. In questo modo si può evitare che i produttori e i consumatori si bloccino a vicenda e si crei una situazione di deadlock.
In questo esempio, i produttori producono items e li aggiungono alla coda condivisa, mentre i consumatori prelevano gli items dalla coda e li consumano. Tuttavia, se i produttori e i consumatori non sono sincronizzati correttamente, può verificarsi un deadlock in cui i produttori si bloccano nell’attesa che la coda sia vuota e i consumatori si bloccano nell’attesa che la coda sia piena.
I semafori
I semafori sono oggetti utilizzati per controllare l’accesso alle risorse condivise tra più thread o processi. Essi consentono di sincronizzare l’accesso alle risorse condivise in modo da evitare problemi di concorrenza critica, come la perdita di aggiornamenti o l’inconsistenza dei dati.
I semafori possono essere utilizzati per implementare meccanismi di sincronizzazione più complessi, come ad esempio le code (queue) o i monitor. Essi sono spesso utilizzati insieme ad altre tecniche di sincronizzazione, come i mutex e le variabili condizionali.
Semafori con i thread in python
In Python, il modulo threading
fornisce una classe Semaphore
che può essere utilizzata per creare semafori. La classe Semaphore
ha due attributi principali: il valore del semaforo (value
) e il numero di thread o processi che possono accedere contemporaneamente alla risorsa condivisa (acquired
).
Ecco un esempio di utilizzo della classe Semaphore
in Python:
In questo esempio, creiamo un semaforo con valore iniziale 1 e due funzioni funzione_1
e funzione_2
che eseguono un’operazione sulla risorsa condivisa. All’interno di ciascuna funzione, acquisiamo il semaforo utilizzando il metodo acquire()
prima di eseguire l’operazione e lo rilasciamo utilizzando il metodo release()
alla fine dell’operazione.
Creiamo quindi due thread e li avviamo utilizzando i metodi start()
e join()
. In questo modo, solo un thread può accedere alla risorsa condivisa alla volta, evitando problemi di concorrenza.
semafori con multiprocessing in python
I semafori possono essere utilizzati anche con il modulo multiprocessing
in Python per sincronizzare l’accesso alle risorse condivise tra più processi. In questo caso, la classe Semaphore
può essere utilizzata allo stesso modo della classe threading.Semaphore
, ma ci sono alcune differenze da tenere in considerazione.
Ecco un esempio di utilizzo dei semafori con il modulo multiprocessing
:
In questo esempio, creiamo un semaforo con valore iniziale 1 e due funzioni funzione_1
e funzione_2
che eseguono un’operazione sulla risorsa condivisa. All’interno di ciascuna funzione, acquisiamo il semaforo utilizzando il metodo acquire()
prima di eseguire l’operazione e lo rilasciamo utilizzando il metodo release()
alla fine dell’operazione.
Creiamo quindi due processi utilizzando la classe multiprocessing.Process
e li avviamo utilizzando i metodi start()
e join()
. In questo modo, solo un processo può accedere alla risorsa condivisa alla volta, evitando problemi di concorrenza.
Rispetto all’utilizzo dei semafori con i thread, ci sono alcune differenze da tenere in considerazione:
- I processi utilizzano una memoria separata, quindi le variabili condivise devono essere esplicitamente passate come argomenti alle funzioni o create utilizzando il modulo
multiprocessing.Manager
. Ciò significa che i semafori non possono essere condivisi tra thread e processi. - I processi sono più pesanti dei thread, quindi l’utilizzo di troppi processi può rallentare l’applicazione.
- I processi possono causare problemi di sincronizzazione più complessi rispetto ai thread, poiché hanno accesso a una memoria separata.
In generale, i semafori possono essere utilizzati sia con i thread che con i processi per sincronizzare l’accesso alle risorse condivise. Tuttavia, è importante tenere in considerazione le differenze tra thread e processi quando si sceglie la modalità di esecuzione da utilizzare.
Esercizi
Ecco alcuni esercizi e domande di riflessione ed approfondimento sui semafori:
- Scrivere una funzione in Python che utilizzi un semaforo per consentire l’accesso ad una risorsa condivisa solo a un numero limitato di thread o processi contemporaneamente.
- Descrivere come i semafori possono essere utilizzati per implementare meccanismi di sincronizzazione più complessi, come le code (queue) o i monitor.
- Riflettere su come i semafori possono essere utilizzati per evitare problemi di concorrenza in applicazioni multithread o multiprocess.
I Mutex
I mutex sono una sorta di “blocco” che impedisce l’accesso contemporaneo di più thread o processi ad una risorsa condivisa, garantendo così la mutua esclusione. In altre parole, i mutex permettono a un solo thread o processo alla volta di accedere ad una risorsa condivisa, evitando problemi di concorrenza e corruzione dei dati.
Immagina di avere una scatola di caramelle che devi dividere con i tuoi amici. Se più persone cercano di prendere le caramelle contemporaneamente, potrebbe verificarsi il caos e alcune caramelle potrebbero finire per terra o essere prese due volte. Per evitare questo problema, puoi usare un mutex come un “custode” che permette solo ad una persona alla volta di prendere le caramelle dalla scatola.
In Python, i mutex possono essere implementati utilizzando il modulo threading
o il modulo multiprocessing
. Ecco alcuni esempi:
mutex con il modulo threading
:
In questo esempio, creiamo una risorsa condivisa caramelle
e un lock mutex mutex
. All’interno della funzione prendi_caramella()
, acquisiamo il lock mutex utilizzando il metodo acquire()
prima di accedere alle caramelle e lo rilasciamo utilizzando il metodo release()
alla fine dell’accesso. In questo modo, solo un thread alla volta può prendere le caramelle dalla scatola.
mutex con il modulo multiprocessing
:
Esercizi
domande di riflessione e approfondimento sui mutex
- Qual è la differenza tra un lock mutex e un semaforo? In che situazioni è più appropriato utilizzare l’uno o l’altro?
- Come si può verificare se un lock mutex è già stato acquisito da un thread o processo prima di tentare di acquisirlo nuovamente?
- Quali sono i possibili problemi che possono verificarsi se non si utilizza un lock mutex quando si accede ad una risorsa condivisa tra più thread o processi?
Esercizi in Python per praticare l’utilizzo dei mutex
- Scrivere un programma in Python che utilizzi un lock mutex per garantire l’accesso esclusivo di due thread alla stessa variabile condivisa. I due thread dovrebbero incrementare la variabile condivisa di 1 ogni secondo, partendo da zero. Dopo 10 secondi, il programma dovrebbe stampare il valore finale della variabile condivisa.
- Scrivere un programma in Python che utilizzi un lock mutex per garantire l’accesso esclusivo di più processi alla stessa risorsa condivisa. I processi dovrebbero leggere e scrivere sulla risorsa condivisa in modo da non sovrascriversi a vicenda. Ad esempio, si potrebbe utilizzare una lista come risorsa condivisa e i processi potrebbero aggiungere elementi alla lista o rimuoverli.
- Scrivere un programma in Python che utilizzi un lock mutex per garantire l’accesso esclusivo di più thread ad una risorsa condivisa che rappresenta uno stack di interi. I thread dovrebbero essere in grado di push e pop gli elementi dallo stack senza corrompere i dati.
Le variabili condizionali
Le variabili condizionali sono una struttura di sincronizzazione utilizzata nei programmi che eseguono più thread o processi contemporaneamente. Permettono ai thread o processi di attendere fino a quando non si verifica una determinata condizione prima di procedere con l’esecuzione.
In Python, le variabili condizionali sono implementate nella libreria threading
come oggetti della classe Condition
. Una variabile condizionale è associata ad un lock mutex e viene utilizzata per sincronizzare l’accesso di più thread o processi ad una risorsa condivisa. I thread o processi possono attendere fino a quando non si verifica una determinata condizione utilizzando il metodo wait()
della variabile condizionale, mentre altri thread o processi possono notificare che la condizione è stata soddisfatta utilizzando il metodo notify()
o notify_all()
.
Ecco un esempio di utilizzo delle variabili condizionali in Python:
In questo esempio, creiamo una risorsa condivisa caramelle
e una variabile condizionale cond
associata ad un lock mutex. All’interno della funzione prendi_caramella()
, utilizziamo la variabile condizionale per attendere fino a quando non ci sono più di 5 caramelle nella scatola. Quando ci sono abbastanza caramelle, i thread possono procedere con l’accesso alla risorsa condivisa e prendere una caramella. Alla fine dell’esecuzione dei thread, notifichiamo la variabile condizionale che ci sono abbastanza caramelle nella scatola utilizzando il metodo notify_all()
.
Esercizi
domande di riflessione e approfondimento sulle variabili condizionali:
- Qual è la differenza tra l’utilizzo di una variabile condizionale rispetto all’utilizzo di un lock mutex per sincronizzare l’accesso di più thread o processi ad una risorsa condivisa?
- In che modo le variabili condizionali possono essere utilizzate per implementare un sistema di produzione e consumo in cui i produttori attendono fino a quando non ci sono abbastanza prodotti nel magazzino, mentre i consumatori attendono fino a quando non ci sono più prodotti nel magazzino?
- Come si può evitare il problema della “race condition” quando si utilizzano le variabili condizionali per sincronizzare l’accesso di più thread o processi ad una risorsa condivisa?
Esercizi per praticare l’utilizzo delle variabili condizionali in Python
- Scrivere un programma che utilizzi una variabile condizionale per sincronizzare l’accesso di più thread ad una risorsa condivisa rappresentata da una lista di interi. I thread dovrebbero aggiungere elementi alla lista e attendere fino a quando non ci sono abbastanza elementi nella lista prima di procedere con l’eliminazione degli elementi dalla lista.
- Scrivere un programma che utilizzi una variabile condizionale per implementare un sistema di produzione e consumo in cui i produttori producono oggetti e li aggiungono ad una coda, mentre i consumatori rimuovono gli oggetti dalla coda e li elaborano. I produttori dovrebbero attendere fino a quando non ci sono abbastanza oggetti nella coda prima di procedere con la produzione di nuovi oggetti, mentre i consumatori dovrebbero attendere fino a quando non ci sono più oggetti nella coda prima di procedere con l’eliminazione degli oggetti dalla coda.
- Scrivere un programma che utilizzi una variabile condizionale per sincronizzare l’accesso di più processi ad una risorsa condivisa rappresentata da un file di testo. I processi dovrebbero scrivere informazioni nel file di testo e attendere fino a quando non ci sono abbastanza righe nel file prima di procedere con la lettura delle informazioni dal file di testo.
Gli eventi
Gli eventi per thread e processi sono una caratteristica importante della programmazione concorrente, che permette ai programmi di rispondere alle azioni dei thread o dei processi esterni. Gli eventi possono essere utilizzati per eseguire determinate azioni quando si verifica un evento specifico, come l’interruzione di un processo o la terminazione di un thread.
In Python, gli eventi per thread e processi sono gestiti dalle classi Event
e Thread
. La classe Event
rappresenta l’evento stesso, mentre la classe Thread
è utilizzata per creare nuovi thread e gestire i loro eventi.
Ecco un esempio di utilizzo degli eventi per thread in Python:
In questo esempio, creiamo due thread e li avviamo. Definiamo una funzione funzione_thread()
che stampa un messaggio ogni volta che viene eseguita. Utilizziamo la classe Event
per creare un evento che indica quando i thread devono essere terminati. Impostiamo l’evento dopo 5 secondi utilizzando la classe Timer
, e blocchiamo il thread principale fino a quando i thread non vengono terminati.
esercizi
Domande di riflessione e approfondimento sugli eventi per thread e processi
- Qual è la differenza tra un evento e una funzione in Python?
- In che modo gli eventi possono essere utilizzati per creare programmi concorrenti?
- Come si può gestire più di un evento contemporaneamente in Python?
Esercizi per praticare l’utilizzo degli eventi per thread e processi in Python
- Scrivere un programma che utilizzi gli eventi per creare due thread che eseguono una funzione in modo concorrente. Quando uno dei thread termina, il programma deve stampare un messaggio.
- Creare un’applicazione che utilizzi gli eventi per gestire l’interruzione di un processo esterno. L’applicazione deve terminare quando il processo viene interrotto.
- Scrivere un programma che utilizzi gli eventi per creare una finestra di dialogo con un pulsante “Termina”. Quando si clicca sul pulsante, la finestra di dialogo deve chiudersi e tutti i thread associati devono essere terminati.