8.5. Timeout e annullamento

L’annullamento è il nome che asyncio usa per «smettere di eseguire questa coroutine, sollevare un’eccezione al suo interno così che abbia la possibilità di fare pulizia, e rimuoverla dallo scheduler». I timeout sono il motivo più comune per annullare qualcosa; il task.cancel() manuale è l’altro.

8.5.1. Annullare un task

Chiamare Task.cancel schedula il sollevamento di asyncio.CancelledError all’interno della coroutine in esecuzione, in corrispondenza del suo successivo await. La coroutine è libera di ignorare l’annullamento (intercettare l’eccezione e continuare l’esecuzione) oppure di rispettarlo – la scelta consueta è rispettarlo dopo aver eseguito il codice di pulizia:

async def network_worker(stream):
    try:
        while True:
            data = await stream.read(64)
            # ...
    finally:
        stream.close()

Una semplice clausola finally è il pattern più pulito: la pulizia viene eseguita sia che la coroutine sia uscita normalmente, sia che abbia sollevato un’eccezione non correlata, sia che sia stata annullata. La CancelledError si propaga di nuovo verso l’alto attraverso il finally, il loop vede che il task è terminato e il chiamante di await task vede l’annullamento.

Intercettare esplicitamente CancelledError va bene anche quando l’applicazione vuole farne qualcosa di specifico – registrarla, cedere la risorsa in modo pulito, ecc. La regola è: ri-sollevarla dopo che la pulizia è stata eseguita. Ingoiare CancelledError mantiene viva la coroutine quando il suo chiamante le ha chiesto di fermarsi, il che è quasi sempre un bug:

async def worker():
    try:
        await long_running_thing()
    except asyncio.CancelledError:
        log("worker cancelled, cleaning up")
        close_resources()
        raise          # << this line is the important one

8.5.2. Timeout con wait_for

asyncio.wait_for() incapsula un’awaitable in una scadenza. Se l’awaitable termina entro il timeout, il suo risultato viene restituito. In caso contrario, l’awaitable viene annullata e il chiamante riceve asyncio.TimeoutError

try:
    frame = await asyncio.wait_for(grab_frame(), timeout=2)
except asyncio.TimeoutError:
    print("camera took too long")

L’argomento dei secondi accetta un float. Per scadenze nell’ordine dei millisecondi, asyncio.wait_for_ms() accetta un conteggio intero di millisecondi – un’estensione di MicroPython che si allinea con le manopole di temporizzazione del firmware con granularità al millisecondo.

Internamente, wait_for fa esattamente ciò che farebbe un annullamento manuale: quando la scadenza scade chiama cancel() sul task incapsulato, viene sollevata CancelledError all’interno della coroutine, le clausole finally vengono eseguite, e una volta completata la pulizia l’eccezione viene tradotta in una TimeoutError per il chiamante.

Ciò significa che una coroutine che intercetta e ignora CancelledError vanificherà il timeout – la scadenza è scaduta, ma la coroutine si è rifiutata di fermarsi, e wait_for non può forzarla. Vale anche qui la regola precedente: intercettare CancelledError solo per eseguire la pulizia, poi ri-sollevarla.

8.5.3. Annullamento tramite gather

L’annullamento si propaga verso il basso attraverso gather(). Se il task che sta attendendo una chiamata a gather viene annullato, anche ogni awaitable ancora in esecuzione all’interno del gather viene annullata – ciascuna ha la possibilità di fare pulizia attraverso le proprie clausole finally prima che l’annullamento risalga fino al chiamante.

Combinato con i timeout, questo è il modo standard per imporre una scadenza a un gruppo di operazioni:

await asyncio.wait_for(
    asyncio.gather(a(), b(), c()),
    timeout=5,
)

O ogni sotto-operazione termina entro cinque secondi, oppure vengono tutte annullate insieme.

8.5.4. Spegnere un’applicazione

L’annullamento è anche il modo in cui un’applicazione reale si arresta in modo pulito. Il pattern è coerente tra i vari script: main cattura gli handle dei task di background a lunga durata che ha avviato, esegue il proprio lavoro di livello superiore, poi annulla ciascun handle e lo attende in un blocco finally

async def main():
    sender = asyncio.create_task(uplink())
    watcher = asyncio.create_task(button_watcher())
    try:
        await snapshot_loop()
    finally:
        sender.cancel()
        watcher.cancel()
        await asyncio.gather(sender, watcher,
                             return_exceptions=True)

Il return_exceptions=True è il trucco che impedisce al gather di ri-sollevare la CancelledError che ciascun task figlio sta per consegnare, così che sia il motivo di uscita dell’applicazione stessa – qualunque eccezione snapshot_loop abbia o non abbia sollevato – a emergere da main.