· Andrea Pollini · materiale didattico · 32 min read

gestione della concorrenza

gestione della concorrenza per i thread

gestione della concorrenza per i thread

La concorrenza è un paradigma di programmazione in cui più operazioni o processi vengono eseguiti contemporaneamente, condividendo le risorse del sistema. In informatica, la concorrenza è importante perché consente di migliorare l’efficienza e le prestazioni dei sistemi.

Grazie alla concorrenza, i programmi possono eseguire più compiti contemporaneamente, invece di attendere che un compito sia completato prima di passare al successivo. Ciò può essere particolarmente utile in situazioni in cui ci sono molte operazioni da eseguire o quando si lavora con grandi quantità di dati.

La concorrenza può essere implementata utilizzando diverse tecniche, come ad esempio i thread, i processi o le code di messaggi. Ogni tecnica ha i propri vantaggi e svantaggi e viene scelta in base alle esigenze specifiche del programma.

In generale, la concorrenza è un’abilità importante per gli sviluppatori di software perché consente loro di creare programmi più efficienti e reattivi. Tuttavia, la gestione della concorrenza può essere complessa e richiede una buona comprensione dei principi fondamentali e delle tecniche di programmazione avanzate.

Quando è importante la concorrenza?

In generale, la concorrenza può essere utile in tutte quelle situazioni in cui si desidera migliorare le prestazioni del sistema o eseguire compiti paralleli. Alcuni esempi di situazioni dove la concorrenza è importante sono:

  1. Esecuzione di compiti paralleli: la concorrenza può essere utilizzata per eseguire più compiti contemporaneamente, invece di attendere che un compito sia completato prima di passare al successivo. Ad esempio, in una applicazione web, la concorrenza può essere utilizzata per elaborare richieste simultanee da parte degli utenti, migliorando così le prestazioni del sistema.
  2. Elaborazione di grandi quantità di dati: quando si lavora con grandi quantità di dati, la concorrenza può essere utilizzata per suddividere il lavoro in più processi o thread, che elaborano i dati contemporaneamente. Ciò consente di ridurre i tempi di elaborazione e migliorare l’efficienza del sistema.
  3. Applicazioni multimediali: le applicazioni multimediali, come ad esempio i videogiochi o i software di editing video, possono trarre beneficio dalla concorrenza per eseguire operazioni parallele, come ad esempio il rendering di immagini o l’elaborazione di effetti speciali.
  4. Applicazioni scientifiche: le applicazioni scientifiche, come ad esempio la simulazione di fenomeni fisici o l’analisi di dati sperimentali, possono utilizzare la concorrenza per eseguire calcoli paralleli e migliorare così le prestazioni del sistema.

Implementazioni della concorrenza

Ecco una tabella che riassume le diverse modalità di implementazione della concorrenza che abbiamo analizzato:

Modalità di ImplementazioneDescrizioneVantaggiSvantaggi
ThreadUn thread è un’unità di esecuzione all’interno di un processo. I thread condividono lo stesso spazio di memoria e possono comunicare facilmente tra loro.- Basso overhead di creazione e gestione
- Facile da implementare
- Condivisione semplice delle risorse
- Concorrenza interna al processo può portare a problemi di sincronizzazione
- Gestione dei thread può essere complessa
ProcessiUn processo è un’istanza di un programma in esecuzione. I processi comunicano attraverso canali di input/output o socket.- Alta isolamento tra i processi
- Facile da implementare
- Flessibilità nell’utilizzo delle risorse
- Alto overhead di creazione e gestione
- Comunicazione tra processi può essere complessa
Code di MessaggiUn codice di messaggi è un meccanismo di comunicazione asincrona tra processi o thread. I messaggi vengono inviati e ricevuti attraverso una coda condivisa.- Alta flessibilità nella gestione dei messaggi
- Facile da implementare
- Concorrenza interna alla coda può portare a problemi di sincronizzazione
- Alto overhead di creazione e gestione
- Gestione della coda può essere complessa

che tipo di implementazione scegliere?

Suggerimenti su cosa scegliere in base al contesto:

  • Se si lavora con un numero limitato di task da eseguire contemporaneamente, i thread possono essere la scelta migliore per via del basso overhead di creazione e gestione.
  • Se si lavora con task che richiedono un alto grado di isolamento o se si prevede una comunicazione complessa tra i processi, i processi possono essere la scelta migliore.
  • Se si lavora con task che devono comunicare asincronamente o se si prevede una concorrenza interna alla coda, le code di messaggi possono essere la scelta migliore.

