Vai al contenuto

Periodo 1 · Concorrenza e parallelismo

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 con try/except sono 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=(...)) (o Thread(...)) — 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 in target comincia 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 stesso for). 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 con join().

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 è 100 dentro il processo figlio, ma 0 nel 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 i join(), il programma potrebbe stampare "Tempo totale" prima ancora che i download siano finiti.

Esercizio pratico.

  1. Create 3-4 processi con Process come nell'esempio, ognuno che stampa il proprio numero identificativo e il proprio PID
  2. Aprite il Task Manager (o htop su Linux) mentre il programma gira: provate a individuare i PID stampati dal programma
  3. 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 for che chiama la funzione una volta per pagina, una dopo l'altra
    • Versione con thread — come nell'esempio sopra, un thread per ogni pagina
  4. 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")) se nome contiene la parola "corrotto", altrimenti stampa "file elaborato correttamente". Chiamatela su una lista di 5 nomi di file finti, di cui uno contiene "corrotto":

  1. Prima chiamatela normalmente, nello stesso processo, dentro un ciclo for: cosa succede al programma quando arriva il file corrotto?
  2. Poi eseguite ogni chiamata dentro un Process separato (uno per file), con p.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 requests e 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}&current_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 dati restituito potete esplorarla con print(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_KEY

Cosa fare:

  1. Chiedi all'utente di scegliere 3 sezioni tra le 4 disponibili, con un ciclo for. Salva le scelte in una lista sezioni.
  2. 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ù grande
    • apod → titolo della foto astronomica del giorno
    • epic → numero di immagini della Terra disponibili + data dell'ultima
    • donki → numero di brillamenti solari nell'ultima settimana
  3. Aggiungi un cronometro (time.time()) attorno alle chiamate.
  4. 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).
  5. 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, oppure concurrent.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, con Process — 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 Manager apposito
Ordine 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:

  1. Il tempo della versione sequenziale deve crescere in modo evidente aumentando il numero di città (raddoppiando le città, circa raddoppia il tempo)
  2. I tempi di Thread e Process devono restare molto più stabili all'aumentare delle città, rispetto alla sequenziale
  3. Confrontando Thread e Process tra loro, il tempo di Process deve 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 Thread e Process è praticamente identico in questo esercizio, perché non usare sempre Process, 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 di Process non porta nessun vantaggio in questo caso, solo il costo extra di crearli — Process conviene 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.

  1. Eseguite il codice del contatore senza lock più volte: annotate i valori ottenuti (saranno diversi ogni volta)
  2. Aggiungete il Lock come nell'esempio e rieseguite più volte: verificate che il risultato sia sempre 200.000
  3. 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 Lock attorno 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 con multiprocessing su Windows (e buona pratica ovunque): evita che ogni nuovo processo, ripartendo da zero, rilanci per errore l'intero script da capo.

Esercizio pratico.

  1. Scrivete la funzione e_primo come sopra e testatela su una lista di numeri (es. da 100.000 a 200.000) con un normale ciclo for o una list comprehension, cronometrando il tempo
  2. Riscrivete lo stesso calcolo usando Pool come nell'esempio
  3. Confrontate i tempi: provate anche a cambiare processes=4 con 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 con multiprocessing.Pool.

Obiettivo misurabile: con almeno 30-40 immagini, il tempo della versione con Pool deve 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 .py del laboratorio, o create voi 20-30 file di testo di prova), prima in sequenziale poi con Pool.

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 Pool deve 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.

  1. Scrivete il codice sopra e verificate che funzioni
  2. 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 Queue ogni 0.3 secondi circa. Un thread "magazzino" li legge uno alla volta, simula l'elaborazione con time.sleep(1) (più lento della ricezione) e stampa f"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.sleep proporzionale 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 Lock o Queue dove 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