8.2. Coroutines en tasks

Coroutines zijn de eenheid werk waaruit een asyncio-programma is opgebouwd; tasks zijn de manier waarop een applicatie meerdere coroutines gelijktijdig draait.

8.2.1. Coroutines

Een coroutine is een functie gedeclareerd met async def

import asyncio

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

Het lichaam ziet eruit als een gewone functie, met één extra ingrediënt: await. Overal waar de coroutine op iets moet wachten – een sleep, een netwerklezing, een gebeurtenis die wordt ingesteld – awaitt hij een expressie die weet hoe hij de coroutine moet opschorten totdat datgene waarop hij wacht klaar is. Bij elke await geeft de coroutine de controle terug aan asyncio; asyncio hervat hem vanaf hetzelfde punt zodra de geawaitte operatie is voltooid.

De asyncio-module levert twee sleeps:

  • asyncio.sleep() – argument in seconden, accepteert een float.

  • asyncio.sleep_ms() – argument in milliseconden, neemt een int. Een MicroPython-uitbreiding; meestal de juiste keuze op de camera omdat de timinginstellingen in de firmware in milliseconden zijn uitgedrukt.

Een kale async def doet op zichzelf niets. Het aanroepen van heartbeat(500) voert het lichaam niet uit; het retourneert een coroutine-object dat asyncio moet plannen. De eenvoudigste manier om er een te plannen is asyncio.run()

asyncio.run(heartbeat(500))

asyncio.run() start de event loop, plant de coroutine die hij kreeg als startpunt op het hoogste niveau, stuurt de lus aan totdat die coroutine terugkeert en breekt de lus dan af. Voor een enkele coroutine is dat het hele programma. Voor meerdere coroutines grijpt de applicatie naar tasks.

8.2.2. Tasks

Een task is de wrapper van asyncio rond een coroutine die zegt: plan deze gelijktijdig met de huidige en laat mij doorgaan. asyncio.create_task() maakt er een en retourneert een Task-object dat het geplande werk vertegenwoordigt:

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

De coroutine staat nu op het schema van de lus; de aanroeper heeft er niet op gewacht. De geretourneerde Task is de handle die de aanroeper daarna gebruikt om met dat draaiende werk te interageren.

Zodra de applicatie de handle heeft, kan hij er drie dingen mee doen:

  • Wachten tot de task klaar is. Een Task is zelf awaitbaar. result = await task schort de huidige coroutine op totdat de coroutine van task terugkeert, en hervat dan met wat die coroutine ook retourneerde (of werpt opnieuw wat hij ook wierp).

  • De task annuleren. task.cancel() plant dat asyncio.CancelledError wordt geworpen binnen de coroutine van de task bij zijn volgende await, waardoor hij de kans krijgt om opschooncode in een finally-blok uit te voeren. De pagina over time-outs en annulering behandelt de details.

  • Hem later identificeren. asyncio.current_task() retourneert de Task voor de coroutine die momenteel draait. De meeste scripts roepen hem nooit aan; hij komt voor in instrumentatie en in exception-handlers.

Het script hoeft de handle niet elke keer vast te leggen. Wegwerpbare achtergrond-tasks die de applicatie start en laat draaien, kunnen de retourwaarde laten vallen – de lus plant ze nog steeds:

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())

De twee create_task-aanroepen plannen beide heartbeats zonder op een van beide te wachten. De controle keert onmiddellijk terug naar main, die vervolgens een sleep van vijf seconden awaitt. Terwijl het slaapt, boeken de twee heartbeat-tasks vooruitgang; de lus doorloopt steeds de task die klaar is om te draaien. Na vijf seconden keert main terug, breekt de lus alle nog levende tasks af, en asyncio.run() keert terug naar de aanroeper.

Leg de handle vast wanneer de applicatie daadwerkelijk een van de drie bovenstaande operaties nodig heeft. In de praktijk betekent dat bijna altijd, want een applicatie netjes afsluiten betekent het annuleren van de achtergrond-tasks die hij heeft voortgebracht – de annuleringspagina behandelt het patroon.

8.2.3. De tweeregelige regel

Het minimale asyncio-programma bestaat uit de twee regels waarmee de bovenstaande voorbeelden eindigen:

async def main():
    ...

asyncio.run(main())

Al het overige – de tasks die de applicatie maakt, de primitieven waarmee hij ze coördineert, de streams die hij opent – gebeurt binnen main (en binnen de coroutines die main voortbrengt). Wanneer een script de klassieke while True: csi0.snapshot()-lus van de camera ontgroeit, is het antwoord niet om asyncio.run() op meerdere plaatsen aan te roepen; het is om het nieuwe werk als meer tasks in main te vouwen.