So funktioniert Python asyncio: von Grund auf neu aufgebaut
(jacobpadilla.com)- 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
yielddie Kontrolle zurückerhält und dann zum nächsten Task weitergeht sleeplässt sich als Subgenerator umsetzen, der bis zum Ablauf einer Zeitspanne immer wiederyieldausführt;yield fromsorgt dafür, dass der ursprüngliche Task danach weiterläuft- Mit
async/awaitübergibtTask.__await__()die Kontrolle an die Event Loop, bis die Aufgabe abgeschlossen ist, undcreate_tasksowierunbilden eine Form, die der echten asyncio-API ähnelt - Ersetzt man benutzerdefinierte
jacobio-Aufrufe durchasyncio, 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
rangein 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
- Würde man
- 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ächstenyieldausgeführt - Gibt es kein weiteres
yield, wird die AusnahmeStopIterationausgelöst
yield fromerlaubt 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
yieldan und gibt die Kontrolle an die Event Loop zurück
- In einem Beispiel, in dem
task1()undtask2()jeweils etwas ausgeben und dannyieldausfü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
- Beide Funktionen enthalten eine
sleep mit yield from bauen
sleep(seconds)wird als Generator implementiert, der die Startzeit speichert und bis zum Ablauf der angegebenen Zeit fortlaufendyieldausführt- Die Task-Funktion delegiert die Ausführung an einen sleep-Subgenerator wie
yield from sleep(1)oderyield from sleep(5)- Solange
sleepweiteryieldausführt, bleibt der Task angehalten - Ist genug Zeit vergangen, endet die
while-Schleife insleep - Da kein weiteres
yieldmehr folgt, trittStopIterationauf, undyield fromsetzt in der Task-Funktion mit der nächsten Zeile fort
- Solange
- Im Beispiel gibt
task1jede Sekunde etwas aus,task2alle fünf Sekunden- Nach
Task 1,Task 2erscheintTask 1mehrfach, bevor wiederTask 2folgt
- Nach
Von yield zu await
- Damit
awaitverwendet werden kann, muss das Zielobjekt eine__await__-Methode besitzen oder eine Coroutine sein - In asyncio arbeitet man üblicherweise mit
Task-Objekten über Funktionen wieasyncio.create_task- Ein
Task-Objekt erbt vomFuture-Objekt von asyncio - Das
Future-Objekt besitzt eine__await__-Methode
- Ein
- Wird eine Funktion mit dem Schlüsselwort
asyncaufgerufen, entsteht ein Coroutine-Objekt- Eine Coroutine kann wie eine Generatorfunktion angehalten und später fortgesetzt werden
awaitlässt sich als eine Artyield frommit zusätzlichen Prüfregeln verstehenawait objectführt dazu, dass in__await__der Objektinstanzyieldverwendet 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
Queueals Event Loop- So lassen sich das Hinzufügen und Entfernen von Aufgaben in konstanter Zeit verarbeiten
- Die Klasse
Taskspeichert ein Generatorobjekt und den Abschlusszustand- Das Generatorobjekt wird in
self.itergespeichert self.finishedstartet mitFalse- Löst der Generator
StopIterationaus, wird der Task als abgeschlossen markiert done()gibt den Abschlusszustand zurück
- Das Generatorobjekt wird in
Task.__await__()führt so langeyield selfaus, bis die Aufgabe abgeschlossen ist- Dadurch geht die Kontrolle an die Event Loop zurück
create_task(generator)verpackt den Generator in einenTask, 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 echtenasyncio.run()ähnelt- Das zunächst übergebene
mainwird in einenTaskverpackt 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
StopIterationauf, wirdtask.finished = Truegesetzt - Tritt keine Ausnahme auf, wird der Task erneut in die Queue der Event Loop gelegt
- Das zunächst übergebene
- Dass hier statt
next(task.iter)task.iter.send(None)verwendet wird, hängt mit der Arbeitsweise vonasync/awaitzusammen; in diesem Fall erfüllt es denselben Zweck
Async-kompatibles sleep und das jacobio-Beispiel
- Das bisherige
sleepwar eine Generatorfunktion, dochawaitlässt sich nicht direkt mit einer Generatorfunktion kombinieren- Ein Ziel von
awaitmuss ein Objekt mit__await__oder eine Coroutine-Funktion sein
- Ein Ziel von
- Die eigentliche Warte-Logik wird in den Generator
_sleep(seconds)verschoben_sleepführtyieldaus, bis eine bestimmte Zeit verstrichen ist
async def sleep(seconds)erzeugt einen Task aus_sleep(seconds)und wartet dann mitawaitdaraufawait taskruftTask.__await__()auf- Ist der Task noch nicht beendet, wird mit
yielddie Kontrolle an die Event Loop zurückgegeben
- Die fertige benutzerdefinierte Datei
jacobio.pyenthält folgende Elemente- die Queue der Event Loop
_sleepasync sleepTaskcreate_taskrun
- Im Anwendungsbeispiel wird
yield fromdurchawaitersetzt, und Funktionen mitawaitwerden mitasyncmarkierttask1gibt zweimal etwas aus und wartet jeweils aufjacobio.sleep(1)task2gibt dreimal etwas aus und wartet jeweils aufjacobio.sleep(0)mainerzeugt beide Tasks, wartet auf beide und gibt anschließenddoneaus
- 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 überalljacobiodurchasyncio, 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
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.sleepzu dauerhaften Timern werden. Das heißt, Code kann auf einer anderen Maschine fortgesetzt werden, wodurch auch ein Sleep über mehrere Wochen möglich istDie 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 defblockierende Aufrufe direkt oder indirekt aufgerufen werdenGrundlegende 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
Als ich verstanden habe, wie sie die asyncio-Event-Loop implementiert haben, war das ein echter Mindblown-Moment
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
Purchaser.purchasenicht eigentlichdo_purchaseheißen?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
selectmit einem Timeout verwendet werden kannEine 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
selectwerden von libuv übernommen[1] https://simpy.readthedocs.io/en/latest/
[2] https://gitlab.com/team-simpy/simpy.io
Eine Event Loop muss nicht zwingend in einer Schleife laufen; man kann einfach
mainstarten und gemeinsam mit dessen Ende auch die Loop beenden. Man kann sich vorstellen, dass ein Server gestartet wird, der einewhile true-Schleife hat und auf Sockets wartet, und dass bei einer Abbruchbedingung oder einem Interrupt alles endet und damit auch das ProgrammAus 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
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
yieldgezeigt hätte. Ehrlich gesagt ist genau das der Teil, der sich wirklich wie Magie anfühltEine 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 funktioniertEs ist völlig seltsam, dass Python
defkapert und dafür verwendet, Objekte zu erzeugen, die in Wirklichkeit keine Funktionen sindWenigstens hätte man dafür ein anderes Schlüsselwort einführen können
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
IterableundAwaitableEs 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 sinddefschreiben muss und damit keine Inline-Funktionen wie JS-Arrow-Functions oder C++-Lambdas erzeugen kannDas ist keine Frage sprachlicher Reinheit, sondern einfach unpraktisch