Problematiche nella gestione della concorrenza

La gestione della concorrenza può portare a diverse problematiche legate alla sincronizzazione tra i thread o i processi che accedono contemporaneamente alle stesse risorse. Due delle principali problematiche sono la perdita di aggiornamenti e l’inconsistenza dei dati.

perdita di aggiornamenti

La perdita di aggiornamenti si verifica quando due thread o processi modificano contemporaneamente la stessa risorsa e le modifiche effettuate da uno dei due vengono sovrascritte dalle modifiche dell’altro. Ad esempio, supponiamo che due thread accedano contemporaneamente a una variabile condivisa x e la incrementino di 1. Se il primo thread esegue l’operazione x = x + 1 e il secondo thread esegue la stessa operazione prima che il primo thread possa scrivere il valore aggiornato di x, allora il valore finale di x sarà 2 invece di 3.

inconsistenza dei dati

L’inconsistenza dei dati si verifica quando due thread o processi accedono contemporaneamente alle stesse risorse e leggono valori non aggiornati o parzialmente aggiornati. Ad esempio, supponiamo che due thread accedano contemporaneamente a una struttura dati condivisa data e la modificano in modo indipendente. Se il primo thread legge i valori di data prima che il secondo thread possa scrivere i propri aggiornamenti, allora il primo thread vedrà un valore inconsistente di data.

Come affrontare il problema?

Per evitare queste problematiche, è necessario utilizzare tecniche di sincronizzazione appropriate come ad esempio i lock o le variabili atomiche. In questo modo si garantisce che solo un thread alla volta possa accedere alle risorse condivise e modificare i loro valori, evitando così la perdita di aggiornamenti e l’inconsistenza dei dati.

Tutte queste tecniche di sincronizzazione possono essere utilizzate per evitare problematiche come la perdita di aggiornamenti e l’inconsistenza dei dati quando si accede contemporaneamente alle stesse risorse condivise da più thread o processi.

Inoltre, alcune di queste tecniche sono peculiari dei thread mentre altre vengono utilizzate sia per i thread che per i processi. Ad esempio, i lock e le conditional variables sono spesso utilizzati per la sincronizzazione tra thread, mentre i semafori ed eventi possono essere utilizzati sia per la sincronizzazione tra thread che tra processi. Le code invece sono spesso utilizzate per implementare meccanismi di produzione-consumo tra thread o processi.

Ecco una tabella riassuntiva delle tecniche di sincronizzazione più comuni (in python):

Tecnica di SincronizzazioneDescrizioneUtilizzata per
LockUn lock è un meccanismo che consente a un solo thread alla volta di accedere a una risorsa condivisa. Quando un thread acquisisce il lock, nessun altro thread può acquisire il lock fino a quando il primo thread non lo rilascia.Thread
Conditional VariableUna conditional variable è un oggetto che consente ai thread di attendere fino a quando una certa condizione non viene soddisfatta. È spesso utilizzato insieme a un lock per implementare meccanismi di sincronizzazione più complessi.Thread
SemaphoreUn 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.Thread, Processi
EventUn evento è un oggetto che può essere impostato o azzerato da un thread o processo e verificato da altri thread o processi. È spesso utilizzato per implementare meccanismi di sincronizzazione tra thread o processi che non condividono alcuna risorsa.Thread, Processi
QueueUna coda (queue) è un contenitore che consente ai thread o processi di aggiungere o rimuovere elementi in modo sicuro e sincronizzato. È spesso utilizzato per implementare meccanismi di produzione-consumo tra thread o processi.Thread, Processi

Tecniche di sincronizzazione

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:

  1. 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.
  2. 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.
  3. 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.

