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

URL: https://skillaria.top/it/guides/python/asyncio-python

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

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

## FAQ

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


---
Source: Skillaria.Top (https://skillaria.top)
