Basi

Asyncio in Python: async/await, TaskGroup, thread, multiprocessing e GIL

Aggiornato: · Skillaria.Top

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 con await event.wait(), un'altra segnala con event.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

CompitoStrumentoPerché
Scaricare 200 pagine, molte richieste a un'APIasyncio (httpx/aiohttp) oppure ThreadPoolExecutorattesa della rete, il processore è libero
Più richieste dentro un programma già asincronoTaskGroupil codice è già async
Libreria sincrona senza versione asyncthread, asyncio.to_threadnon serve riscrivere il codice
Calcolo pesante in Python puroProcessPoolExecutoraggira 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.

Impara l’argomento in video

Questo argomento è trattato in modo approfondito, con pratica, nel corso «Python 3.15: от новичка до профи».

Vai al corso