import threading
import time
# Definiamo la classe Filosofo
class Filosofo(threading.Thread):
def __init__(self, name, fork_on_left, fork_on_right):
self.name = name
self.fork_on_left = fork_on_left
self.fork_on_right = fork_on_right
self.left_fork = None
self.right_fork = None
# Metodo run per il thread del filosofo
def run(self):
while True:
print(f"{self.name} è affamato")
time.sleep(1)
self.pick_up_forks()
time.sleep(1)
self.put_down_forks()
# Metodo per prendere le forchette
def pick_up_forks(self):
self.left_fork = self.fork_on_left
self.right_fork = self.fork_on_right
if self.left_fork and self.right_fork:
print(f"{self.name} ha preso entrambe le forchette")
else:
print(f"{self.name} non può prendere le forchette")
# Metodo per posare le forchette
def put_down_forks(self):
if self.left_fork and self.right_fork:
print(f"{self.name} ha posato entrambe le forchette")
self.left_fork = None
self.right_fork = None
# Creiamo i filosofi e le forchette
philosophers = ["Platon", "Aristotele", "Socrate", "Pitagora"]
forks = [threading.Lock() for _ in range(4)]
# Creiamo i thread per i filosofi
threads = []
for i, name in enumerate(philosophers):
if i == len(philosophers) - 1:
left_fork = forks[0]
right_fork = forks[i]
else:
left_fork = forks[i + 1]
right_fork = forks[i]
threads.append(Filosofo(name, left_fork, right_fork))
# Avviamo i thread
for t in threads:
t.start()
# Uniamo i thread
for t in threads:
t.join()

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.

import threading
# Definiamo la classe Produttore
class Produttore(threading.Thread):
def __init__(self, queue):
self.queue = queue
# Metodo run per il thread del produttore
def run(self):
while True:
item = produce_item()
self.queue.put(item)
print(f"Prodotto {item} aggiunto alla coda")
# Definiamo la classe Consumatore
class Consumatore(threading.Thread):
def __init__(self, queue):
self.queue = queue
# Metodo run per il thread del consumatore
def run(self):
while True:
item = self.queue.get()
consume_item(item)
print(f"Consumato {item}")
# Creiamo la coda condivisa tra produttori e consumatori
queue = Queue.Queue()
# Creiamo i produttori e i consumatori come thread
produttori = [Produttore(queue) for _ in range(3)]
consumatori = [Consumatore(queue) for _ in range(2)]
# Avviamo i thread dei produttori e dei consumatori
for p in produttori:
p.start()
for c in consumatori:
c.start()
# Uniamo i thread dei produttori e dei consumatori
for p in produttori:
p.join()
for c in consumatori:
c.join()

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:

import threading
# Creiamo un semaforo con valore iniziale 1
sem = threading.Semaphore(1)
def funzione_1():
# Acquisiamo il semaforo
sem.acquire()
try:
# Eseguiamo l'operazione sulla risorsa condivisa
print("Operazione in corso...")
finally:
# Rilasciamo il semaforo
sem.release()
def funzione_2():
# Acquisiamo il semaforo
sem.acquire()
try:
# Eseguiamo l'operazione sulla risorsa condivisa
print("Operazione in corso...")
finally:
# Rilasciamo il semaforo
sem.release()
# Creiamo due thread e li avviamo
t1 = threading.Thread(target=funzione_1)
t2 = threading.Thread(target=funzione_2)
t1.start()
t2.start()
# Attendiamo che entrambi i thread terminino l'esecuzione
t1.join()
t2.join()

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:

import multiprocessing
# Creiamo un semaforo con valore iniziale 1
sem = multiprocessing.Semaphore(1)
def funzione_1():
# Acquisiamo il semaforo
sem.acquire()
try:
# Eseguiamo l'operazione sulla risorsa condivisa
print("Operazione in corso...")
finally:
# Rilasciamo il semaforo
sem.release()
def funzione_2():
# Acquisiamo il semaforo
sem.acquire()
try:
# Eseguiamo l'operazione sulla risorsa condivisa
print("Operazione in corso...")
finally:
# Rilasciamo il semaforo
sem.release()
# Creiamo due processi e li avviamo
p1 = multiprocessing.Process(target=funzione_1)
p2 = multiprocessing.Process(target=funzione_2)
p1.start()
p2.start()
# Attendiamo che entrambi i processi terminino l'esecuzione
p1.join()
p2.join()

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:

import threading
# Definiamo una risorsa condivisa
caramelle = 10
# Definiamo un lock mutex
mutex = threading.Lock()
def prendi_caramella():
global caramelle
# Acquisiamo il lock mutex
mutex.acquire()
try:
# Verifichiamo se ci sono ancora caramelle nella scatola
if caramelle > 0:
print("Prendo una caramella!")
caramelle -= 1
else:
print("Mi dispiace, non ci sono più caramelle!")
finally:
# Rilasciamo il lock mutex
mutex.release()
# Creiamo tre thread e li avviamo
t1 = threading.Thread(target=prendi_caramella)
t2 = threading.Thread(target=prendi_caramella)
t3 = threading.Thread(target=prendi_caramella)
t1.start()
t2.start()
t3.start()
# Attendiamo che tutti i thread terminino l'esecuzione
t1.join()
t2.join()
t3.join()

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:

