8.15. Insidie

Gli stessi schemi che rendono asyncio piacevole – nessuna prelazione, await espliciti – gli conferiscono anche una serie di situazioni che possono mordere. Questa pagina è il catalogo di quelle che si presentano abbastanza spesso da valere la pena di conoscerle.

8.15.1. Dimenticare await

Chiamare una funzione async def restituisce un oggetto coroutine. Non esegue il corpo della funzione. Per eseguirlo davvero, la coroutine deve essere attesa con await o racchiusa in un task:

async def main():
    send_request()              # bug: returns the coroutine, does nothing
    await send_request()        # right: run it to completion
    asyncio.create_task(send_request())  # right: run it concurrently

Il bug è silenzioso – l’oggetto coroutine viene creato, scartato e mai eseguito. L’applicazione procede come se tutto avesse funzionato. MicroPython a volte registra un avviso che una coroutine non è mai stata attesa; a volte no. Controlla la presenza di await mancanti in ogni punto di chiamata che sembra una chiamata di funzione.

8.15.2. Cicli stretti senza await

Una coroutine che gira in un ciclo e non esegue mai await monopolizza l’event loop. Nessun altro task avanza finché il ciclo non termina o non cede il controllo:

async def counter():
    n = 0
    while True:
        n += 1               # bug: starves the loop

La soluzione è un yield all’interno del ciclo – di solito await asyncio.sleep_ms(0) – così gli altri task pronti hanno la possibilità di essere eseguiti. Anche il lavoro a forte carico computazionale rientra in questa forma: un ciclo di elaborazione delle immagini che gira per centinaia di millisecondi per iterazione dovrebbe cedere il controllo almeno una volta per iterazione, così il resto del programma non si blocca.

8.15.3. Ingoiare CancelledError

La pagina sull’annullamento ha già trattato questo punto in dettaglio. Lo si ripete qui perché è la causa più comune di «la mia applicazione non si spegne»: una coroutine cattura asyncio.CancelledError a scopo di pulizia e dimentica di risollevarla. Il task continua a girare; il chiamante che ha richiesto l’annullamento resta appeso per sempre in attesa che termini. Risolleva sempre dopo la pulizia, oppure usa un blocco try/finally invece di un except esplicito.

8.15.4. Mutare lo stato condiviso attraverso gli await

Lo scheduling cooperativo garantisce che una coroutine abbia la CPU tutta per sé tra un await e l’altro, ma non appena esegue await un’altra coroutine può essere eseguita. Se due coroutine modificano la stessa struttura dati in passi che includono await, le loro operazioni possono intrecciarsi in modi che corrompono la struttura:

# bug: two tasks running do_work simultaneously can
# interleave around the await and corrupt items
async def do_work():
    n = len(items)
    await asyncio.sleep_ms(0)
    items.append(some_work(n))

Per lo stato che viene mutato solo tra un await e l’altro all’interno di una sola coroutine, non serve alcuna sincronizzazione. Per lo stato mutato attraverso gli await e a cui si accede da più di una coroutine, racchiudi la sezione critica in un Lock.

8.15.5. await a livello di modulo

await è valido solo all’interno di un corpo async def. Scriverlo a livello di modulo – al di fuori di qualsiasi coroutine – è un errore di sintassi:

# bug: not inside an async def
result = await fetch()

La soluzione è inserire il lavoro in una coroutine e chiamarla dal punto di ingresso asyncio.run() del programma.

8.15.6. Chiamate multiple a asyncio.run

MicroPython ha un event loop. Chiamare asyncio.run() due volte di seguito – una per la configurazione, una per il lavoro principale – usa comunque lo stesso loop. Chiamarlo dall’interno di una coroutine in esecuzione è un errore: il loop è già in esecuzione. Entrambi i casi si presentano più spesso quando uno script cresce in modo organico e l’autore cerca di estenderlo aggiungendo altre chiamate a run() invece di integrare il nuovo lavoro nel main esistente.

8.15.7. Uso di Event da un interrupt

asyncio.Event.set() può essere chiamato in sicurezza solo dall’interno dell’event loop. Chiamarlo da un gestore di interrupt GPIO è un rischio di corruzione. Per risvegliare un task da un interrupt, usa invece ThreadSafeFlag – la pagina dedicata ne illustra la forma.

8.15.8. Chiamate sincrone lunghe

Una coroutine può attendere le primitive di attesa proprie di asyncio; qualsiasi altra cosa chiami viene eseguita in modo sincrono e blocca il loop finché non ritorna. Una time.sleep() bloccante di 200 ms, una scrittura su scheda SD che impiega 80 ms a fare il flush, una grande compressione JPEG, una chiamata a csi.CSI.snapshot() – ognuna di queste trattiene l’event loop per tutta la sua durata. La soluzione dipende dalla chiamata:

  • Per time.sleep: sostituiscila con await asyncio.sleep o await asyncio.sleep_ms.

  • Per csi.CSI.snapshot: usa il wrapper async per snapshot costruito dalla pagina sull’acquisizione.

  • Per i calcoli lunghi (elaborazione delle immagini, codifica JPEG): accetta il costo oppure suddividi il lavoro in blocchi che eseguono await tra le iterazioni.

Asyncio non può rendere non bloccante una chiamata sincrona. Può solo permettere ad altre coroutine di essere eseguite mentre qualcos’altro è in attesa.