In questa lezione impari a
- distinguere processi e thread
- usare threading e multiprocessing in Python
- riconoscere una race condition e risolverla con un Lock
- far comunicare thread e processi con una Queue
Come far fare più cose insieme al proprio programma: thread, processi, e i problemi (divertenti) che nascono quando più parti di codice lavorano nello stesso momento.
ottobre – dicembre · ~10 settimane · 30 ore · 5 moduli + progetto
Se non l'hai ancora fatto, parti dalla lezione Le API: qui la diamo per conosciuta. In particolare, gli errori di rete (
ConnectionError) e la loro gestione contry/exceptsono spiegati là: nei thread che chiamano servizi esterni li useremo spesso, quindi tieni quella pagina a portata di mano.
1.1 Processi vs Thread
Durata indicativa: 2 settimane
Un processo è come un ristorante indipendente: ha la sua cucina, i suoi ingredienti, il suo personale. Se il ristorante A ha un problema in cucina, il ristorante B di fianco continua a lavorare tranquillamente, perché non condividono nulla. Un thread, invece, è come un cuoco che lavora dentro uno di quei ristoranti: più cuochi (thread) nella stessa cucina condividono gli stessi fornelli e gli stessi ingredienti — cioè la stessa memoria del programma.
In pratica: quando avviate un programma (es. il browser, VS Code), il sistema operativo crea per lui un processo tutto suo, con la sua zona di memoria privata. Dentro quel processo però possono lavorare più thread contemporaneamente, che si dividono la stessa memoria.
Perché ci servono? Due motivi principali:
- Reattività — mentre un thread aspetta qualcosa di lento (es. una risposta di rete), un altro può continuare a lavorare, invece di bloccare tutto il programma
- Sfruttare più core — un computer moderno ha più "cervelli" (core) che possono lavorare in parallelo davvero, nello stesso istante
I metodi che userete sempre: creare, avviare, aspettare
Sia per un processo che per un thread, in Python si lavora sempre con lo stesso schema in tre passi. Vediamolo con un cameriere invece che con un cuoco, è più facile da immaginare:
Process(target=funzione, args=(...))(oThread(...)) — crea l'oggetto, ma non fa ancora nulla. È come assumere un cameriere e dirgli cosa dovrà fare: finché non gli dite "vai", resta fermo..start()— lo avvia davvero: da questo momento, la funzione indicata intargetcomincia a essere eseguita "in background", e il programma principale continua subito a fare altro, senza aspettare che finisca..join()— aspetta che quel processo/thread abbia terminato il suo lavoro prima di andare avanti. È come dire "non me ne vado dal ristorante finché quel cameriere non ha finito il suo giro".
Attenzione: Un errore comune: creare, avviare e aspettare uno alla volta nello stesso ciclo (
p.start(); p.join()dentro lo stessofor). Così facendo si perde tutto il vantaggio della concorrenza: il programma aspetta che il primo finisca prima di far partire il secondo, esattamente come se fossero sequenziali! Per questo, quando si creano più processi/thread insieme, si usano due cicli separati: prima si avviano tutti (così partono "in parallelo"), solo dopo si aspetta ciascuno conjoin().
Un processo "figlio", per vedere la differenza
Prima di arrivare ai thread, vediamo come si crea un secondo processo indipendente con multiprocessing.Process — ha la sua memoria separata, tanto che la variabile contatore modificata dentro non cambia quella del programma principale.
from multiprocessing import Process
contatore = 0
def lavora():
global contatore
contatore += 100
print(f"Dentro il processo figlio: contatore = {contatore}")
if __name__ == "__main__":
p = Process(target=lavora) # 1. lo creo
p.start() # 2. lo avvio
p.join() # 3. aspetto che finisca
print(f"Nel programma principale: contatore = {contatore}")
Il risultato è
100dentro il processo figlio, ma0nel programma principale: il processo figlio ha ricevuto una copia separata di tutta la memoria, "cucina propria" per l'appunto. Le modifiche fatte lì dentro non tornano indietro da sole.
Più processi figlio insieme
Come per i thread, si possono creare più processi in un ciclo, passando a ciascuno un numero identificativo. Qui si vede bene perché servono due cicli separati: uno che crea e avvia (start()), un altro — dopo — che aspetta (join()). Ogni processo avviato viene anche salvato in una lista (processi.append(p)), altrimenti nel secondo ciclo non avremmo più modo di riferirci a lui per aspettarlo:
from multiprocessing import Process
import os
def lavora(numero):
print(f"Processo {numero} — PID assegnato dal sistema: {os.getpid()}")
if __name__ == "__main__":
processi = []
for i in range(4):
p = Process(target=lavora, args=(i,)) # creo il processo i-esimo
processi.append(p) # lo salvo per poterlo aspettare dopo
p.start() # lo avvio subito: parte in background
# solo ORA, dopo aver avviato tutti, li aspetto uno per uno
for p in processi:
p.join()
Se invece scriveste p.start() seguito subito da p.join() dentro lo stesso ciclo, il programma aspetterebbe la fine del processo 0 prima ancora di creare il processo 1: avreste scritto, senza accorgervene, una versione sequenziale travestita da parallela.
Ogni processo stampa un PID (Process ID) diverso: è il numero con cui il sistema operativo tiene traccia di ciascun processo attivo sul computer, esattamente come quelli che vedete aprendo il Task Manager (Windows) o htop (Linux).
Un esempio che conoscete già: Chrome usa esattamente questa idea. Ogni scheda aperta su un sito diverso gira in un proprio renderer process separato (potete verificarlo aprendo il Task Manager di Chrome con
Shift+Esc: ogni scheda ha un PID diverso). Per questo se una scheda va in crash, di solito le altre continuano a funzionare: sono ristoranti diversi, non condividono la cucina. Se invece tutte le schede girassero come thread di un unico processo, un crash grave in una scheda rischierebbe di trascinarsi dietro anche le altre, perché condividerebbero la stessa memoria.
Attenzione a non confondere le cose: avviare l'eseguibile di Chrome una seconda volta di solito non crea un nuovo processo indipendente — Chrome rileva che è già in esecuzione e si limita ad aprire una nuova finestra dentro lo stesso "gruppo" di processi. Sono le schede (su siti diversi), non le finestre in sé, a corrispondere a processi separati.
Il modulo threading: stessa cucina, più cuochi
Un thread, al contrario, vive dentro lo stesso processo e vede la stessa memoria. Lo si crea allo stesso modo, ma con threading.Thread invece di Process.
import threading
import time
def scarica_pagina(n):
print(f"Inizio download pagina {n}")
time.sleep(2) # simula il tempo di download
print(f"Pagina {n} scaricata")
inizio = time.time()
# creiamo e avviamo 5 thread, uno per pagina
lista_thread = []
for i in range(5):
t = threading.Thread(target=scarica_pagina, args=(i,))
lista_thread.append(t)
t.start()
# aspettiamo che tutti abbiano finito
for t in lista_thread:
t.join()
print(f"Tempo totale: {time.time() - inizio:.2f} secondi")
t.join()dice al programma principale "aspetta che questo thread abbia finito prima di andare avanti". Senza ijoin(), il programma potrebbe stampare "Tempo totale" prima ancora che i download siano finiti.
Esercizio pratico.
- Create 3-4 processi con
Processcome nell'esempio, ognuno che stampa il proprio numero identificativo e il proprio PID - Aprite il Task Manager (o
htopsu Linux) mentre il programma gira: provate a individuare i PID stampati dal programma - Scrivete due versioni dello stesso programma che "scarica" 5 pagine finte (con
time.sleep(2)per simulare il tempo di download):- Versione sequenziale — un normale ciclo
forche chiama la funzione una volta per pagina, una dopo l'altra - Versione con thread — come nell'esempio sopra, un thread per ogni pagina
- Versione sequenziale — un normale ciclo
- Misurate il tempo di entrambe le versioni con
time.time()e confrontatele
Cosa dovete osservare: la versione sequenziale impiegherà circa 10 secondi (5 × 2s), quella con i thread solo poco più di 2 secondi, perché i "download" avvengono tutti insieme.
Applicazione reale — isolare codice a rischio di crash. Contesto: immaginate un programma che deve elaborare file caricati dagli utenti (es. convertire immagini, leggere PDF). Se un file è corrotto e il codice che lo elabora va in crash, l'intero programma potrebbe chiudersi — anche se stava gestendo altri 100 file perfettamente validi. Eseguire il codice "a rischio" in un processo separato protegge il programma principale: se il figlio muore, il padre resta vivo.
Scrivete una funzione
elabora_file(nome)che genera un errore (es.raise ValueError("file corrotto")) senomecontiene la parola"corrotto", altrimenti stampa"file elaborato correttamente". Chiamatela su una lista di 5 nomi di file finti, di cui uno contiene"corrotto":
- Prima chiamatela normalmente, nello stesso processo, dentro un ciclo
for: cosa succede al programma quando arriva il file corrotto?- Poi eseguite ogni chiamata dentro un
Processseparato (uno per file), conp.join()subito dopo ciascuno: cosa cambia?Obiettivo misurabile: nella versione con i processi, il programma deve arrivare a elaborare (o tentare di elaborare) tutti e 5 i file, stampando un messaggio anche per quelli dopo il file corrotto — cosa che nella versione senza processi non succede, perché il programma si ferma all'errore.
Applicazione reale — scaricare dati da più fonti (thread). Contesto: ogni volta che un'app recupera dati da più fonti esterne (più città, più server, più contatti), i thread evitano di aspettare una fonte alla volta.
Non hai mai usato le API? Prima di procedere, leggi la pagina Le API spiegate bene: trovi cos'è una Web API, come funziona HTTP, il formato JSON, come si usa
requestse gli errori più comuni.Prima di iniziare — la libreria
requests: se non l'avete mai usata, vi basta questo per l'esercizio (se non è già installata, da terminale:pip install requests).requests.get(url)fa una richiesta HTTP GET (lo stesso concetto visto nella pagina di teoria) e restituisce una risposta;.json()la trasforma da testo a dizionario Python, pronta da leggere:import requests lat, lon = 45.07, 7.69 # coordinate di Torino, cambiatele per un'altra città url = ( f"https://api.open-meteo.com/v1/forecast" f"?latitude={lat}&longitude={lon}¤t_weather=true" ) risposta = requests.get(url) dati = risposta.json() # da testo JSON a dizionario Python temperatura = dati["current_weather"]["temperature"] print(f"Temperatura attuale: {temperatura}°C")Potete trovare latitudine e longitudine di qualsiasi città cercando su Google "nome città latitudine longitudine". La struttura del dizionario
datirestituito potete esplorarla conprint(dati)prima di andare a cercare la chiave che vi serve.Trovare le coordinate via codice (invece che a mano): Open-Meteo offre anche una API di geocoding gratuita, senza chiave, che fa esattamente questo lavoro: dato il nome di una città, restituisce le sue coordinate in JSON.
import requests nome_citta = "Milano" url_geocoding = f"https://geocoding-api.open-meteo.com/v1/search?name={nome_citta}" risposta = requests.get(url_geocoding) risultati = risposta.json()["results"] lat = risultati[0]["latitude"] lon = risultati[0]["longitude"] print(f"{nome_citta}: latitudine {lat}, longitudine {lon}")Scegliete 5 città (con le rispettive coordinate) e scrivete una versione che ne scarica il meteo una alla volta e una con 5 thread, uno per città.
Variante avanzata (facoltativa): invece di scrivere le coordinate a mano, fate in modo che il programma chieda all'utente il nome delle 5 città e le geocodifichi da solo con la chiamata sopra, prima di scaricarne il meteo — così l'intero flusso (nome città → coordinate → meteo) è automatico.
Obiettivo misurabile: stampate a schermo, per ciascuna versione, il tempo totale impiegato; la versione con i thread deve risultare visibilmente più veloce (idealmente vicina al tempo della singola richiesta più lenta, non alla somma di tutte).
Applicazione reale — AstroTracker: dashboard spaziale con i thread. Contesto: sei il developer di AstroTracker, un'app per appassionati di astronomia. L'utente vuole sapere in tempo reale cosa succede nello spazio (asteroidi vicini, foto del giorno, immagini della Terra, brillamenti solari) e la dashboard deve aggiornarsi il più velocemente possibile, senza aspettare una sezione alla volta.
Le API da usare (tutte gratuite su
api.nasa.gov, con?api_key=DEMO_KEY):
- NEO — asteroidi vicini alla Terra:
https://api.nasa.gov/neo/rest/v1/feed?start_date=2026-10-11&end_date=2026-10-11&api_key=DEMO_KEY- APOD — foto astronomica del giorno:
https://api.nasa.gov/planetary/apod?api_key=DEMO_KEY- EPIC — immagini della Terra dallo spazio:
https://api.nasa.gov/EPIC/api/natural?api_key=DEMO_KEY- DONKI — brillamenti solari recenti:
https://api.nasa.gov/DONKI/FLR?startDate=2026-10-01&endDate=2026-10-11&api_key=DEMO_KEYCosa fare:
- Chiedi all'utente di scegliere 3 sezioni tra le 4 disponibili, con un ciclo
for. Salva le scelte in una listasezioni.- Scrivi una funzione
analizza_sezione(nome)che, per ogni sezione, chiama l'URL giusto, legge il JSON e stampa un riepilogo:
neo→ numero di asteroidi del giorno + nome del più grandeapod→ titolo della foto astronomica del giornoepic→ numero di immagini della Terra disponibili + data dell'ultimadonki→ numero di brillamenti solari nell'ultima settimana- Aggiungi un cronometro (
time.time()) attorno alle chiamate.- Esegui la funzione una volta con un ciclo sequenziale e una volta con un thread per sezione (ricorda:
start()in un ciclo,join()in un ciclo separato).- Confronta i due tempi e stampali a schermo.
Twist finale — Allerta Spaziale: dopo che tutti i thread hanno finito, il programma deve decidere se lanciare un'allerta. Se almeno una sezione ha restituito un valore "preoccupante" (es. più di 10 asteroidi, o più di 5 brillamenti solari), stampa:
🚨 ALLERTA SPAZIALE — Attività solare/asteroidale elevata!Altrimenti:
✅ Tutto tranquillo nello spazio oggi.Attenzione didattica: i thread non restituiscono valori. Per raccogliere i risultati e decidere l'allerta, usa un dizionario condiviso con un
Lock, oppure una lista dove ogni thread scrive il proprio risultato, oppureconcurrent.futures.ThreadPoolExecutor(variante avanzata).Bonus (facoltativo): chiedi all'utente quante volte aggiornare la dashboard (es. 3 aggiornamenti) e ripeti l'intero blocco di thread in un ciclo esterno, misurando il tempo totale.
Obiettivo misurabile: la versione con i thread deve risultare visibilmente più veloce di quella sequenziale (idealmente vicina al tempo della chiamata più lenta, non alla somma di tutte). Con le API NASA, che a volte sono lente, la differenza si vede molto bene.
Confronto finale — sequenziale, thread e processi a confronto. Contesto: ora che avete scritto (o visto) tutte e tre le versioni — sequenziale, con
Thread, conProcess— per lo stesso identico compito (scaricare il meteo di più città), è il momento di metterle una accanto all'altra e misurare, non solo intuire, le differenze.
Sequenziale Thread Process Come procede una richiesta alla volta tutte insieme, stessa memoria tutte insieme, memoria separata Tempo totale (es. 5 città) somma di tutte le richieste ≈ quella più lenta ≈ quella più lenta + un piccolo overhead di avvio Raccogliere i risultati in una lista banale, è tutto sequenziale diretto: la lista è condivisa richiede un ManagerappositoOrdine di stampa sempre uguale all'elenco può cambiare a ogni esecuzione può cambiare, ma spesso "sembra" ordinato per l'overhead di avvio Prendete le tre versioni (sequenziale, thread, processi) e aggiungete a ciascuna lo stesso cronometro attorno alla parte che scarica i dati di tutte le città:
import time inizio = time.time() # ... qui il codice che scarica il meteo di tutte le città ... print(f"Tempo totale: {time.time() - inizio:.2f} secondi")Eseguite le tre versioni sulle stesse 5-10 città e riportate i tre tempi ottenuti in una tabellina.
Obiettivo misurabile:
- Il tempo della versione sequenziale deve crescere in modo evidente aumentando il numero di città (raddoppiando le città, circa raddoppia il tempo)
- I tempi di
ThreadeProcessdevono restare molto più stabili all'aumentare delle città, rispetto alla sequenziale- Confrontando
ThreadeProcesstra loro, il tempo diProcessdeve risultare leggermente superiore (l'overhead di creazione dei processi si vede a occhio, anche se piccolo)Domanda guida per la discussione in classe: se il vantaggio di
ThreadeProcessè praticamente identico in questo esercizio, perché non usare sempreProcess, che sembra più "sicuro" per via della memoria isolata? La risposta: qui il collo di bottiglia è la rete (I/O), non il calcolo. L'isolamento della memoria diProcessnon porta nessun vantaggio in questo caso, solo il costo extra di crearli —Processconviene quando il collo di bottiglia è la CPU (calcolo pesante da distribuire sui core), come nell'esercizio delle miniature/hash del punto 1.3.
1.2 Sincronizzazione e problemi tipici
Durata indicativa: 2 settimane
Far lavorare più thread insieme è comodo, ma nasconde un problema: se due thread modificano la stessa variabile nello stesso momento, il risultato può essere sbagliato.
Race condition: il bug invisibile
Guardate questo codice: due thread che incrementano lo stesso contatore 100.000 volte ciascuno. Il risultato finale dovrebbe essere 200.000...
import threading
contatore = 0
def incrementa():
global contatore
for _ in range(100000):
contatore += 1
t1 = threading.Thread(target=incrementa)
t2 = threading.Thread(target=incrementa)
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Atteso: 200000 — Ottenuto: {contatore}")
Attenzione: Eseguendo questo codice, il valore ottenuto sarà quasi sempre minore di 200.000, e diverso ogni volta che lo eseguite. Non è un errore di battitura nel codice: è una race condition.
Il motivo: contatore += 1 non è un'operazione singola e "atomica". Il computer in realtà fa tre cose: legge il valore, lo aumenta di 1, lo riscrive. Se due thread leggono lo stesso valore prima che l'altro riesca a riscriverlo, uno dei due incrementi viene "perso".
Il Lock: mettere un lucchetto
Un Lock garantisce che solo un thread alla volta possa eseguire una certa porzione di codice — gli altri devono aspettare il proprio turno.
import threading
contatore = 0
lock = threading.Lock()
def incrementa():
global contatore
for _ in range(100000):
with lock:
contatore += 1
t1 = threading.Thread(target=incrementa)
t2 = threading.Thread(target=incrementa)
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Atteso: 200000 — Ottenuto: {contatore}")
Con il Lock, il risultato sarà sempre esattamente 200.000: un thread "chiude a chiave" quella riga di codice finché non ha finito, poi lascia entrare l'altro.
Deadlock: quando i lucchetti si bloccano a vicenda
Se due thread hanno bisogno di due lucchetti diversi, ma li prendono in ordine diverso, possono bloccarsi a vicenda per sempre: il primo tiene il lucchetto A aspettando B, il secondo tiene B aspettando A. Nessuno dei due lo lascerà mai. La regola pratica per evitarlo: prendere sempre i lock nello stesso ordine, in ogni parte del programma.
Esercizio pratico.
- Eseguite il codice del contatore senza lock più volte: annotate i valori ottenuti (saranno diversi ogni volta)
- Aggiungete il
Lockcome nell'esempio e rieseguite più volte: verificate che il risultato sia sempre 200.000 - Provate ad aumentare il numero di thread (es. 4 o 5 thread invece di 2): il problema senza lock peggiora?
Cosa consegnare: le due versioni del codice + una riga di commento con i valori ottenuti in 3 esecuzioni diverse senza lock.
Applicazione reale — vendita di biglietti online. Contesto: se due persone comprano l'ultimo biglietto disponibile nello stesso istante, senza sincronizzazione il sistema può venderlo a entrambe — è un problema reale delle piattaforme di ticketing durante le vendite ad alta richiesta.
Simulate uno stock condiviso di 10 biglietti. Create 20 thread, ognuno rappresenta un utente che tenta di comprare un biglietto (controlla se lo stock è maggiore di 0, poi lo decrementa di 1).
Obiettivo misurabile: eseguendo senza lock, contate quante "vendite" totali avvengono: se il numero supera 10, o lo stock finale risulta negativo, avete riprodotto il bug. Dopo aver aggiunto un
Lockattorno al controllo+decremento, le vendite totali devono risultare esattamente 10, in ogni esecuzione.
Applicazione reale — prelievi da un conto condiviso. Contesto: un conto corrente cointestato, o un sistema di pagamento con più dispositivi collegati allo stesso account, deve gestire prelievi che arrivano nello stesso momento senza mai far scendere il saldo sotto zero per un errore di sincronizzazione.
Partendo da un saldo di 100€, create 10 thread che tentano ciascuno un prelievo di 15€ (controllano se il saldo è sufficiente, poi lo scalano). Con 10 prelievi da 15€ (150€ totali) su un saldo di 100€, non tutti i prelievi dovrebbero andare a buon fine.
Obiettivo misurabile: senza lock, verificate se il saldo finale può risultare negativo (bug). Con il
Lock, il saldo finale deve restare sempre maggiore o uguale a zero, e il numero di prelievi riusciti moltiplicato per 15€ deve corrispondere esattamente ai soldi effettivamente scalati dal saldo.
1.3 Multiprocessing
Durata indicativa: 2 settimane
C'è un dettaglio importante di Python che rende i thread meno utili di quanto sembri per certi compiti: il GIL (Global Interpreter Lock). In parole semplici, il GIL fa in modo che, anche con più thread, solo uno alla volta possa eseguire codice Python "puro" in un dato istante — quindi i thread non sfruttano davvero più core della CPU per calcoli intensi (funzionano benissimo, invece, per compiti come i download, dove si passa il tempo ad aspettare).
Per sfruttare davvero più core in calcoli pesanti, si usa multiprocessing: invece di più thread nello stesso programma, crea più processi separati (ognuno con la propria copia di Python), che girano davvero in parallelo su core diversi.
Pool: distribuire il lavoro automaticamente
Pool prende una lista di elementi da elaborare e li distribuisce automaticamente tra più processi.
from multiprocessing import Pool
import time
def e_primo(n):
if n < 2:
return False
for i in range(2, int(n**0.5) + 1):
if n % i == 0:
return False
return True
if __name__ == "__main__":
numeri = list(range(100000, 200000))
inizio = time.time()
with Pool(processes=4) as pool:
risultati = pool.map(e_primo, numeri)
print(f"Con Pool: {time.time()-inizio:.2f}s — primi trovati: {sum(risultati)}")
Il blocco
if __name__ == "__main__":è obbligatorio conmultiprocessingsu Windows (e buona pratica ovunque): evita che ogni nuovo processo, ripartendo da zero, rilanci per errore l'intero script da capo.
Esercizio pratico.
- Scrivete la funzione
e_primocome sopra e testatela su una lista di numeri (es. da 100.000 a 200.000) con un normale cicloforo una list comprehension, cronometrando il tempo - Riscrivete lo stesso calcolo usando
Poolcome nell'esempio - Confrontate i tempi: provate anche a cambiare
processes=4con 2 o 8 e osservate cosa cambia
Cosa consegnare: le due versioni + una tabella con i tempi ottenuti al variare del numero di processi.
Applicazione reale — miniature di foto. Contesto: siti di e-commerce, gallerie fotografiche e social network devono generare miniature di migliaia di immagini caricate dagli utenti: farlo con un solo processo alla volta sarebbe troppo lento.
Con la libreria
Pillow(pip install pillow), prendete tutte le immagini da una cartella (usate foto vostre, o create copie ripetute della stessa immagine con nomi diversi per averne tante) e create una miniatura 200x200 per ciascuna, prima con un ciclo sequenziale, poi conmultiprocessing.Pool.Obiettivo misurabile: con almeno 30-40 immagini, il tempo della versione con
Pooldeve risultare chiaramente inferiore (idealmente diviso circa per il numero di processi usati) rispetto alla versione sequenziale; con solo 2-3 immagini il vantaggio sarà minimo o assente — provate a spiegare perché.
Applicazione reale — verificare l'integrità di più file. Contesto: quando scaricate un programma o un file importante, spesso viene fornito anche il suo "hash" (un'impronta digitale del contenuto): ricalcolandolo e confrontandolo, potete verificare che il file non sia stato alterato o corrotto durante il download. Con molti file da controllare, calcolare gli hash uno alla volta è lento.
Usando solo la libreria standard (
hashlib, già inclusa in Python, senza bisogno di installare nulla), scrivete una funzione che calcola l'hash SHA-256 di un file dato il suo percorso. Applicatela a tutti i file di una cartella con molti file (es. tutti i.pydel laboratorio, o create voi 20-30 file di testo di prova), prima in sequenziale poi conPool.Obiettivo misurabile: il programma deve stampare, per ogni file, il nome e il relativo hash; confrontando i tempi delle due versioni su una cartella con almeno 20-30 file di dimensione non trascurabile (qualche MB ciascuno), la versione con
Pooldeve risultare più veloce.
1.4 Comunicazione tra thread e processi
Durata indicativa: 2 settimane
Spesso un thread produce dati che un altro deve consumare (esempio: uno raccoglie dati da un sensore, un altro li salva). Scambiarsi dati con variabili condivise è rischioso (come visto al punto 1.2): la soluzione sicura è una Queue, una fila FIFO (primo dentro, primo fuori) pensata apposta per essere usata da più thread contemporaneamente senza bisogno di lock manuali.
Pattern produttore-consumatore
import threading, queue, random, time
coda = queue.Queue()
def produttore():
for _ in range(10):
numero = random.randint(1, 100)
print(f"Prodotto: {numero}")
coda.put(numero)
time.sleep(0.2)
coda.put(None) # segnale: "ho finito"
def consumatore():
totale = 0
while True:
numero = coda.get()
if numero is None:
break
totale += numero
print(f"Consumato: {numero} (totale: {totale})")
print(f"Somma finale: {totale}")
t1 = threading.Thread(target=produttore)
t2 = threading.Thread(target=consumatore)
t1.start(); t2.start()
t1.join(); t2.join()
coda.put(None)è un piccolo trucco: il consumatore non sa in anticipo quanti numeri arriveranno, quindi il produttore gli manda un valore speciale (None) per dirgli "puoi fermarti, ho finito".
Esercizio pratico.
- Scrivete il codice sopra e verificate che funzioni
- Modificatelo per avere due produttori che generano numeri insieme, e un solo consumatore che li somma tutti (attenzione al segnale di fine: ora servono due
None, uno per produttore)
Cosa consegnare: il codice con due produttori, con la somma finale corretta stampata a schermo.
Applicazione reale — monitoraggio ambientale. Contesto: un dispositivo IoT che raccoglie letture da un sensore mentre un altro componente le salva su file o le invia altrove, senza che i due si blocchino a vicenda.
Un thread "sensore" genera una lettura di temperatura casuale (tra 15 e 35 gradi) ogni mezzo secondo e la mette in una
Queue. Un thread "logger" legge dalla coda e scrive ogni lettura, con data e ora, in un file di testo (es.log_temperature.txt). Fate girare il sistema per almeno 20 letture.Obiettivo misurabile: al termine, il file deve contenere esattamente 20 righe, ciascuna con una temperatura tra 15 e 35 e un orario, nello stesso ordine in cui sono state generate (nessuna persa, nessuna duplicata).
Applicazione reale — elaborazione ordini in coda. Contesto: molti sistemi reali (e-commerce, prenotazioni) separano la ricezione di una richiesta dalla sua elaborazione: l'ordine entra subito in una coda, e un altro componente lo processa con calma (verifica pagamento, aggiorna il magazzino), così chi ha effettuato l'ordine non deve aspettare che tutto sia completato.
Un thread "ricezione ordini" genera 15 ordini finti (ognuno un dizionario con un id e un prodotto a caso da una lista) e li mette in una
Queueogni 0.3 secondi circa. Un thread "magazzino" li legge uno alla volta, simula l'elaborazione contime.sleep(1)(più lento della ricezione) e stampaf"Ordine {id} evaso: {prodotto}".Obiettivo misurabile: tutti e 15 gli ordini devono essere evasi, nell'ordine in cui sono arrivati, anche se il thread "ricezione" ha finito il suo lavoro molto prima del thread "magazzino" — verificate che il programma aspetti (con
join()) che la coda sia completamente svuotata prima di terminare.
1.5 Progetto del periodo
Durata indicativa: 2 settimane
Esercizio.
Obiettivo
Realizzare una piccola applicazione che sfrutti concretamente quanto visto nei quattro moduli precedenti, dimostrando la differenza di prestazioni tra una versione sequenziale e una concorrente/parallela.
Scegliete un tema (o proponetene uno voi, va approvato dal docente)
- Downloader simulato — "scarica" un elenco di file finti (con
time.sleepproporzionale a una dimensione casuale), mostrando una versione sequenziale e una multi-thread - Elaboratore di immagini/file in parallelo — applica una trasformazione (es. ridimensionamento, conversione) a più file di una cartella, confrontando esecuzione sequenziale e con
multiprocessing.Pool
Requisiti minimi
- Una versione sequenziale funzionante del compito scelto
- Una versione concorrente/parallela (thread o processi, a seconda del tipo di compito)
- Misurazione e confronto dei tempi di esecuzione tra le due versioni
- Se il programma condivide dati tra thread/processi, uso corretto di
LockoQueuedove serve - Codice commentato nei punti chiave (perché si usa un thread lì, perché un lock qui)
Cosa consegnare: codice sorgente funzionante (su GitHub o cartella condivisa) + una breve nota (anche solo poche righe) con i tempi misurati nelle due versioni + demo live di 5 minuti in laboratorio.
academy-pc.it · materiale didattico — Modulo Concorrenza e Parallelismo, TPSIT quarta