Introduzione
La programmazione asincrona con async e await è molto efficace quando un’applicazione deve gestire operazioni di attesa, come richieste di rete, accesso a servizi esterni o lettura di dati. Mentre un’operazione asincrona è in attesa, l’event loop può eseguire altre coroutine.
Il problema nasce quando, all’interno di una coroutine, dobbiamo usare una funzione sincrona e bloccante. Alcuni esempi comuni sono:
- leggere file usando normali funzioni di I/O;
- chiamare una libreria HTTP che non supporta async;
- eseguire una funzione che calcola dati per molto tempo;
- usare una libreria legacy progettata esclusivamente per il codice sincrono.
Se chiamiamo direttamente queste funzioni dentro l’event loop, blocchiamo tutte le altre coroutine. Python offre asyncio.to_thread(), disponibile dalla versione 3.9, per eseguire una funzione bloccante in un thread separato senza fermare il ciclo asincrono.
Questo approccio è utile soprattutto per integrare codice esistente in una nuova applicazione asincrona, senza dover riscrivere immediatamente tutte le librerie utilizzate.
Codice completo
import asyncio
import time
from pathlib import Path
def leggi_file_sincrono(percorso: str) -> str:
"""Funzione bloccante: simula la lettura di un file lento."""
time.sleep(2)
return Path(percorso).read_text(encoding="utf-8")
def analizza_testo(testo: str) -> dict:
"""Elabora il testo in modo sincrono."""
parole = testo.split()
return {
"caratteri": len(testo),
"parole": len(parole),
}
async def carica_e_analizza(percorso: str) -> dict:
# La lettura viene spostata in un thread separato.
testo = await asyncio.to_thread(leggi_file_sincrono, percorso)
# Anche l´analisi è una funzione sincrona.
# Per un´elaborazione breve può essere eseguita direttamente.
risultato = analizza_testo(testo)
return {
"file": percorso,
"risultato": risultato,
}
async def mostra_stato():
"""Dimostra che l´event loop continua a funzionare."""
for numero in range(6):
print(f"Event loop attivo: {numero + 1}")
await asyncio.sleep(0.5)
async def main():
percorso = "documento.txt"
# Creiamo un file di esempio per il tutorial.
Path(percorso).write_text(
"Python asincrono permette di gestire molte attese in modo efficiente.",
encoding="utf-8",
)
task_file = asyncio.create_task(carica_e_analizza(percorso))
task_stato = asyncio.create_task(mostra_stato())
risultato, _ = await asyncio.gather(task_file, task_stato)
print("Risultato:", risultato)
if __name__ == "__main__":
asyncio.run(main()) Spiegazione
Perché una funzione sincrona può essere un problema?
Consideriamo questa chiamata:
testo = leggi_file_sincrono("documento.txt") La funzione contiene time.sleep(2) e simula un’operazione lenta. Durante quei due secondi il thread principale resta bloccato. Di conseguenza, l’event loop non può eseguire altre coroutine, anche se queste sarebbero pronte a lavorare.
In un server asincrono, questo comportamento può causare rallentamenti evidenti: una singola richiesta lenta potrebbe impedire di rispondere rapidamente a tutte le altre richieste.
Come funziona asyncio.to_thread()
La funzione accetta una funzione sincrona e i suoi argomenti:
risultato = await asyncio.to_thread(funzione, argomento1, argomento2) Python esegue funzione in un thread appartenente a un pool gestito dall’event loop. La coroutine corrente viene sospesa con await, ma l’event loop può continuare a eseguire altre attività.
Nel codice completo, mentre leggi_file_sincrono() attende due secondi, la coroutine mostra_stato() continua a stampare messaggi ogni mezzo secondo. Questo dimostra che il thread principale non è bloccato.
Quando usare direttamente la funzione?
Nel nostro esempio, l’analisi del testo viene eseguita direttamente:
risultato = analizza_testo(testo) Questa scelta è corretta se l’elaborazione è breve. Se invece la funzione esegue un calcolo pesante per molto tempo, è possibile spostarla in un thread:
risultato = await asyncio.to_thread(analizza_testo, testo) È importante distinguere tra operazioni bloccanti di I/O e calcoli intensivi. I thread sono generalmente adatti all’I/O bloccante. Per calcoli CPU-bound molto pesanti, a causa del Global Interpreter Lock di CPython, può essere più appropriato usare processi separati, ad esempio con ProcessPoolExecutor.
Gestione degli errori
Le eccezioni sollevate dalla funzione eseguita nel thread vengono propagate quando si usa await. Possiamo quindi gestirle normalmente:
async def carica_sicuro(percorso: str):
try:
return await asyncio.to_thread(leggi_file_sincrono, percorso)
except FileNotFoundError:
print(f"File non trovato: {percorso}")
return None Best practice
- Non usare to_thread per tutto: preferisci librerie native asincrone quando sono disponibili.
- Individua le chiamate bloccanti: cerca operazioni di file, accessi a database sincroni, time.sleep() e client HTTP non asincroni.
- Non confondere sleep sincrono e asincrono: dentro una coroutine usa await asyncio.sleep(), non time.sleep().
- Mantieni le funzioni isolate: racchiudi il codice legacy bloccante in funzioni piccole e facilmente testabili.
- Fai attenzione alla sicurezza dei dati condivisi: più thread possono accedere a risorse comuni. Evita variabili globali modificabili o proteggile adeguatamente.
- Ricorda che to_thread non rende magicamente asincrona una funzione: la funzione rimane sincrona, viene soltanto eseguita fuori dal thread dell’event loop.
- Verifica la compatibilità: asyncio.to_thread() richiede Python 3.9 o versioni successive.
Riepilogo
asyncio.to_thread() è uno strumento pratico per usare funzioni sincrone bloccanti all’interno di un’applicazione asincrona. La funzione viene eseguita in un thread separato, mentre l’event loop resta libero di gestire altre coroutine.
Il modello fondamentale è:
risultato = await asyncio.to_thread(funzione_sincrona, argomenti) Usalo soprattutto per integrare codice legacy o librerie che non supportano async. Quando possibile, però, scegli librerie asincrone native: offrono un controllo più preciso delle connessioni e consumano generalmente meno risorse rispetto ai thread.
