Python Async e await: gestire task cancellabili e shutdown pulito

by theArchitect
SHARE
Python Async e await: gestire task cancellabili e shutdown pulito
© Guida-HTML5.it

Introduzione

Quando si lavora con asyncio in Python, non basta saper avviare coroutine o eseguire operazioni concorrenti: in applicazioni reali bisogna anche saperle interrompere in modo sicuro. Questo è fondamentale in servizi web, worker, bot, client di rete e script che restano attivi a lungo. Un task asincrono può essere cancellato perché l’utente chiude il programma, perché arriva un segnale di stop, oppure perché il sistema deve liberare risorse rapidamente.

In questo tutorial vedremo un sotto-argomento molto pratico e spesso trascurato: come gestire la cancellazione dei task e fare uno shutdown pulito. Imparerai a usare asyncio.Task, task.cancel(), la gestione di asyncio.CancelledError e la chiusura ordinata di risorse come connessioni, file o sessioni di rete.

Questo approccio è essenziale per scrivere codice asincrono robusto, evitando task “orfani”, risorse lasciate aperte e comportamenti imprevedibili durante l’arresto dell’applicazione.

Codice completo

import asyncio


async def fetch_data(name: str, delay: float) -> str:
    """
    Simula un´operazione asincrona lunga.
    Durante l´attesa può essere cancellata.
    """
    print(f"[{name}] avviato")
    try:
        # Simuliamo lavoro I/O-bound
        await asyncio.sleep(delay)
        print(f"[{name}] completato")
        return f"risultato-{name}"
    except asyncio.CancelledError:
        print(f"[{name}] cancellato: eseguo pulizia finale")
        # Qui puoi chiudere connessioni, salvare stato, rilasciare risorse...
        raise  # Importante: rilanciare l´eccezione dopo la cleanup


async def worker(stop_event: asyncio.Event) -> None:
    """
    Task principale che continua a lavorare finché non riceve il segnale di stop.
    """
    i = 0
    try:
        while not stop_event.is_set():
            i += 1
            print(f"[worker] ciclo {i}")
            await asyncio.sleep(0.5)
    except asyncio.CancelledError:
        print("[worker] cancellato: chiusura ordinata in corso")
        raise
    finally:
        print("[worker] cleanup finale eseguito")


async def main() -> None:
    stop_event = asyncio.Event()

    # Creiamo task separati
    t1 = asyncio.create_task(fetch_data("A", 2.0))
    t2 = asyncio.create_task(fetch_data("B", 5.0))
    t3 = asyncio.create_task(worker(stop_event))

    try:
        # Aspettiamo un po´ e poi simuliamo una richiesta di arresto
        await asyncio.sleep(3)
        print("[main] richiesta di shutdown ricevuta")

        # Segnaliamo al worker di fermarsi
        stop_event.set()

        # Cancelliamo il task ancora in esecuzione
        t2.cancel()

        # Aspettiamo che tutti i task terminino davvero
        results = await asyncio.gather(t1, t2, t3, return_exceptions=True)
        print("[main] risultati finali:", results)

    finally:
        # In caso di errore o uscita anticipata, assicuriamoci che
        # eventuali task rimasti vengano cancellati.
        for task in (t1, t2, t3):
            if not task.done():
                task.cancel()

        await asyncio.gather(t1, t2, t3, return_exceptions=True)
        print("[main] shutdown pulito completato")


if __name__ == "__main__":
    asyncio.run(main())

Spiegazione

Il punto chiave della programmazione asincrona non è solo avviare task, ma anche terminarli correttamente. Il codice sopra mostra tre elementi importanti:

1. Creazione dei task con asyncio.create_task()

Quando chiami asyncio.create_task(), la coroutine viene programmata per l’esecuzione concorrente. Non viene eseguita subito in modo bloccante: il loop potrà alternarla con altri task quando incontra punti di attesa come await asyncio.sleep() o operazioni di I/O.

2. Cancellazione con task.cancel()

Il metodo cancel() non interrompe il task istantaneamente come un “kill” brutale. Invia una richiesta di cancellazione. La coroutine riceverà una asyncio.CancelledError al prossimo punto di sospensione utile.

Per questo motivo è importante che il codice asincrono usi spesso await: è lì che la cancellazione può essere intercettata e gestita.

3. Gestione di asyncio.CancelledError

Nel nostro esempio, sia fetch_data() sia worker() intercettano CancelledError. Questo serve per eseguire una pulizia finale: chiudere socket, rilasciare lock, salvare dati temporanei o chiudere sessioni HTTP.

Attenzione: dopo la cleanup, l’eccezione va quasi sempre rilanciata con raise. Se la sopprimi senza motivo, il task sembrerà terminato normalmente e il codice chiamante potrebbe non capire che è stato cancellato.

4. Uso di asyncio.Event per uno stop cooperativo

Per il task worker() abbiamo usato asyncio.Event. È una soluzione elegante quando vuoi fermare un task senza cancellarlo forzatamente. Il task controlla periodicamente stop_event.is_set() e si interrompe in modo naturale. Questo è spesso preferibile alla cancellazione diretta, perché permette una logica di uscita più controllata.

5. Raccolta finale con asyncio.gather(..., return_exceptions=True)

Quando cancelli task, è buona pratica attendere che finiscano davvero. asyncio.gather(..., return_exceptions=True) impedisce che una cancellazione o un errore di un task blocchi la raccolta degli altri risultati. In un shutdown, l’obiettivo non è “fallire velocemente”, ma “chiudere bene”.

Best practice

  • Gestisci sempre la cancellazione nei task che fanno I/O o loop lunghi.
  • Usa finally per cleanup garantito: è il posto giusto per chiudere risorse.
  • Rilancia CancelledError dopo aver fatto la pulizia, salvo casi davvero specifici.
  • Preferisci uno stop cooperativo con asyncio.Event quando vuoi una terminazione più ordinata.
  • Evita task “fire and forget” senza una strategia di arresto: in applicazioni reali diventano difficili da controllare.
  • Chiudi sempre le risorse esterne: sessioni HTTP, connessioni DB, file, stream e client di rete.
  • Usa asyncio.run() come entry point principale: semplifica la gestione del loop e dello shutdown.

Riepilogo

La cancellazione dei task è una parte fondamentale della programmazione asincrona in Python. Saper avviare coroutine è utile, ma saperle fermare correttamente è ciò che distingue un esempio didattico da un’applicazione affidabile.

In questo tutorial hai visto come:

  • creare task asincroni con asyncio.create_task();
  • richiedere la cancellazione con task.cancel();
  • gestire asyncio.CancelledError;
  • usare asyncio.Event per uno stop cooperativo;
  • chiudere tutto con uno shutdown pulito e prevedibile.

Se costruisci servizi asincroni, questa è una competenza indispensabile: riduce bug difficili da riprodurre e rende il codice più professionale e mantenibile.

Approfondisci con risorse ufficiali

SHARE