3 Punkte von GN⁺ 2024-05-09 | 1 Kommentare | Auf WhatsApp teilen
  • asyncio in Python ist ein Werkzeug für I/O-bound Programme, und der Artikel baut es als einfache Implementierung auf Basis von Generatoren nach, um die Funktionsweise zu zeigen
  • Der Kern ist eine Event Loop mit mehreren Aufgaben, die Tasks ausführt, an yield die Kontrolle zurückerhält und dann zum nächsten Task weitergeht
  • sleep lässt sich als Subgenerator umsetzen, der bis zum Ablauf einer Zeitspanne immer wieder yield ausführt; yield from sorgt dafür, dass der ursprüngliche Task danach weiterläuft
  • Mit async/await übergibt Task.__await__() die Kontrolle an die Event Loop, bis die Aufgabe abgeschlossen ist, und create_task sowie run bilden eine Form, die der echten asyncio-API ähnelt
  • Ersetzt man benutzerdefinierte jacobio-Aufrufe durch asyncio, entsteht Code mit dem echten Paket, auch wenn asyncio in der Praxis deutlich komplexer ist und intern teils anders funktioniert

Die Grundstruktur von asyncio mit Generatoren verstehen

  • asyncio wird in Python zur Verarbeitung von I/O-bound Programmen verwendet, und der Artikel rekonstruiert es in vereinfachter Form auf Generatorbasis
  • Wie range in Python 3+ erzeugen Generatoren benötigte Werte einzeln, statt die gesamte Sequenz im Speicher abzulegen
    • Würde man range(100_000_000) wie eine Liste erzeugen, müssten 100 Millionen Elemente gespeichert werden, was speicherineffizient ist
    • Generatoren erzeugen Werte bei Bedarf und halten nicht die gesamte Sequenz vor
  • Generatorfunktionen werden wie normale Funktionen definiert, verwenden aber yield
    • Beim Funktionsaufruf wird der Rumpf nicht sofort ausgeführt, sondern ein Generatorobjekt zurückgegeben
    • Mit next(iterator) wird bis zum nächsten yield ausgeführt
    • Gibt es kein weiteres yield, wird die Ausnahme StopIteration ausgelöst
  • yield from erlaubt es einem Generator, einen Subgenerator oder ein Iterable aufzurufen und so eine Kette von Generatoren zu bilden
  • Die wichtige Eigenschaft dabei ist, dass die Funktionsausführung angehalten und später bei erhaltenem Zustand wieder aufgenommen werden kann

Die Event Loop als Liste vereinfacht

  • Die Event Loop ist der Kern von asyncio, der laufende Aufgaben ausführt und verwaltet
  • Die echte asyncio-Event-Loop ist in C geschrieben, im einfachen Modell kann man sie aber als Liste der aktuellen Aufgaben betrachten
  • Im ersten Beispiel werden Aufgaben als Generatorobjekte behandelt
    • Der Verwalter der Event Loop durchläuft die Task-Liste
    • Für jeden Task wird next(task) aufgerufen
    • Wenn ein Task wie bei einer I/O-bound Aufgabe warten muss, hält er mit yield an und gibt die Kontrolle an die Event Loop zurück
  • In einem Beispiel, in dem task1() und task2() jeweils etwas ausgeben und dann yield ausführen, erscheinen die Ausgaben abwechselnd
    • Beide Funktionen enthalten eine while True-Schleife, daher läuft die Ausführung fortlaufend weiter
    • Die Ausgabe wiederholt sich als Task 1, Task 2, Task 1, Task 2

sleep mit yield from bauen

  • sleep(seconds) wird als Generator implementiert, der die Startzeit speichert und bis zum Ablauf der angegebenen Zeit fortlaufend yield ausführt
  • Die Task-Funktion delegiert die Ausführung an einen sleep-Subgenerator wie yield from sleep(1) oder yield from sleep(5)
    • Solange sleep weiter yield ausführt, bleibt der Task angehalten
    • Ist genug Zeit vergangen, endet die while-Schleife in sleep
    • Da kein weiteres yield mehr folgt, tritt StopIteration auf, und yield from setzt in der Task-Funktion mit der nächsten Zeile fort
  • Im Beispiel gibt task1 jede Sekunde etwas aus, task2 alle fünf Sekunden
    • Nach Task 1, Task 2 erscheint Task 1 mehrfach, bevor wieder Task 2 folgt

