8.2. Koroutinen und Tasks

Koroutinen sind die Arbeitseinheit, aus der ein Asyncio-Programm aufgebaut ist; Tasks sind die Art, wie eine Anwendung mehrere Koroutinen nebenläufig ausführt.

8.2.1. Koroutinen

Eine Koroutine ist eine Funktion, die mit async def deklariert wird:

import asyncio

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

Der Rumpf sieht aus wie eine gewöhnliche Funktion, mit einer zusätzlichen Zutat: await. Überall dort, wo die Koroutine auf etwas warten muss — einen Sleep, einen Netzwerk-Lesevorgang, ein gesetztes Ereignis — awaitet sie einen Ausdruck, der weiß, wie die Koroutine zu suspendieren ist, bis das Erwartete bereit ist. Bei jedem await gibt die Koroutine die Kontrolle an Asyncio zurück; Asyncio setzt sie an derselben Stelle fort, sobald die erwartete Operation abgeschlossen ist.

Das Asyncio-Modul liefert zwei Sleeps:

  • asyncio.sleep() — Argument in Sekunden, akzeptiert einen Float.

  • asyncio.sleep_ms() — Argument in Millisekunden, nimmt einen Int. Eine MicroPython-Erweiterung; auf der Kamera meist die richtige Wahl, da die Timing-Stellschrauben in der Firmware millisekundenförmig sind.

Ein bloßes async def tut für sich allein nichts. Der Aufruf von heartbeat(500) führt den Rumpf nicht aus; er gibt ein Koroutinen-Objekt zurück, das Asyncio schedulen muss. Der einfachste Weg, eines zu schedulen, ist asyncio.run():

asyncio.run(heartbeat(500))

asyncio.run() startet die Ereignisschleife, schedult die übergebene Koroutine als Top-Level-Einstiegspunkt, treibt die Schleife an, bis diese Koroutine zurückkehrt, und baut die Schleife dann wieder ab. Für eine einzelne Koroutine ist das das gesamte Programm. Für mehrere Koroutinen greift die Anwendung zu Tasks.

8.2.2. Tasks

Ein Task ist der Wrapper von Asyncio um eine Koroutine, der sagt: schedule dies nebenläufig zur aktuellen und lass mich weitermachen. asyncio.create_task() erstellt einen und gibt ein Task-Objekt zurück, das die geschedulte Arbeit repräsentiert:

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

Die Koroutine steht nun im Zeitplan der Schleife; der Aufrufer hat nicht auf sie gewartet. Der zurückgegebene Task ist das Handle, das der Aufrufer anschließend verwendet, um mit dieser laufenden Arbeit zu interagieren.

Sobald die Anwendung das Handle hat, kann sie drei Dinge damit tun:

  • Auf das Ende des Tasks warten. Ein Task ist selbst awaitable. result = await task suspendiert die aktuelle Koroutine, bis die Koroutine von task zurückkehrt, und setzt dann mit dem fort, was diese Koroutine zurückgegeben hat (oder wirft erneut, was sie geworfen hat).

  • Den Task abbrechen. task.cancel() plant, dass asyncio.CancelledError innerhalb der Koroutine des Tasks bei deren nächstem await ausgelöst wird, und gibt ihr die Möglichkeit, Aufräumcode in einem finally-Block auszuführen. Die Seite über Timeouts und Abbruch behandelt die Details.

  • Ihn später identifizieren. asyncio.current_task() gibt den Task für die aktuell laufende Koroutine zurück. Die meisten Skripte rufen sie nie auf; sie taucht in der Instrumentierung und in Exception-Handlern auf.

Das Skript muss das Handle nicht jedes Mal erfassen. Wegwerf-Hintergrund-Tasks, die die Anwendung startet und laufen lässt, können den Rückgabewert verwerfen — die Schleife schedult sie trotzdem:

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

Die beiden create_task-Aufrufe schedulen beide Heartbeats, ohne auf einen von ihnen zu warten. Die Kontrolle kehrt sofort zu main zurück, das dann einen fünf Sekunden langen Sleep awaitet. Während es schläft, kommen die beiden Heartbeat-Tasks voran; die Schleife wechselt durch jeweils den Task, der bereit zum Laufen ist. Nach fünf Sekunden kehrt main zurück, die Schleife baut alle noch lebenden Tasks ab, und asyncio.run() kehrt zum Aufrufer zurück.

Erfassen Sie das Handle immer dann, wenn die Anwendung tatsächlich eine der drei obigen Operationen benötigt. In der Praxis bedeutet das fast immer, denn ein sauberes Herunterfahren einer Anwendung bedeutet, die von ihr gestarteten Hintergrund-Tasks abzubrechen — die Abbruch-Seite behandelt das Muster.

8.2.3. Die Zwei-Zeilen-Regel

Das minimale Asyncio-Programm sind die zwei Zeilen, mit denen die obigen Beispiele enden:

async def main():
    ...

asyncio.run(main())

Alles andere — die Tasks, die die Anwendung erstellt, die Primitive, mit denen sie sie koordiniert, die Streams, die sie öffnet — geschieht innerhalb von main (und innerhalb der Koroutinen, die main erzeugt). Wenn ein Skript der klassischen while True: csi0.snapshot()-Schleife der Kamera entwächst, lautet die Antwort nicht, asyncio.run() an mehreren Stellen aufzurufen; sie lautet, die neue Arbeit als weitere Tasks in main zu integrieren.