import multiprocessing
# Definiamo una risorsa condivisa
caramelle = 10
# Definiamo un lock mutex
mutex = multiprocessing.Lock()
def prendi_caramella(conn):
global caramelle
# Acquisiamo il lock mutex
mutex.acquire()
try:
# Verifichiamo se ci sono ancora caramelle nella scatola
if caramelle > 0:
print("Prendo una caramella!")
caramelle -= 1
conn.send(caramelle)
else:
print("Mi dispiace, non ci sono più caramelle!")
finally:
# Rilasciamo il lock mutex
mutex.release()
# Creiamo due processi e li avviamo
conn1, conn2 = multiprocessing.Pipe()
p1 = multiprocessing.Process(target=prendi_caramella, args=(conn1,))

Esercizi

domande di riflessione e approfondimento sui mutex

  1. Qual è la differenza tra un lock mutex e un semaforo? In che situazioni è più appropriato utilizzare l’uno o l’altro?
  2. Come si può verificare se un lock mutex è già stato acquisito da un thread o processo prima di tentare di acquisirlo nuovamente?
  3. 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

  1. 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.
  2. 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.
  3. 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:

import threading
# Definiamo una risorsa condivisa
caramelle = 10
# Definiamo una variabile condizionale associata ad un lock mutex
cond = threading.Condition()
def prendi_caramella():
global caramelle
# Acquisiamo il lock mutex associato alla variabile condizionale
with cond:
# Attendiamo fino a quando non ci sono più di 5 caramelle nella scatola
while caramelle <= 5:
print("Non ci sono abbastanza caramelle, attendo...")
cond.wait()
# Verifichiamo se ci sono ancora caramelle nella scatola
if caramelle > 0:
print("Prendo una caramella!")
caramelle -= 1
else:
print("Mi dispiace, non ci sono più caramelle!")
# Creiamo tre thread e li avviamo
t1 = threading.Thread(target=prendi_caramella)
t2 = threading.Thread(target=prendi_caramella)
t3 = threading.Thread(target=prendi_caramella)
t1.start()
t2.start()
t3.start()
# Attendiamo che tutti i thread terminino l'esecuzione
t1.join()
t2.join()
t3.join()
# Notifichiamo i thread che ci sono abbastanza caramelle nella scatola
with cond:
caramelle = 10
cond.notify_all()

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:

  1. 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?
  2. 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?
  3. 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

  1. 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.
  2. 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.
  3. 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:

import threading
# Definiamo una funzione che stampa il numero di volta in cui viene eseguita
def funzione_thread(numero):
print(f"Esecuzione del thread {numero}")
# Creiamo un evento per terminare il thread
evento = threading.Event()
# Eseguiamo la funzione fino a quando l'evento non viene impostato
while not evento.is_set():
pass
print(f"Terminazione del thread {numero}")
# Creiamo due thread e li avviamo
thread1 = threading.Thread(target=funzione_thread, args=(1,))
thread2 = threading.Thread(target=funzione_thread, args=(2,))
thread1.start()
thread2.start()
# Impostiamo l'evento per terminare i thread dopo 5 secondi
evento = threading.Event()
threading.Timer(5, evento.set).start()
# Blocchiamo il thread principale fino a quando i thread non vengono terminati
thread1.join()
thread2.join()

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

  1. Qual è la differenza tra un evento e una funzione in Python?
  2. In che modo gli eventi possono essere utilizzati per creare programmi concorrenti?
  3. Come si può gestire più di un evento contemporaneamente in Python?

Esercizi per praticare l’utilizzo degli eventi per thread e processi in Python

  1. 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.
  2. Creare un’applicazione che utilizzi gli eventi per gestire l’interruzione di un processo esterno. L’applicazione deve terminare quando il processo viene interrotto.
  3. 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.

90-esercizi sulla concorrenza

  1. Scrivere un programma che utilizzi due thread per contare da 0 a 100 in parallelo.
  2. Creare un programma che utilizzi più processi per calcolare la somma dei numeri da 1 a 1000000 utilizzando la concorrenza.
  3. Scrivere un programma che utilizzi thread per scaricare contemporaneamente più file da Internet.
  4. Creare un’applicazione che utilizzi la concorrenza per calcolare il fattoriale di un numero molto grande.
  5. Scrivere un programma che utilizzi processi per eseguire una ricerca parallela in una lista di nomi.
  6. Creare un’applicazione che utilizzi thread per gestire contemporaneamente più connessioni client in una chat room.
  7. Scrivere un programma che utilizzi la concorrenza per calcolare il prodotto scalare tra due vettori di grandi dimensioni.
  8. Creare un’applicazione che utilizzi processi per eseguire una ricerca parallela in una grande base di dati.
  9. Scrivere un programma che utilizzi thread per elaborare contemporaneamente più immagini in formato JPEG.
  10. Creare un’applicazione che utilizzi la concorrenza per calcolare il valore di una funzione matematica utilizzando diverse tecniche di ottimizzazione.

