Thread in Python: GIL, Ciclo di Vita e Concorrenza - Tecnologia A4 M1 L03 | Prof. Giagnotti
Tecnologia • Anno 4 • Modulo 1 • Lezione 03
✍ Autore: Prof. Giagnotti

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.

⏱ 60 min 🧠 GIL • threading • Lock • Pool
OBIETTIVI DI APPRENDIMENTO
✓Distinguere programma, processo Python e thread e capire cosa fa davvero l'interprete CPython
✓Conoscere il ciclo di vita di un thread Python (NEW, RUNNABLE, BLOCKED/WAITING, TERMINATED) e il ruolo del GIL
✓Creare thread con threading.Thread, target e start() vs join() correttamente
✓Gestire race condition, deadlock e scegliere tra Thread, Multiprocessing e Asyncio nei casi d'uso reali

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

📌 Definizione da ricordare

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.

💬 Tip del docente – tecnologia concreta

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.

🎯 Esempio in classe – GUI che si blocca

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.

TermineSignificato in PythonObiettivo / Quando
threading (multithreading)Più thread nello stesso processo, condividono memoria, 1 solo gira su CPU per volta per via del GILPerfetto per I/O-bound: download, rete, file, DB, UI responsive
multiprocessingPiù 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 awaitMigliaia di connessioni I/O con pochissima RAM (server moderni)
💬 Tip del docente

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.

⚠️ Errore comune – GIL non è un bug

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.

NEW RUNNABLE BLOCKED TIMED_WAITING sleep / join(t) / Lock(t) WAITING TERMINATED start() sleep() Lock.acquire() Event.wait() target ritorna exit timeout / notify / release
Stato PythonCosa significa davvero
NEWOggetto 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.
BLOCKEDBloccato su Lock.acquire(), RLock. Non consuma GIL in attesa.
WAITINGEvent.wait(), Condition.wait(), Queue.get() senza timeout. Aspetta notifica di un altro thread.
TIMED_WAITINGtime.sleep(s), join(timeout), Lock.acquire(timeout), Event.wait(timeout). Rilascia GIL durante l'attesa.
TERMINATEDFunzione target finita o eccezione. is_alive() = False. Non ripartibile.
🎯 Esempio – perché sleep() salva la CPU (e il GIL)

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

📌 Definizione da esame – Thread Python sotto il cofano

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 PythonContenuto praticoPerché serve nel vostro lab
TID / namethreading.get_ident() + name ("Downloader-1")Log, debug, current_thread().name
Stato + GILRUNNABLE ma aspetta GILSpiega perché CPU-bound non scala
Py StackFrame Python (funzioni chiamate)RecursionError per thread se ricorsione infinita
Heap condivisoListe, dict, oggettiQui nascono race condition
Daemon flagSe True, muore con mainEvita che programma resti appeso
Lock implicitoGIL = 1 lock per tutto l'interpreteContext switch Python = 5ms default (sys.getswitchinterval)
💬 Metodo Giagnotti – come spiegarlo

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ù.

⚠️ Attenzione – thread Python non sono gratis

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.

Metodo 1 – Eredità

class MioThread(Thread)

Override di run(). Didattico, ma verboso. Lo trovate nei vecchi tutorial.

Metodo 2 – Quello giusto

Thread(target=funzione)

Separa task da thread. Riutilizzabile con Pool. È quello che uso io al 90% in lab.

PYTHON · 2 modi ufficiali + errore da non fare
# 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()
💬 Tip lab Tecnologia

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.

PYTHON · Caso d'uso reale: 3 download I/O-bound paralleli
# 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!

ProblemaCome si vede in PythonSoluzione che voglio in verifica
Race Conditionconto += 1 fa 10000+10000 = 18734, non 20000Lock, RLock, Queue (thread-safe)
Visibility / GILGIL garantisce visibility, ma non atomicità di più bytecodeLock per sezioni critiche
DeadlockThread A ha Lock X aspetta Y, B ha Y aspetta X. Entrambi BLOCKED per sempreOrdine dei lock, with Lock (release automatica)
Starvation / LivelockThread bassa priorità non entra mai in LockEvent, Condition, Semaphore
PYTHON · Race condition + fix Lock
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
PYTHON · Deadlock classico - da saper disegnare
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")
💬 Tip del docente – come lo spiego io all'esame

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.

⚠️ Errore comune – Lock dimenticato

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 PythonCosa faQuando lo uso io
ThreadPoolExecutorPool di thread, riuso, coda, FutureSempre al posto di Thread(). Es. max_workers=20 per I/O
Queue / LifoQueueCoda thread-safe, produttore-consumatoreQuando devo passare dati tra thread senza Lock manuale
ProcessPoolExecutorProcessi separati, ognuno con suo GILCPU-bound: calcoli, immagini, ML
asyncio + aiohttpEvent loop, 10k task con 1 threadServer, 10k connessioni, scraping massivo
🎯 Caso reale – come gestisco 10k client in Python

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.

PYTHON · Executor + Queue + CPU vs I/O - codice da produzione
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()
💬 Tip lab Tecnologia

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:

  1. Disegna il diagramma NEW → RUNNABLE (lotta per GIL) → (BLOCKED / WAITING / TIMED_WAITING) → TERMINATED con frecce start, Lock.acquire, Event.wait, sleep, fine target.
  2. 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.
  3. Codice: t = Thread(target=...); t.run(); t.run(); Cosa succede? In che stato è t? Perché non è multithreading? Cosa cambia con t.start()?
  4. 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.
  5. 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.
✅ Soluzione ragionata – chiave

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.

✓Programma ≠ Processo Python ≠ Thread: Processo = interprete con GIL, Thread = flusso con stack che condivide heap e lotta per GIL.
✓6 stati Python: NEW, RUNNABLE (con attesa GIL), BLOCKED (Lock), WAITING (Event/Queue), TIMED_WAITING (sleep/join), TERMINATED. is_alive() per verifica.
✓GIL: 1 solo thread Python su CPU alla volta. I/O-bound = threading ok (GIL rilasciato), CPU-bound = multiprocessing obbligatorio.
✓Creazione: Thread(target=...) (quello che uso io) vs class Thread + run() (vecchio). start() crea thread OS, run() no. join() = WAITING.
✓Sincronizzazione: Race anche con GIL → Lock / with lock / Queue (già thread-safe). Deadlock = ordine lock diverso + dimenticare with.
✓Moderno: ThreadPoolExecutor per I/O (20-50 worker), ProcessPoolExecutor per CPU (n core), asyncio per 10k connessioni con 1 thread.
✓Casi d'uso reali: UI responsive (thread worker), download paralleli, produttore-consumatore con Queue, calcolo parallelo con ProcessPool, server FastAPI async.