8.2. Coroutine e task

Le coroutine sono l’unità di lavoro da cui è costruito un programma asyncio; i task sono il modo in cui un’applicazione esegue più coroutine in concorrenza.

8.2.1. Coroutine

Una coroutine è una funzione dichiarata con async def

import asyncio

async def heartbeat(interval_ms):
    while True:
        print("tick")
        await asyncio.sleep_ms(interval_ms)

Il corpo sembra una funzione ordinaria, con un ingrediente in più: await. Ovunque la coroutine debba aspettare qualcosa – uno sleep, una lettura di rete, un evento che viene impostato – esegue await su un’espressione che sa come sospendere la coroutine finché ciò che sta aspettando non è pronto. A ogni await la coroutine restituisce il controllo ad asyncio; asyncio la riprende dallo stesso punto una volta completata l’operazione attesa.

Il modulo asyncio fornisce due sleep:

  • asyncio.sleep() – argomento in secondi, accetta un float.

  • asyncio.sleep_ms() – argomento in millisecondi, accetta un int. Un’estensione di MicroPython; di solito la scelta giusta sulla camera, poiché i parametri di temporizzazione nel firmware sono espressi in millisecondi.

Un semplice async def non fa nulla di per sé. Chiamare heartbeat(500) non esegue il corpo; restituisce un oggetto coroutine che asyncio deve schedulare. Il modo più semplice per schedularne uno è asyncio.run()

asyncio.run(heartbeat(500))

asyncio.run() avvia l’event loop, schedula la coroutine che gli è stata passata come punto di ingresso di primo livello, guida il loop finché quella coroutine non ritorna, quindi smonta il loop. Per una singola coroutine, questo è l’intero programma. Per più coroutine, l’applicazione ricorre ai task.

8.2.2. Task

Un task è il wrapper di asyncio attorno a una coroutine che dice schedula questa in concorrenza con quella corrente e lasciami continuare. asyncio.create_task() ne crea uno e restituisce un oggetto Task che rappresenta il lavoro schedulato:

task = asyncio.create_task(heartbeat("fast", 100))

La coroutine è ora nello scheduler del loop; il chiamante non l’ha attesa. Il Task restituito è l’handle che il chiamante utilizza successivamente per interagire con quel lavoro in esecuzione.

Una volta che l’applicazione ha l’handle può farci tre cose:

  • Attendere che il task finisca. Un Task è esso stesso awaitable. result = await task sospende la coroutine corrente finché la coroutine di task non ritorna, quindi riprende con qualsiasi cosa quella coroutine abbia restituito (o rilancia qualsiasi eccezione abbia sollevato).

  • Annullare il task. task.cancel() schedula il sollevamento di asyncio.CancelledError all’interno della coroutine del task al suo prossimo await, dandole la possibilità di eseguire codice di pulizia in un blocco finally. La pagina su timeout e annullamento ne tratta i dettagli.

  • Identificarlo in seguito. asyncio.current_task() restituisce il Task per la coroutine attualmente in esecuzione. La maggior parte degli script non lo chiama mai; compare nella strumentazione e nei gestori di eccezioni.

Lo script non deve catturare l’handle ogni volta. I task in background usa-e-getta che l’applicazione avvia e lascia in esecuzione possono scartare il valore di ritorno – il loop li schedula comunque:

import asyncio

async def heartbeat(name, interval_ms):
    while True:
        print(name)
        await asyncio.sleep_ms(interval_ms)

async def main():
    asyncio.create_task(heartbeat("fast", 100))
    asyncio.create_task(heartbeat("slow", 500))
    await asyncio.sleep(5)

asyncio.run(main())

Le due chiamate a create_task schedulano entrambi gli heartbeat senza attendere nessuno dei due. Il controllo ritorna immediatamente a main, che poi esegue await su uno sleep di cinque secondi. Mentre dorme, i due task heartbeat avanzano; il loop scorre tra quale task è pronto per l’esecuzione. Dopo cinque secondi main ritorna, il loop smonta tutti i task ancora attivi e asyncio.run() ritorna al chiamante.

Cattura l’handle ogni volta che l’applicazione ha effettivamente bisogno di una delle tre operazioni di cui sopra. In pratica questo significa quasi sempre, perché arrestare in modo pulito un’applicazione significa annullare i task in background che ha generato – la pagina sull’annullamento tratta questo schema.

8.2.3. La regola delle due righe

Il programma asyncio minimo è costituito dalle due righe con cui terminano gli esempi precedenti:

async def main():
    ...

asyncio.run(main())

Tutto il resto – i task che l’applicazione crea, le primitive con cui li coordina, gli stream che apre – avviene dentro main (e dentro le coroutine che main genera). Quando uno script supera il classico ciclo while True: csi0.snapshot() della camera, la risposta non è chiamare asyncio.run() in più punti; è integrare il nuovo lavoro in main sotto forma di altri task.