Thread in Python: GIL, Ciclo di Vita e Concorrenza
Dal main single-thread alle app che scaricano, calcolano e aggiornano contemporaneamente: come nasce, dorme, aspetta e muore un thread in Python, e perché con il GIL non basta il threading per andare più veloci.
Quando fate python main.py pensate di far partire un programma. In realtà lanciate un interprete CPython che è un processo, e dentro quel processo gira un main thread. Io ve lo dico sempre in lab: se il vostro script fa tutto nel main, state usando Python come fosse il 1990. Oggi impariamo a farlo lavorare come un'app moderna.
1. Programmi, processi Python e thread: la differenza fondamentale
Programma = file .py statico. Processo Python = interprete CPython con suo PID, GIL e memoria. Thread Python = oggetto threading.Thread leggero dentro lo stesso processo, condivide oggetti ma ha suo stack Python. Condivide il GIL: gira uno alla volta su CPU.
Il file app.py è statico e passivo. Il processo Python è dinamico: ha heap per gli oggetti, moduli importati, file aperti. Il thread è l'unità che la CPU schedula dentro quel processo. In Python la differenza è enorme per il GIL.
Io ho pensato di fare così per farvelo ricordare all'interrogazione: aprite Task Manager, lanciate 1 script Python con 10 thread e vedete 1 solo processo Python, 1 solo core che lavora davvero in CPU-bound. Io lo farei così per spiegarlo alla Maturità: "Il programma è la ricetta, il processo Python è la cucina con un solo coltello (GIL), i thread sono i cuochi che si passano il coltello". Se dite che i thread Python vanno in parallelo su più core, vi fermo subito.
Avete una app Tkinter che scarica file nel main thread? Si freeza. Stesso errore che in Java. Soluzione: thread worker con threading.Thread. Il main resta libero per ridisegnare la UI. Questo è il caso d'uso #1 che voglio in verifica.
2. Multithreading, multiprocessing e il GIL di Python
Questa è la parte che sbaglia il 90% degli studenti. In CPython c'è il GIL - Global Interpreter Lock: un mutex globale che permette a un solo thread alla volta di eseguire bytecode Python. Per I/O va benissimo, per CPU è un collo di bottiglia.
| Termine | Significato in Python | Obiettivo / Quando |
|---|---|---|
| threading (multithreading) | Più thread nello stesso processo, condividono memoria, 1 solo gira su CPU per volta per via del GIL | Perfetto per I/O-bound: download, rete, file, DB, UI responsive |
| multiprocessing | Più processi Python separati, ognuno con suo GIL, memoria isolata (pickle) | Per CPU-bound: calcoli, numpy, elaborazione immagini, compressione |
| asyncio (concorrenza cooperativa) | Un solo thread, event loop, task che cedono controllo con await | Migliaia di connessioni I/O con pochissima RAM (server moderni) |
Io ho pensato di fare così quando correggo le verifiche: se per velocizzare un calcolo di numeri primi mi proponete threading vi metto 2 punti in meno. Io lo farei così per ricordarlo: "I/O = Thread, CPU = Process". Guardate htop in Linux mentre girano 4 thread Python CPU-bound: un core al 100%, gli altri fermi. Quello è il GIL che vi sta parlando.
Il GIL non è un errore di Python, è una scelta per rendere CPython veloce su single-thread e semplice per la gestione memoria. Se fate while True: x+=1 in 8 thread non andrete 8 volte più veloci. Anzi, più lenti per il context switch. Per CPU usate multiprocessing o Python 3.13+ con GIL opzionale (free-threaded, ancora sperimentale).
3. Il ciclo di vita: gli stati di un thread in Python
Python non vi mostra gli stati come Java, ma sotto ci sono. Io ve li mappo come li vedete in threading + GIL.
| Stato Python | Cosa significa davvero |
|---|---|
| NEW | Oggetto Thread creato: Thread(target=...), ma start() non chiamato. Non è nel thread OS. |
| RUNNABLE (Running + Ready) | Dopo start(). Lotta per il GIL. Solo uno alla volta esegue bytecode, gli altri aspettano GIL. |
| BLOCKED | Bloccato su Lock.acquire(), RLock. Non consuma GIL in attesa. |
| WAITING | Event.wait(), Condition.wait(), Queue.get() senza timeout. Aspetta notifica di un altro thread. |
| TIMED_WAITING | time.sleep(s), join(timeout), Lock.acquire(timeout), Event.wait(timeout). Rilascia GIL durante l'attesa. |
| TERMINATED | Funzione target finita o eccezione. is_alive() = False. Non ripartibile. |
Fate time.sleep(1) e il thread rilascia il GIL: altri thread girano. Fate while time.time()... busy-wait e tenete il GIL stretto al 100% di un core. Io lo vedo ogni anno: busy-wait = bocciatura diretta dell'esercizio.
4. Cosa salva Python per un thread: TCB, stack e GIL
Il TCB di un thread Python è leggero: PyThreadState con program counter Python, stack di frame Python (chiamate), riferimento a heap, stato, nome. Context switch = l'interprete salva PyThreadState corrente e carica quello nuovo, ma deve anche prendere/rilasciare il GIL: ecco perché 1 solo thread Python alla volta su CPU.
| Campo del thread Python | Contenuto pratico | Perché serve nel vostro lab |
|---|---|---|
| TID / name | threading.get_ident() + name ("Downloader-1") | Log, debug, current_thread().name |
| Stato + GIL | RUNNABLE ma aspetta GIL | Spiega perché CPU-bound non scala |
| Py Stack | Frame Python (funzioni chiamate) | RecursionError per thread se ricorsione infinita |
| Heap condiviso | Liste, dict, oggetti | Qui nascono race condition |
| Daemon flag | Se True, muore con main | Evita che programma resti appeso |
| Lock implicito | GIL = 1 lock per tutto l'interprete | Context switch Python = 5ms default (sys.getswitchinterval) |
Io ho pensato di fare così a lezione di Tecnologia: apro threading.enumerate() e vi mostro MainThread + i vostri. Poi vi faccio vedere con faulthandler i dump dei thread. Io lo farei così all'interrogazione: "Prof, il thread Python è come un segnalibro Python: salva frame e PC, ma c'è un solo evidenziatore (GIL) per tutta la classe". Se dite questa frase, vi do il mezzo punto in più.
Ogni thread Python mangia ~8MB di stack OS + overhead PyThreadState. 1000 thread = crash. Ecco perché non si fa new Thread in loop, si usa ThreadPoolExecutor con max 20-50 per I/O.
5. Come creare un thread in Python (i 2 modi + quello che uso io)
Ci sono 2 modi ufficiali, ma uno è quello che vi chiedo io in verifica. Non esiste start senza target/run.
class MioThread(Thread)
Override di run(). Didattico, ma verboso. Lo trovate nei vecchi tutorial.
Thread(target=funzione)
Separa task da thread. Riutilizzabile con Pool. È quello che uso io al 90% in lab.
# METODO 1: ereditarietà - da sapere ma sconsigliato
import threading, time
class MioThread(threading.Thread):
def run(self):
print(f"Sono {self.name} - ident: {threading.get_ident()}")
# METODO 2: target - quello che voglio io in verifica
def mio_task(nome):
for i in range(1,4):
print(f"{nome} -> {i} su {threading.current_thread().name}")
time.sleep(0.5) # TIMED_WAITING + rilascia GIL
def main():
# ERRORE CHE VEDO SEMPRE: chiamare run() invece di start()
# t = MioThread(); t.run() # SBAGLIATO: gira nel main, non è un thread!
# GIUSTO:
t1 = MioThread(name="Downloader-1")
t1.start() # NEW -> RUNNABLE
t2 = threading.Thread(target=mio_task, args=("Calcolo-A",), name="Worker-2")
t2.start()
# METODO CHE USO IO: lambda + args + name - pulito
t3 = threading.Thread(target=lambda: print(f"Task lambda su {threading.current_thread().name}"), name="Lambda-Worker")
t3.start()
print(f"Main continua, non aspetta. t1 is_alive: {t1.is_alive()}")
t1.join(); t2.join(); t3.join() # main va in WAITING finché finiscono
print("Main: tutti finiti")
if __name__ == "__main__":
main()
Io ho pensato di fare così in lab: vi faccio scrivere prima run() e poi start() e vi faccio stampare current_thread().name. Se vedete due volte "MainThread", avete chiamato run() e non start(). Io lo farei così per farlo sembrare vostro: dite "Prof, start() crea il thread OS e mette NEW->RUNNABLE e prende il GIL a turno, run() è solo un metodo normale". Nelle app moderne non faccio mai Thread() in loop, uso PoolExecutor, ve lo mostro dopo.
# Caso d'uso perfetto per threading: I/O-bound, GIL rilasciato durante I/O
import threading, time, random
from threading import Lock
completati = 0
lock_contatore = Lock() # serve per contatore condiviso
def download_simulato(url):
global completati
thread_name = threading.current_thread().name
print(f"[{thread_name}] Inizio download: {url}")
# Simulo rete lenta -> rilascia GIL, altri thread girano
time.sleep(random.uniform(0.5, 2.0))
with lock_contatore:
completati += 1
tot = completati
print(f"[{thread_name}] Finito {url} - Totale: {tot}/3")
def main():
urls = ["foto1.jpg", "video.mp4", "doc.pdf"]
threads = []
start = time.time()
for i, url in enumerate(urls):
t = threading.Thread(target=download_simulato, args=(url,), name=f"DL-{i+1}")
t.start()
threads.append(t)
print("[MAIN] Interfaccia libera, posso muovere il mouse... (main RUNNABLE)")
for t in threads:
t.join() # WAITING
print(f"[MAIN] Tutti finiti in {time.time()-start:.2f}s vs ~4.5s sequenziale")
if __name__ == "__main__":
main()
6. Sincronizzazione: race condition, Lock e deadlock
Questo è il cuore dell'esame. Anche con il GIL, in Python avete race condition perché il GIL si rilascia ogni 5ms e tra bytecode. x += 1 non è atomico!
| Problema | Come si vede in Python | Soluzione che voglio in verifica |
|---|---|---|
| Race Condition | conto += 1 fa 10000+10000 = 18734, non 20000 | Lock, RLock, Queue (thread-safe) |
| Visibility / GIL | GIL garantisce visibility, ma non atomicità di più bytecode | Lock per sezioni critiche |
| Deadlock | Thread A ha Lock X aspetta Y, B ha Y aspetta X. Entrambi BLOCKED per sempre | Ordine dei lock, with Lock (release automatica) |
| Starvation / Livelock | Thread bassa priorità non entra mai in Lock | Event, Condition, Semaphore |
import threading
class ContoCorrente:
def __init__(self):
self.saldo = 0
self.lock = threading.Lock()
# VERSIONE SBAGLIATA - 10 su 10 lo sbagliano al primo colpo
# def deposita(self, v):
# self.saldo += v # NON atomico: LOAD, ADD, STORE = 3 bytecode, GIL può switchare!
# VERSIONE GIUSTA 1: Lock esplicito - quella che uso io
def deposita_sync(self, v):
with self.lock: # BLOCKED se occupato, poi RUNNABLE
self.saldo += v
# VERSIONE GIUSTA 2: RLock se metodo chiama altro metodo sincronizzato
# self.rlock = threading.RLock()
# VERSIONE GIUSTA 3: moderna, senza lock manuale - Queue
# from queue import Queue - Queue è già thread-safe
conto = ContoCorrente()
def task_deposito():
for _ in range(10000):
conto.deposita_sync(1)
t1 = threading.Thread(target=task_deposito, name="T1")
t2 = threading.Thread(target=task_deposito, name="T2")
t1.start(); t2.start()
t1.join(); t2.join()
# Se usi metodo non sincronizzato vedrai 15432, 19211... casuale!
print(f"Saldo atteso 20000, reale: {conto.saldo}")
# Io ho pensato di fare così per farvelo vedere: lanciate 5 volte, vedete 5 risultati diversi
import threading, time
forchetta1 = threading.Lock()
forchetta2 = threading.Lock()
def filosofo1():
with forchetta1:
print("Filosofo1 ha forchetta1, vuole forchetta2...")
time.sleep(0.1)
with forchetta2: # aspetta per sempre se l'altro ha forchetta2
print("Filosofo1 mangia")
def filosofo2():
with forchetta2: # ORDINE INVERSO -> DEADLOCK!
print("Filosofo2 ha forchetta2, vuole forchetta1...")
time.sleep(0.1)
with forchetta1:
print("Filosofo2 mangia")
t1 = threading.Thread(target=filosofo1, name="Filosofo-1")
t2 = threading.Thread(target=filosofo2, name="Filosofo-2")
t1.start(); t2.start()
# Come lo trovo? t1.is_alive() resta True per sempre, Ctrl+C non basta se non daemon
# Fix che voglio: sempre stesso ordine di lock! Oppure con timeout:
# forchetta1.acquire(timeout=1) -> se False, rilascio e riprovo
t1.join(timeout=2)
if t1.is_alive():
print("⚠️ DEADLOCK rilevato! Filosofo1 ancora BLOCKED dopo 2s")
Io ho pensato di fare così quando mi chiedete del deadlock: vi disegno alla lavagna due thread che si abbracciano. Io lo farei così per farlo sembrare vostro: "Prof, il deadlock in Python ha 4 condizioni di Coffman: mutua esclusione (Lock), hold-and-wait, no preemption, attesa circolare. Se rompo una, niente deadlock. In Python rompo l'attesa circolare prendendo i lock sempre nello stesso ordine e usando with". Se dite Coffman, vi metto subito 8.
Mai fare lock.acquire(); ...; lock.release() senza try/finally. Se c'è eccezione, release non chiamato = deadlock. Usate sempre with lock: - è il vostro migliore amico. È l'errore che trovo nel 70% dei vostri compiti.
7. Threading moderno in Python: Pool, Queue e Asyncio
Dal 2015 in poi nessuno crea più Thread() a mano in produzione per 100 task. Si usano pool. E per I/O massiccio, si usa asyncio.
| Strumento Python | Cosa fa | Quando lo uso io |
|---|---|---|
| ThreadPoolExecutor | Pool di thread, riuso, coda, Future | Sempre al posto di Thread(). Es. max_workers=20 per I/O |
| Queue / LifoQueue | Coda thread-safe, produttore-consumatore | Quando devo passare dati tra thread senza Lock manuale |
| ProcessPoolExecutor | Processi separati, ognuno con suo GIL | CPU-bound: calcoli, immagini, ML |
| asyncio + aiohttp | Event loop, 10k task con 1 thread | Server, 10k connessioni, scraping massivo |
Server vecchio: 1 thread per client = 10k thread = crash. Server con ThreadPool 100 = coda, lento, GIL. Server moderno Python: asyncio con 1 thread e 10k coroutine → quando una fa I/O fa await e libera event loop. Così sembra avere 10k connessioni ma usa 1 solo thread reale. È il motivo per cui FastAPI + asyncio batte Flask + threading.
import time, random
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed
from queue import Queue
import threading
# METODO CLASSICO CHE USO IN 4A: ThreadPoolExecutor per I/O
def scarica_url(task_id):
print(f"Task {task_id} su {threading.current_thread().name}")
time.sleep(0.5) # I/O: rilascia GIL
return task_id * task_id
def demo_thread_pool():
# Io ho pensato di fare così: 8 worker = buono per I/O in lab
with ThreadPoolExecutor(max_workers=8, thread_name_prefix="IO-Worker") as pool:
futures = [pool.submit(scarica_url, i) for i in range(8)]
for f in as_completed(futures):
print(f"Risultato: {f.result()}") # WAITING finché pronto
print("Pool chiuso automatico con with")
# METODO MODERNO: Produttore-Consumatore con Queue (quello che voglio in verifica)
def demo_queue():
q = Queue()
def produttore():
for i in range(10):
print(f"[Produttore] metto {i}")
q.put(i)
time.sleep(0.1)
q.put(None) # sentinella fine
def consumatore():
while True:
item = q.get() # WAITING se vuota, thread-safe
if item is None:
q.put(None) # avviso altro consumatore
break
print(f"[Consumatore {threading.current_thread().name}] preso {item}")
q.task_done()
t_prod = threading.Thread(target=produttore, name="Produttore")
t_cons = threading.Thread(target=consumatore, name="Consumatore")
t_prod.start(); t_cons.start()
t_prod.join(); t_cons.join()
print("Queue svuotata")
# CPU-BOUND: qui ThreadPool NON serve, serve ProcessPool
def calcolo_pesante(n):
# Simulo CPU: non rilascia GIL
s = 0
for i in range(n):
s += i*i
return s
def demo_cpu_bound():
NUM = 5_000_000
start = time.time()
# Con ThreadPool: 4 thread ma 1 core -> lento per GIL
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(calcolo_pesante, [NUM]*4))
print(f"ThreadPool CPU-bound: {time.time()-start:.2f}s (GIL blocca)")
start = time.time()
# Con ProcessPool: 4 processi, 4 GIL, 4 core reali
with ProcessPoolExecutor(max_workers=4) as pool:
list(pool.map(calcolo_pesante, [NUM]*4))
print(f"ProcessPool CPU-bound: {time.time()-start:.2f}s (vero parallelo)")
if __name__ == "__main__":
print("--- ThreadPool I/O ---")
demo_thread_pool()
print("\n--- Queue Prod/Cons ---")
demo_queue()
print("\n--- CPU-bound confronto ---")
demo_cpu_bound()
Io ho pensato di fare così in lab: vi faccio lanciare demo_cpu_bound() e vedete che ThreadPool è più lento di sequenziale. Poi ProcessPool va 4x più veloce su PC con 4 core. Io lo farei così per farlo sembrare vostro: dite "Prof, con threading faccio concorrenza su I/O perché il GIL si rilascia su sleep/I/O, con multiprocessing faccio parallelismo su CPU perché ogni processo ha suo GIL". Se dite questa frase, vi metto il bonus.
8. Verifica e laboratorio mentale
🔬 Attività – carta e penna (da saper fare senza PC)
Esercizi che escono in verifica scritta, 15 minuti:
- Disegna il diagramma NEW → RUNNABLE (lotta per GIL) → (BLOCKED / WAITING / TIMED_WAITING) → TERMINATED con frecce start, Lock.acquire, Event.wait, sleep, fine target.
- Dati 2 thread che fanno saldo+=1 10000 volte su stesso oggetto senza Lock: quale valore finale ti aspetti? Perché <20000? Mostra LOAD, ADD, STORE e dove il GIL può switchare.
- Codice: t = Thread(target=...); t.run(); t.run(); Cosa succede? In che stato è t? Perché non è multithreading? Cosa cambia con t.start()?
- Spiega perché in Python threading non accelera un calcolo di numeri primi su 8 core. Cosa usi al posto? Confronta ThreadPool vs ProcessPool su 4 core.
- Caso d'uso: server che deve gestire 5000 download HTTP. Perché Thread classici non bastano e asyncio è meglio? Calcola memoria: 5000 thread vs 5000 coroutine.
1) Guardate svg sopra. 2) Race: T1 LOAD saldo 100, T2 LOAD 100, T1 ADD 1 STORE 101, T2 ADD 1 STORE 101 invece di 102. GIL si rilascia tra bytecode, incremento perso. Valore <20000, non deterministico. 3) run() è chiamata diretta nel MainThread, non crea thread OS. Stato resta NEW, is_alive=False. Non c'è parallelismo. Con start() crea thread OS, NEW->RUNNABLE, prende GIL a turno. 4) GIL = 1 solo thread Python su CPU alla volta, quindi 8 thread CPU-bound girano su 1 core. Uso ProcessPoolExecutor: 4 processi = 4 GIL = 4 core reali, 4x speedup. ThreadPool CPU-bound = overhead + switch GIL = più lento. 5) Thread: 5000*8MB=40GB stack = OOM. Asyncio: 5000 coroutine = pochi MB, 1 thread, quando una fa await I/O libera event loop.