8.15. Fallstricke

Genau die Muster, die asyncio angenehm machen – keine Präemption, explizite awaits – bringen einen eigenen Satz an tückischen Konstellationen mit sich. Diese Seite ist der Katalog derjenigen, die häufig genug auftreten, um es wert zu sein, sie zu kennen.

8.15.1. Das await vergessen

Der Aufruf einer async def-Funktion gibt ein Koroutinen-Objekt zurück. Er führt nicht den Rumpf der Funktion aus. Um sie tatsächlich auszuführen, muss die Koroutine awaitet oder in einen Task verpackt werden:

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

Der Fehler ist still – das Koroutinen-Objekt wird erzeugt, verworfen und nie ausgeführt. Die Anwendung läuft weiter, als hätte alles funktioniert. MicroPython protokolliert manchmal eine Warnung, dass eine Koroutine nie awaitet wurde; manchmal aber auch nicht. Prüfe an jeder Aufrufstelle, die wie ein Funktionsaufruf aussieht, auf fehlende awaits.

8.15.2. Enge Schleifen ohne await

Eine Koroutine, die in einer Schleife läuft und nie awaitet, monopolisiert die Ereignisschleife. Kein anderer Task kommt voran, bis die Schleife verlassen wird oder die Kontrolle abgibt:

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

Die Lösung ist ein Yield innerhalb der Schleife – normalerweise await asyncio.sleep_ms(0) –, damit andere bereite Tasks eine Chance zum Laufen bekommen. Rechenintensive Arbeit gehört ebenfalls in diese Form: Eine Bildverarbeitungsschleife, die pro Durchlauf Hunderte von Millisekunden benötigt, sollte mindestens einmal pro Durchlauf die Kontrolle abgeben, damit der Rest des Programms nicht stockt.

8.15.3. Das Verschlucken von CancelledError

Die Seite zum Abbruch hat dies bereits ausführlich behandelt. Es wird hier wiederholt, weil es die häufigste Ursache für „meine Anwendung fährt nicht herunter“ ist: Eine Koroutine fängt asyncio.CancelledError zu Aufräumzwecken ab und vergisst, sie erneut auszulösen. Der Task läuft weiter; der Aufrufer, der den Abbruch angefordert hat, hängt für immer und wartet darauf, dass er fertig wird. Löse die Ausnahme nach dem Aufräumen immer erneut aus, oder verwende einen try/finally-Block anstelle eines expliziten except.

8.15.4. Gemeinsamen Zustand über Awaits hinweg verändern

Kooperatives Scheduling garantiert, dass eine Koroutine die CPU zwischen den Awaits für sich allein hat, aber sobald sie awaitet, kann eine andere Koroutine laufen. Wenn zwei Koroutinen dieselbe Datenstruktur in Schritten verändern, die ein await enthalten, können sich ihre Operationen so verschränken, dass die Struktur beschädigt wird:

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

Für Zustand, der ausschließlich zwischen Awaits innerhalb einer Koroutine verändert wird, ist keine Synchronisation nötig. Für Zustand, der über Awaits hinweg verändert und von mehr als einer Koroutine zugegriffen wird, kapsle den kritischen Abschnitt in einen Lock.

8.15.5. await auf Modulebene

await ist nur innerhalb eines async def-Rumpfs gültig. Es auf Modulebene zu schreiben – außerhalb jeder Koroutine – ist ein Syntaxfehler:

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

Die Lösung besteht darin, die Arbeit in eine Koroutine zu legen und sie vom asyncio.run()-Einstiegspunkt des Programms aus aufzurufen.

8.15.6. Mehrere asyncio.run-Aufrufe

MicroPython hat eine Ereignisschleife. asyncio.run() zweimal hintereinander aufzurufen – einmal für die Einrichtung, einmal für die Hauptarbeit – nutzt weiterhin dieselbe Schleife. Es von innerhalb einer laufenden Koroutine aufzurufen, ist ein Fehler: Die Schleife läuft bereits. Beide Fälle treten am häufigsten auf, wenn ein Skript organisch wächst und der Autor versucht, es durch das Hinzufügen weiterer run()-Aufrufe zu erweitern, anstatt die neue Arbeit in das bestehende main einzugliedern.

8.15.7. Verwendung von Event aus einem Interrupt

asyncio.Event.set() kann nur sicher von innerhalb der Ereignisschleife aufgerufen werden. Ein Aufruf aus einem GPIO-Interrupt-Handler ist eine Gefahr für Datenbeschädigung. Um einen Task aus einem Interrupt aufzuwecken, verwende stattdessen ThreadSafeFlag – die Seite dazu behandelt die Vorgehensweise.

8.15.8. Lange synchrone Aufrufe

Eine Koroutine kann auf asyncios eigene Warte-Primitive awaiten; alles andere, was sie aufruft, läuft synchron und blockiert die Schleife, bis es zurückkehrt. Ein 200 ms blockierendes time.sleep(), ein SD-Karten-Schreibvorgang, der 80 ms zum Leeren braucht, eine große JPEG-Kompression, ein csi.CSI.snapshot()-Aufruf – jeder davon hält die Ereignisschleife für seine gesamte Dauer fest. Die Lösung hängt vom Aufruf ab:

  • Für time.sleep: ersetze es durch await asyncio.sleep oder await asyncio.sleep_ms.

  • Für csi.CSI.snapshot: verwende den asynchronen Snapshot-Wrapper, den die Erfassungsseite erstellt.

  • Für lange Berechnungen (Bildverarbeitung, JPEG-Kodierung): nimm die Kosten in Kauf oder zerlege die Arbeit in Abschnitte, die zwischen den Durchläufen awaiten.

Asyncio kann einen synchronen Aufruf nicht nicht-blockierend machen. Es kann nur andere Koroutinen laufen lassen, während etwas anderes awaitet.