Per questi esercizi, è possibile utilizzare i seguenti moduli Python:

  • threading: per creare e gestire thread
  • multiprocessing: per creare e gestire processi
  • queue: per condividere dati tra thread o processi
  • mutex: per sincronizzare l’accesso a risorse condivise
  • condition: per gestire condizioni di corsa tra thread o processi
  • semaphore: per limitare l’accesso a risorse condivise

Proposte per il laboratorio

  1. Creazione di un’applicazione per la gestione di una lista condivisa tra più processi, utilizzando tecnologie come multiprocessing o concurrent.futures. Gli studenti potrebbero lavorare insieme per creare le funzionalità di base come l’aggiunta e la rimozione di elementi dalla lista e sviluppare un sistema di sincronizzazione utilizzando lock o semafori.
  2. Sviluppo di un’applicazione per la gestione di una chat room condivisa tra più processi, utilizzando tecnologie come multiprocessing o concurrent.futures. Gli studenti potrebbero lavorare insieme per creare le funzionalità di base come l’invio e la ricezione di messaggi e sviluppare un sistema di sincronizzazione utilizzando lock o semafori.
  3. Creazione di un’applicazione per la gestione di una piattaforma di gioco online condivisa tra più processi, utilizzando tecnologie come multiprocessing o concurrent.futures. Gli studenti potrebbero lavorare insieme per creare le funzionalità di base come il movimento dei pezzi sulla scacchiera e sviluppare un sistema di sincronizzazione utilizzando lock o semafori.
  4. Sviluppo di un’applicazione per la gestione di una piattaforma di collaborazione online condivisa tra più processi, utilizzando tecnologie come multiprocessing o concurrent.futures. Gli studenti potrebbero lavorare insieme per creare le funzionalità di base come la condivisione di documenti e sviluppare un sistema di sincronizzazione utilizzando lock o semafori.
  5. Creazione di un’applicazione per la gestione di una piattaforma di streaming video condivisa tra più processi, utilizzando tecnologie come multiprocessing o concurrent.futures. Gli studenti potrebbero lavorare insieme per creare le funzionalità di base come la riproduzione di video e sviluppare un sistema di sincronizzazione utilizzando lock o semafori.

Soluzioni

Presentiamo la soluzione di alcuni dei progetti proposti.

1. lista condivisa

Ecco un esempio di implementazione di una lista condivisa tra più processi in Python utilizzando il modulo multiprocessing:

import multiprocessing
class SharedList(object):
def __init__(self):
self.lock = multiprocessing.Lock()
self.list = []
def add(self, item):
with self.lock:
self.list.append(item)
def remove(self, item):
with self.lock:
if item in self.list:
self.list.remove(item)
def worker(shared_list, name):
for i in range(10):
shared_list.add(f"Item {i} added by {name}")
print(f"{name} added Item {i}")
if __name__ == "__main__":
shared_list = SharedList()
processes = []
# Creazione di due processi
for i in range(2):
p = multiprocessing.Process(target=worker, args=(shared_list, f"Processo {i+1}"))
processes.append(p)
p.start()
# Attesa della fine dei processi
for p in processes:
p.join()
print("Lista condivisa:")
with shared_list.lock:
for item in shared_list.list:
print(item)

In questo esempio, la classe SharedList rappresenta una lista condivisa tra più processi. La lista è protetta da un lock per evitare conflitti di accesso tra i processi.

La funzione worker rappresenta il compito che viene eseguito dai processi. Ogni processo aggiunge 10 elementi alla lista condivisa e stampa un messaggio per indicare l’aggiunta dell’elemento.

Infine, nel blocco if __name__ == "__main__":, vengono creati due processi utilizzando la funzione multiprocessing.Process(). I processi eseguono la funzione worker() passandogli come argomenti la lista condivisa e il nome del processo. Alla fine dell’esecuzione dei processi, viene stampata la lista condivisa.

Nota: questo esempio è solo un esempio di base e potrebbe essere migliorato o adattato alle esigenze specifiche del progetto.