Von yield zu await

  • Damit await verwendet werden kann, muss das Zielobjekt eine __await__-Methode besitzen oder eine Coroutine sein
  • In asyncio arbeitet man üblicherweise mit Task-Objekten über Funktionen wie asyncio.create_task
    • Ein Task-Objekt erbt vom Future-Objekt von asyncio
    • Das Future-Objekt besitzt eine __await__-Methode
  • Wird eine Funktion mit dem Schlüsselwort async aufgerufen, entsteht ein Coroutine-Objekt
    • Eine Coroutine kann wie eine Generatorfunktion angehalten und später fortgesetzt werden
  • await lässt sich als eine Art yield from mit zusätzlichen Prüfregeln verstehen
    • await object führt dazu, dass in __await__ der Objektinstanz yield verwendet wird oder auf eine andere Coroutine gewartet wird
  • Auch im [Future-Quellcodevon asyncio](https://github.com/python/cpython/blob/main/Lib/asyncio/futures.py) ist zu sehen, dassawaitim Wesentlichenyieldaufruft, solange einFutureoderTask` noch nicht abgeschlossen ist

Eine einfache Implementierung von Task, create_task und run

  • Die benutzerdefinierte Implementierung nutzt statt einer Liste eine Queue als Event Loop
    • So lassen sich das Hinzufügen und Entfernen von Aufgaben in konstanter Zeit verarbeiten
  • Die Klasse Task speichert ein Generatorobjekt und den Abschlusszustand
    • Das Generatorobjekt wird in self.iter gespeichert
    • self.finished startet mit False
    • Löst der Generator StopIteration aus, wird der Task als abgeschlossen markiert
    • done() gibt den Abschlusszustand zurück
  • Task.__await__() führt so lange yield self aus, bis die Aufgabe abgeschlossen ist
    • Dadurch geht die Kontrolle an die Event Loop zurück
  • create_task(generator) verpackt den Generator in einen Task, legt ihn in die Queue der Event Loop und gibt ihn zurück
    • Die Funktion plant also den Task für die Event Loop ein
  • run(main) startet die Event Loop in einer Form, die dem echten asyncio.run() ähnelt
    • Das zunächst übergebene main wird in einen Task verpackt und in die Queue gelegt
    • Solange die Queue nicht leer ist, wird der nächste Task entnommen
    • Mit task.iter.send(None) wird der Task fortgesetzt
    • Tritt StopIteration auf, wird task.finished = True gesetzt
    • Tritt keine Ausnahme auf, wird der Task erneut in die Queue der Event Loop gelegt
  • Dass hier statt next(task.iter) task.iter.send(None) verwendet wird, hängt mit der Arbeitsweise von async/await zusammen; in diesem Fall erfüllt es denselben Zweck

Async-kompatibles sleep und das jacobio-Beispiel

  • Das bisherige sleep war eine Generatorfunktion, doch await lässt sich nicht direkt mit einer Generatorfunktion kombinieren
    • Ein Ziel von await muss ein Objekt mit __await__ oder eine Coroutine-Funktion sein
  • Die eigentliche Warte-Logik wird in den Generator _sleep(seconds) verschoben
    • _sleep führt yield aus, bis eine bestimmte Zeit verstrichen ist
  • async def sleep(seconds) erzeugt einen Task aus _sleep(seconds) und wartet dann mit await darauf
    • await task ruft Task.__await__() auf
    • Ist der Task noch nicht beendet, wird mit yield die Kontrolle an die Event Loop zurückgegeben
  • Die fertige benutzerdefinierte Datei jacobio.py enthält folgende Elemente
    • die Queue der Event Loop
    • _sleep
    • async sleep
    • Task
    • create_task
    • run
  • Im Anwendungsbeispiel wird yield from durch await ersetzt, und Funktionen mit await werden mit async markiert
    • task1 gibt zweimal etwas aus und wartet jeweils auf jacobio.sleep(1)
    • task2 gibt dreimal etwas aus und wartet jeweils auf jacobio.sleep(0)
    • main erzeugt beide Tasks, wartet auf beide und gibt anschließend done aus
  • Die Beispielausgabe lautet in dieser Reihenfolge: Task 1, Task 2, Task 2, Task 2, Task 1, done

Umstellung auf echtes asyncio

  • Ersetzt man im benutzerdefinierten jacobio-Beispiel überall jacobio durch asyncio, erhält man Code mit dem echten asyncio-Paket
  • Die entsprechenden Funktionen sind:
    • jacobio.sleep()asyncio.sleep()
    • jacobio.create_task()asyncio.create_task()
    • jacobio.run()asyncio.run()
  • Echtes asyncio erledigt intern sehr viel mehr
  • Dieser Event-Loop-Manager ist bewusst so einfach wie möglich gehalten; er zeigt die Grundidee von asyncio, weicht aber wegen Umfang und Komplexität des echten Pakets etwas vom tatsächlichen Ablauf im Quellcode ab
  • In echtem asyncio kann man mehrere Tasks statt durch separates Erzeugen und anschließendes Warten auf beide auch mit Funktionen wie asyncio.gather() gemeinsam verarbeiten
  • Verlinkt wird außerdem der verwandte Artikel handling asyncio tasks like a pro

1 Kommentare

 
GN⁺ 2024-05-09
Hacker-News-Kommentare
  • Asyncio kann so ersetzt werden, dass man eine Implementierung mit direkt gebauter Event Loop verwendet
    In Temporal Python werden Workflows als benutzerdefinierte dauerhafte asyncio-Event-Loop ausgedrückt, sodass selbst Dinge wie asyncio.sleep zu dauerhaften Timern werden. Das heißt, Code kann auf einer anderen Maschine fortgesetzt werden, wodurch auch ein Sleep über mehrere Wochen möglich ist
    Die Implementierung wird in diesem Artikel erklärt: https://temporal.io/blog/durable-distributed-asyncio-event-l...
    Das größte Problem von asyncio ist, dass man in Python den asyncio-Thread mit einem synchronen Aufruf blockieren und damit leicht das gesamte System anhalten kann. Python braucht dringend ein statisches Analysewerkzeug, das einen Call Graph erstellt und erkennt, ob innerhalb von async def blockierende Aufrufe direkt oder indirekt aufgerufen werden

    • Vielleicht ist es keine gute Idee, aber statt wie heute alle Aufrufe standardmäßig synchron zu machen, frage ich mich, ob man es nicht umdrehen sollte
      Grundlegende Operationen wie arithmetische Berechnungen oder der Zugriff auf Datenstrukturen könnte man schnell auf eine Allowlist für synchrone Aufrufe setzen, und wenn die Event Loop verdächtig schnell läuft, ließen sich vielleicht auch andere Aufgaben erkennen, die synchron sein sollten
    • Temporal ist wirklich großartig
      Als ich verstanden habe, wie sie die asyncio-Event-Loop implementiert haben, war das ein echter Mindblown-Moment
    • Ich frage mich, warum man keine Threads verwendet
      Ich versuche immer noch zu verstehen, ob Python wirklich eine geeignete Sprache für Concurrency ist. Asyncio wirkte auf mich immer wie gerade noch ausreichend, und aus meiner Sicht ist die asynchrone Implementierung in C# sauberer
    • Sollte im Workflow-Beispiel Purchaser.purchase nicht eigentlich do_purchase heißen?
    • Das statisch zu erkennen dürfte nicht einfach sein
      Selbst ein simples read() kann je nach Deskriptor und dessen Konfiguration blockieren oder auch nicht. Wie will man das per statischer Analyse erkennen?
  • Diese Implementierung scheint beim Sleep ein Busy Waiting zu machen. Das heißt, die Event Loop läuft weiter, auch wenn es gerade keine ausführbaren Tasks gibt
    Ich erinnere mich an eine andere Spielzeug-Implementierung, in der die nächste ausführbare Zeit der Tasks sortiert nachverfolgt wurde, sodass die Event Loop selbst schlafen gelegt wurde, wenn aktuell kein Task ausführbar war. Ich verstehe es so, dass echtes asyncio ebenfalls so arbeitet
    Später wurde das dann so erweitert, dass die Bedingung für die nächste Ausführbarkeit nicht nur eine einfache Wanduhrzeit ist, sondern auch Abhängigkeiten von Dingen wie Sockets haben kann, sodass select mit einem Timeout verwendet werden kann

    • Auch bei asyncio selbst kann man neben der Standardimplementierung benutzerdefinierte Event-Loop-Implementierungen verwenden
      Eine bekannte Implementierung ist uvloop(https://github.com/MagicStack/uvloop); sie verwendet im Wesentlichen libuv für die Implementierung der Loop, und Dinge wie das erwähnte select werden von libuv übernommen
    • Das klingt ziemlich ähnlich wie SimPy [1] / Simpy.io [2], aber SimPy ist etliche Jahre älter als asyncio
      [1] https://simpy.readthedocs.io/en/latest/
      [2] https://gitlab.com/team-simpy/simpy.io
    • Aus einer Perspektive gibt es damit eigentlich kein Problem
      Eine Event Loop muss nicht zwingend in einer Schleife laufen; man kann einfach main starten und gemeinsam mit dessen Ende auch die Loop beenden. Man kann sich vorstellen, dass ein Server gestartet wird, der eine while true-Schleife hat und auf Sockets wartet, und dass bei einer Abbruchbedingung oder einem Interrupt alles endet und damit auch das Programm
      Aus Sicht der Event Loop gibt es dann weder Busy Waiting noch die Notwendigkeit, Sleep oder Sockets anzufassen. Es ist der Unterschied zwischen bis zur Fertigstellung laufen und für immer laufen
      Wenn ich eine Spielzeug-Loop bauen würde, würde ich den ewig laufenden Fall lieber gar nicht erst behandeln
    • Welches Buch eignet sich am besten, um so etwas zu lernen?
  • David Beazleys Vortrag zu asyncio ist hervorragend
    Darauf aufbauend habe ich ein Tool für diskrete Ereignissimulation gebaut. Es ist ziemlich cool, dass man asyncio selbst implementieren und die Systemuhr durch Simulationszeit ersetzen kann

  • Ein wirklich hervorragender Artikel und eine High-Level-Erklärung, die die Teile gut überspringt, bei denen Erstleser gelangweilt sein könnten
    Noch besser wäre es, wenn am Ende weitere Materialien ergänzt würden, die erklären, wie es intern tatsächlich funktioniert

  • Der Artikel wäre noch besser gewesen, wenn er denselben Inhalt ganz ohne yield gezeigt hätte. Ehrlich gesagt ist genau das der Teil, der sich wirklich wie Magie anfühlt
    Eine deutlich tiefere Erklärung zu Python-Koroutinen gibt es hier: https://aosabook.org/en/500L/a-web-crawler-with-asyncio-coro...

  • Wird poll() überhaupt nicht erwähnt? Dann ist das völlig anders als die Art, wie asyncio tatsächlich funktioniert

  • Es ist völlig seltsam, dass Python def kapert und dafür verwendet, Objekte zu erzeugen, die in Wirklichkeit keine Funktionen sind
    Wenigstens hätte man dafür ein anderes Schlüsselwort einführen können

    • Doch, es ist schon eine Funktion, nur eben in eine Funktion mit anderem Rückgabetyp transformiert
      Sie gibt einen Generator oder eine Koroutine zurück, und die Typsignatur kann man hier sehen [1]. Selbst ohne Sprachsyntax-Zuckerguss könnte man in reinem Python mit etwas wie einem Decorator Ähnliches erreichen
      [1] https://docs.python.org/3/library/typing.html#typing.Generat...
      Die Typsignatur erwähne ich allerdings eher aus akademischem Interesse; in echter Typnotation bevorzugt man die einfacheren Iterable und Awaitable
    • Was genau ist das eigentliche Problem? Oder geht es einfach um sprachliche Reinheit?
    • Das ist nun wirklich keine Tragödie
      Es klingt eher so, als würde hier die Semantik der Developer Experience über so etwas wie sprachliche Reinheit gestellt. Ich war nie verwirrt darüber, dass async-Funktionen asynchrone Funktionen sind
    • Das größere Problem von Python ist, dass man für Funktionen überhaupt def schreiben muss und damit keine Inline-Funktionen wie JS-Arrow-Functions oder C++-Lambdas erzeugen kann
      Das ist keine Frage sprachlicher Reinheit, sondern einfach unpraktisch
    • Geht es um Decorators? Es ist im Python-Kontext ziemlich verwirrend, worauf genau du hinauswillst