Asyncio in Python: async/await, TaskGroup, thread, multiprocessing e GIL
Asyncio è la libreria integrata di Python per il codice asincrono: le funzioni dichiarate con async def (coroutine) si sospendono su await, e nel frattempo il ciclo degli eventi esegue altri compiti, così centinaia di richieste di rete procedono insieme in un solo thread. Per attendere rete e disco vanno bene asyncio o i thread (threading), per i calcoli pesanti i processi (multiprocessing), perché il GIL impedisce ai thread del CPython classico di calcolare in parallelo.
Asyncio è la libreria integrata di Python per il codice asincrono. Le funzioni dichiarate con async def sanno sospendersi in corrispondenza di un await, e mentre una aspetta la risposta della rete il ciclo degli eventi manda avanti le altre. Così centinaia di richieste procedono contemporaneamente in un solo thread. Ma asyncio è solo uno dei tre strumenti disponibili: ci sono anche i thread (threading) e i processi (multiprocessing). Vediamo come funziona asyncio in Python, che cosa scegliere per il proprio compito e che ruolo ha il GIL.
Attendere e calcolare sono cose diverse
Senza questa distinzione non si capisce il resto. Un programma rallenta per una di due ragioni:
- I/O-bound — aspetta input/output: la risposta di un'API, di un database, la lettura da disco. In quel momento il processore è inattivo.
- CPU-bound — calcola: elabora immagini, scorre cicli su milioni di record. Il processore è carico.
Per l'attesa vanno bene asyncio o i thread: mentre un'operazione aspetta, se ne esegue un'altra. Per i calcoli servono veri calcoli paralleli su più core, cioè i processi. Confondere i due casi è l'errore principale: i thread non accelerano un calcolo in Python puro, mentre i processi per tre richieste HTTP sono come usare un cannone per una mosca.
Che cos'è asyncio: coroutine e ciclo degli eventi
Il codice asincrono si basa su due parole chiave. async def dichiara una coroutine, cioè una funzione capace di sospendersi. Chiamarla non esegue il corpo, ma crea un oggetto coroutine. await al suo interno significa: «qui aspetto, nel frattempo occupati di altre attività». Tutto è gestito dal ciclo degli eventi (event loop), avviato con asyncio.run().
import asyncio
async def fetch_rate(currency):
await asyncio.sleep(1) # simulazione di una richiesta a un'API
return {"USD": 0.92, "GBP": 0.86, "CNY": 7.2}[currency]
async def main():
rate = await fetch_rate("USD")
print(rate)
asyncio.run(main()) # 0.92
asyncio.sleep() è una pausa asincrona: restituisce il controllo al ciclo degli eventi. Un normale time.sleep() dentro una coroutine bloccherebbe l'intero ciclo, e tutte le altre attività resterebbero ferme.
In sequenza o in contemporanea
Se si scrive await dentro un ciclo, le richieste vanno una dopo l'altra: tre valute richiedono tre secondi. Per farle procedere insieme bisogna avviarle come attività.
import asyncio, time
async def main():
start = time.perf_counter()
for cur in ("USD", "GBP", "CNY"):
await fetch_rate(cur) # una dopo l'altra
print(f"{time.perf_counter() - start:.1f} s") # 3.0 s
asyncio.run(main())
TaskGroup: avviare insieme e attendere tutti
Il modo moderno (Python 3.11+) è il gruppo di attività. Dentro il blocco async with ogni attività parte subito tramite tg.create_task(), e l'uscita dal blocco avviene quando tutte sono terminate.
async def main():
start = time.perf_counter()
async with asyncio.TaskGroup() as tg:
tasks = {cur: tg.create_task(fetch_rate(cur)) for cur in ("USD", "GBP", "CNY")}
rates = {cur: t.result() for cur, t in tasks.items()}
print(rates, f"{time.perf_counter() - start:.1f} s") # ... 1.0 s
asyncio.run(main())
TaskGroup agisce con rigore: se un'attività fallisce, le altre vengono annullate e tutti gli errori si raccolgono in un ExceptionGroup. Si intercettano con except*:
async def main():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch_rate("USD"))
tg.create_task(fetch_rate("XXX")) # KeyError
except* KeyError as eg:
print("valuta sconosciuta:", eg.exceptions)
asyncio.gather e create_task
Prima di TaskGroup lo standard era asyncio.gather(): avvia le coroutine contemporaneamente e restituisce i risultati nello stesso ordine. Nel codice esistente è ancora molto diffuso.
async def main():
usd, gbp, cny = await asyncio.gather(
fetch_rate("USD"), fetch_rate("GBP"), fetch_rate("CNY")
)
# con return_exceptions=True gli errori tornano nella lista invece di interrompere tutto
asyncio.create_task() avvia una coroutine in background senza un gruppo. Il riferimento alla task va conservato, altrimenti può essere eliminato dal garbage collector e gli errori non verrebbero visti da nessuno. Per i progetti nuovi TaskGroup è quindi più affidabile.
Annullamento delle attività e timeout
async def main():
task = asyncio.create_task(fetch_rate("USD"))
task.cancel() # dentro la task viene sollevato CancelledError
try:
await task
except asyncio.CancelledError:
print("annullata")
try:
async with asyncio.timeout(0.5): # Python 3.11+
await fetch_rate("GBP")
except TimeoutError:
print("troppo lento")
Nelle versioni vecchie, al posto di asyncio.timeout() si usa asyncio.wait_for(coro, timeout=0.5). C'è anche asyncio.wait(), che restituisce gli insiemi delle attività completate e di quelle in corso e sa attendere la prima (return_when=asyncio.FIRST_COMPLETED).
Nella Python 3.15, come mostrato nel corso, TaskGroup ha ottenuto il metodo tg.cancel(), che annulla tutte le attività del gruppo senza eccezioni. Questo risolve lo scenario «chi arriva primo»: interrogare tre server speculari, prendere la prima risposta e annullare le altre. In precedenza per farlo si sollevava un'eccezione-segnale personalizzata oppure si annullavano le attività a mano.
Sincronizzazione: Semaphore, Lock, Event, Queue
Asyncio ha primitive proprie, che funzionano con await. La più utile è Semaphore: limita il numero di operazioni simultanee, per non mandare in crisi un'API esterna con mille richieste.
sem = asyncio.Semaphore(10)
async def limited(url):
async with sem: # non più di 10 contemporaneamente
await asyncio.sleep(0.1) # qui ci sarebbe la richiesta
return url
asyncio.Lock— una sola coroutine alla volta nella sezione critica.asyncio.Event— una coroutine aspetta conawait event.wait(), un'altra segnala conevent.set().asyncio.Queue— coda «produttore — consumatore»:await q.put(),await q.get(),q.task_done().
Trappola: await dimenticato
L'errore più comune è chiamare una coroutine senza await:
async def main():
rate = fetch_rate("USD") # await dimenticato
print(rate * 2) # TypeError
# RuntimeWarning: coroutine 'fetch_rate' was never awaited
Python non ha eseguito la funzione, ha soltanto creato l'oggetto coroutine. Se nei log vedete «was never awaited», cercate un await mancante. Il secondo errore tipico è una chiamata bloccante (time.sleep, requests.get) dentro una coroutine. Per l'HTTP usate librerie asincrone (httpx, aiohttp), mentre il codice sincrono inevitabile va spostato in un thread con await asyncio.to_thread(func, arg).
Multithreading in Python: il modulo threading
Un thread è un flusso di esecuzione indipendente all'interno dello stesso processo; i thread condividono la memoria. Per l'attesa della rete funzionano benissimo e non richiedono di riscrivere il codice in modo asincrono.
import threading, time
def download(name):
time.sleep(1) # attesa della rete
print(name, "pronto")
threads = [threading.Thread(target=download, args=(n,)) for n in ("a", "b", "c")]
for t in threads:
t.start()
for t in threads:
t.join() # tutto in circa 1 secondo
La memoria condivisa è un vantaggio e allo stesso tempo una fonte di errori: se due thread modificano la stessa variabile, serve un threading.Lock. Di solito conviene non gestire i thread a mano, ma usare un pool.
ThreadPoolExecutor
concurrent.futures.ThreadPoolExecutor crea un pool di thread e distribuisce loro le attività. map restituisce i risultati nell'ordine originale.
from concurrent.futures import ThreadPoolExecutor
import time
def fetch(url):
time.sleep(1)
return f"{url}: 200"
urls = [f"https://example.com/{i}" for i in range(5)]
with ThreadPoolExecutor(max_workers=5) as pool:
for result in pool.map(fetch, urls):
print(result) # tutto in circa 1 secondo
Che cos'è il GIL e perché i thread non accelerano i calcoli
Il GIL (Global Interpreter Lock) è il lucchetto globale dell'interprete CPython. In ogni istante il bytecode di Python viene eseguito da un solo thread. Mentre un thread aspetta l'I/O, rilascia il GIL, e per la rete i thread sono quindi utili. Nei calcoli puri invece quattro thread non vanno più veloci di uno: si passano il lucchetto a turno.
from concurrent.futures import ThreadPoolExecutor
def heavy(n):
return sum(i * i for i in range(n))
with ThreadPoolExecutor(4) as pool:
list(pool.map(heavy, [5_000_000] * 4)) # circa come in sequenza
Le librerie scritte in C (NumPy e simili) spesso rilasciano il GIL durante le operazioni pesanti, quindi per loro i thread portano un'accelerazione.
multiprocessing: parallelismo vero
Ogni processo ha il proprio interprete e il proprio GIL, quindi i processi calcolano contemporaneamente su core diversi. Il prezzo è la memoria separata: i dati tra processi si trasferiscono con la serializzazione (pickle), e avviare un processo costa più di un thread.
from concurrent.futures import ProcessPoolExecutor
def heavy(n):
return sum(i * i for i in range(n))
if __name__ == "__main__": # obbligatorio su Windows e macOS
with ProcessPoolExecutor() as pool:
print(list(pool.map(heavy, [5_000_000] * 4)))
Senza il controllo if __name__ == "__main__" i processi figli, durante l'import del modulo, cercherebbero di crearne altri. Esiste anche il modulo di basso livello multiprocessing, con Process, Pool, Queue, ma ProcessPoolExecutor è più semplice e ha la stessa interfaccia del pool di thread.
Che cosa scegliere: asyncio, threading o multiprocessing
| Compito | Strumento | Perché |
|---|---|---|
| Scaricare 200 pagine, molte richieste a un'API | asyncio (httpx/aiohttp) oppure ThreadPoolExecutor | attesa della rete, il processore è libero |
| Più richieste dentro un programma già asincrono | TaskGroup | il codice è già async |
| Libreria sincrona senza versione async | thread, asyncio.to_thread | non serve riscrivere il codice |
| Calcolo pesante in Python puro | ProcessPoolExecutor | aggira il GIL, usa tutti i core |
Python senza GIL e sotto-interpreti: le novità
Dalla versione 3.13 esiste una build senza GIL (free-threading, PEP 703), riconoscibile dalla lettera t nel nome, per esempio python3.15t. Nella 3.13 era sperimentale, dalla 3.14 (PEP 779) è ufficialmente supportata. In essa i thread calcolano in parallelo su core diversi. Ma per impostazione predefinita si ottiene ancora il Python classico con il GIL: il codice a thread singolo nella build free-threaded è un po' più lento e non tutte le librerie sono pronte.
import sys
print(sys._is_gil_enabled()) # False nella build free-threaded
Un secondo percorso verso i calcoli paralleli è comparso nella 3.14 (PEP 734): sono i sotto-interpreti nel modulo concurrent.interpreters, cioè più interpreti isolati nello stesso processo, ciascuno con il proprio GIL. Si usano comodamente tramite InterpreterPoolExecutor, che ha la stessa forma di un pool di thread. Nella misura del corso quattro attività pesanti hanno impiegato 0,39 s contro 1,03 s in un solo thread.
Conclusioni
Asyncio in Python è lo strumento per le attese: coroutine con async def, pause con await, avvio simultaneo tramite TaskGroup o gather. I thread risolvono lo stesso problema senza riscrivere il codice in modo asincrono, mentre per i calcoli pesanti servono i processi, perché il GIL impedisce ai thread del CPython classico di calcolare in parallelo. Free-threading e sotto-interpreti stanno cambiando gradualmente il quadro, ma la regola di base «se si aspetta, asyncio o thread; se si calcola, processi» vale ancora. Nel corso tutto questo è trattato in pratica: il tracker delle spese richiede in parallelo i tassi di cambio e impiega un secondo invece di tre.
Domande frequenti
Si può chiamare una funzione async da codice normale?
Sì, con asyncio.run(coro()): crea il ciclo degli eventi, esegue la coroutine e chiude il ciclo. Dentro un ciclo già in esecuzione asyncio.run non si può chiamare, lì si usa await.
Asyncio lavora su più thread?
No, il ciclo degli eventi esegue tutte le coroutine in un solo thread e passa da una all'altra solo nei punti await. Per questo le race condition sono meno frequenti che con i thread, ma una singola operazione bloccante ferma tutto.
Serve installare asyncio con pip?
No, asyncio fa parte della libreria standard di Python 3: basta scrivere import asyncio. Il pacchetto asyncio su PyPI è obsoleto e non va installato.
Come eseguire una funzione pesante dentro asyncio senza bloccare il ciclo?
Usate loop.run_in_executor con un ProcessPoolExecutor per i calcoli, oppure asyncio.to_thread per l'I/O bloccante; il ciclo continua a servire le altre attività.
In che cosa una coroutine si differenzia da una task?
La coroutine è un oggetto che non fa nulla finché qualcuno non la attende con await. La task incapsula la coroutine e la mette subito in programma nel ciclo degli eventi, quindi viene eseguita in parallelo con il resto del codice.
Questo argomento è trattato in modo approfondito, con pratica, nel corso «Python 3.15: от новичка до профи».
Vai al corso