95-Esercizi sulla sincronizzazione

La sincronizzazione è una parte importante della programmazione concorrente in Python. In questa sezione troverai 20 esercizi sulla sincronizzazione, divisi in tre livelli di difficoltà crescente.

Livello 1: Basics

  1. Conta fino a 10: Crea due thread che stampano i numeri da 1 a 10. Utilizza il modulo threading per creare e avviare i thread.
  2. Somma di numeri: Crea due thread che sommano i numeri da 1 a 100. Utilizza il modulo threading per creare e avviare i thread, e utilizza una variabile condivisa per memorizzare il risultato finale.
  3. Timer: Crea un thread che stampa l’ora corrente ogni secondo. Utilizza il modulo threading e la funzione time.sleep() per implementare il timer.

Livello 2: Locks

  1. Conteggio condiviso: Crea due thread che incrementano una variabile condivisa. Utilizza un lock per evitare race conditions.
  2. Accesso condiviso a una risorsa: Crea due thread che accedono a una risorsa condivisa (ad esempio, un file). Utilizza un lock per garantire l’accesso esclusivo alla risorsa.
  3. Protezione di una lista condivisa: Crea una lista condivisa e due thread che aggiungono elementi alla lista. Utilizza un lock per proteggere la lista dalle modifiche simultanee.

Livello 3: Condizioni e eventi

  1. Produttore/consumatore: Crea due thread, uno che produce numeri e l’altro che li consuma. Utilizza una coda condivisa e le condizioni per sincronizzare i thread.
  2. Barriera di sincronizzazione: Crea tre thread che stampano un messaggio in ordine sequenziale. Utilizza una barriera di sincronizzazione per garantire che i thread stampino il messaggio nella giusta sequenza.
  3. Eventi di terminazione: Crea due thread e utilizza gli eventi per gestire la loro terminazione. Il primo thread deve attendere che il secondo termini prima di terminare a sua volta.

Livello 4: Sincronizzazione avanzata

  1. Semafori: Crea tre thread che accedono a una risorsa condivisa. Utilizza i semafori per controllare l’accesso alla risorsa e prevenire race conditions.
  2. Lock con timeout: Modifica l’esercizio 4 del livello 2 in modo da utilizzare un lock con timeout per evitare deadlocks.
  3. Thread pool: Crea una pool di thread e utilizza le condizioni per gestire i task in ingresso e in uscita dalla pool.

Livello 5: Mix

  1. Produttore/consumatore con semafori: Modifica l’esercizio 1 del livello 4 in modo da utilizzare i semafori invece delle condizioni per sincronizzare i thread.
  2. Barriera di sincronizzazione con eventi: Modifica l’esercizio 3 del livello 3 in modo da utilizzare le barriere di sincronizzazione invece degli eventi per gestire la terminazione dei thread.
  3. Lock condiviso tra processi: Crea due processi che accedono a una risorsa condivisa. Utilizza un lock condiviso tra i processi per garantire l’accesso esclusivo alla risosa.
  4. Sincronizzazione con file di lock: Crea due thread che scrivono su un file condiviso. Utilizza un file di lock per sincronizzare l’accesso al file e prevenire race conditions.
  5. Sincronizzazione con pipe: Crea due processi che comunicano attraverso una pipe. Utilizza le pipe per sincronizzare i processi e scambiare dati tra di loro.
    Back to Blog

    Related Posts

    View All Posts »
    TPSIT: Proposta per un percorso moderno

    TPSIT: Proposta per un percorso moderno

    Da quando sono docente ho sempre insegnato la materia di TPSIT, Tecnologie e Progettazione di Sistemi Informatici e di telecomunicazioni. Ho sempre cercato di proporre un approccio moderno, cercando di coinvolgere gli studenti in progetti reali, con l'obiettivo di farli appassionare a questa materia, che è molto importante per il loro futuro lavorativo. In questo articolo propongo un percorso moderno, che ho sperimentato con successo, e che può essere utilizzato da altri docenti per proporre un percorso simile ai loro studenti.

    le user story

    le user story

    capiamo in modo facile l'utilità e le caratteristiche delle user story

    Qual è la differenza tra processo e thread?

    Qual è la differenza tra processo e thread?

    un processo è un'istanza di un programma in esecuzione con il proprio spazio di indirizzamento e risorse, mentre un thread è un sottoprocesso all'interno di un processo principale che condivide lo stesso spazio di indirizzamento e le risorse con il processo principale.