Ein begrifflicher Überblick über asyncio¶
Dieser HOWTO-Artikel möchte dir helfen, ein belastbares Vorstellungsbild davon aufzubauen, wie asyncio im Grunde arbeitet – und damit das Wie und Warum hinter den empfohlenen Mustern zu verstehen.
Vielleicht bist du neugierig auf einige zentrale Begriffe von asyncio. Am Ende dieses Artikels wirst du diese Fragen mühelos beantworten können:
Was passiert hinter den Kulissen, wenn ein Objekt mit await erwartet wird?
Wie unterscheidet
asynciozwischen einer Aufgabe, die keine CPU-Zeit braucht (etwa eine Netzwerkanfrage oder das Lesen einer Datei), und einer, die sie braucht (etwa die Berechnung von n-Fakultät)?Wie man eine asynchrone Variante einer Operation schreibt, etwa ein asynchrones Schlafen oder eine Datenbankanfrage.
Siehe auch
Der Leitfaden, der diesen HOWTO-Artikel angeregt hat, von Alexander Nordin.
Diese ausführliche YouTube-Tutorialreihe zu
asyncio, erstellt von Łukasz Langa, Mitglied des Python-Kernteams.500 Lines or Less: A Web Crawler With asyncio Coroutines von A. Jesse Jiryu Davis und Guido van Rossum.
Ein begrifflicher Überblick, Teil 1: die obere Ebene¶
In Teil 1 behandeln wir die wichtigsten Bausteine von asyncio auf der oberen Ebene: die Ereignisschleife, Coroutine-Funktionen, Coroutine-Objekte, Tasks und await.
Ereignisschleife¶
Alles in asyncio geschieht in Bezug auf die Ereignisschleife. Sie ist der Star der Vorstellung, arbeitet aber am liebsten hinter den Kulissen und verwaltet und koordiniert Ressourcen. Sie ist wie ein Orchesterdirigent. Ein Teil ihrer Macht ist ihr ausdrücklich verliehen, aber ein Großteil ihrer Fähigkeit, Dinge zu bewegen, beruht auf dem Respekt und der Mitarbeit ihrer Musiker.
Technischer gesagt: Die Ereignisschleife enthält eine Sammlung von Aufträgen, die ausgeführt werden sollen. Manche Aufträge fügst du unmittelbar selbst hinzu, manche mittelbar asyncio. Die Ereignisschleife nimmt einen Auftrag aus ihrem Arbeitsvorrat und ruft ihn auf (oder „gibt ihm die Kontrolle“), ähnlich wie beim Aufruf einer Funktion, und dieser Auftrag läuft dann. Sobald er anhält oder fertig ist, gibt er die Kontrolle an die Ereignisschleife zurück. Die Ereignisschleife wählt dann einen anderen Auftrag aus ihrem Vorrat und ruft ihn auf. Du kannst dir die Sammlung von Aufträgen ungefähr als Warteschlange vorstellen: Aufträge werden hinzugefügt und dann einer nach dem anderen abgearbeitet, im Allgemeinen (aber nicht immer) in dieser Reihenfolge. Dieser Vorgang wiederholt sich unbegrenzt, und die Ereignisschleife kreist endlos weiter. Sind keine Aufträge zur Ausführung mehr offen, ist die Ereignisschleife klug genug, zu ruhen und keine CPU-Takte zu verschwenden. Sie kommt zurück, wenn es mehr zu tun gibt – etwa wenn Ein- und Ausgabeoperationen abgeschlossen sind oder Zeitgeber ablaufen.
Eine wirksame Ausführung beruht darauf, dass Aufträge gut teilen und zusammenarbeiten. Ein gieriger Auftrag könnte die Kontrolle an sich reißen und die anderen Aufträge verhungern lassen, was den ganzen Ansatz der Ereignisschleife recht nutzlos machen würde.
import asyncio
# This creates an event loop and indefinitely cycles through
# its collection of jobs.
event_loop = asyncio.new_event_loop()
event_loop.run_forever()
Asynchrone Funktionen und Coroutinen¶
Dies ist eine einfache, langweilige Python-Funktion:
def hello_printer():
print(
"Hi, I am a lowly, simple printer, though I have all I "
"need in life -- \nfresh paper and my dearly beloved octopus "
"partner in crime."
)
Der Aufruf einer gewöhnlichen Funktion führt ihre Logik beziehungsweise ihren Rumpf aus:
>>> hello_printer()
Hi, I am a lowly, simple printer, though I have all I need in life --
fresh paper and my dearly beloved octopus partner in crime.
Das async def anstelle eines einfachen def macht dies zu einer asynchronen Funktion (oder „Coroutine-Funktion“). Ihr Aufruf erzeugt ein Coroutine-Objekt und gibt es zurück.
async def loudmouth_penguin(magic_number: int):
print(
"I am a super special talking penguin. Far cooler than that printer. "
f"By the way, my lucky number is: {magic_number}."
)
Der Aufruf der asynchronen Funktion loudmouth_penguin führt die print-Anweisung nicht aus. Er erzeugt stattdessen ein Coroutine-Objekt:
>>> loudmouth_penguin(magic_number=3)
<coroutine object loudmouth_penguin at 0x104ed2740>
Die Begriffe „Coroutine-Funktion“ und „Coroutine-Objekt“ werden oft zu „Coroutine“ verschmolzen. Das kann verwirren! In diesem Artikel bezeichnet Coroutine ausdrücklich ein Coroutine-Objekt, genauer: eine Instanz von types.CoroutineType (native Coroutine). Beachte, dass Coroutinen auch als Instanzen von collections.abc.Coroutine vorkommen können – ein Unterschied, der bei der Typprüfung eine Rolle spielt.
Eine Coroutine steht für den Rumpf beziehungsweise die Logik der Funktion. Eine Coroutine muss ausdrücklich gestartet werden. Nochmals: Sie bloß zu erzeugen startet sie nicht. Bemerkenswert ist, dass die Coroutine an verschiedenen Stellen innerhalb des Funktionsrumpfs angehalten und fortgesetzt werden kann. Diese Fähigkeit zum Anhalten und Fortsetzen ist es, die asynchrones Verhalten erst möglich macht!
Coroutinen und Coroutine-Funktionen wurden auf der Funktionalität von Generatoren und Generatorfunktionen aufgebaut. Zur Erinnerung: Eine Generatorfunktion ist eine Funktion, die yield-Anweisungen enthält, wie diese hier:
def get_random_number():
# This would be a bad random number generator!
print("Hi")
yield 1
print("Hello")
yield 7
print("Howdy")
yield 4
...
Ähnlich wie bei einer Coroutine-Funktion führt der Aufruf einer Generatorfunktion sie nicht aus. Er erzeugt stattdessen ein Generatorobjekt:
>>> get_random_number()
<generator object get_random_number at 0x1048671c0>
Mit der integrierten Funktion next() kannst du zum nächsten yield eines Generators vorrücken. Anders gesagt: Der Generator läuft und hält dann an. Zum Beispiel:
>>> generator = get_random_number()
>>> next(generator)
Hi
1
>>> next(generator)
Hello
7
Tasks¶
Grob gesagt sind Tasks Coroutinen (keine Coroutine-Funktionen), die an eine Ereignisschleife gebunden sind. Ein Task führt außerdem eine Liste von Rückruffunktionen, deren Bedeutung gleich klar wird, wenn wir await besprechen.
Einen Task zu erzeugen plant ihn automatisch zur Ausführung ein (indem ein Rückruf, der ihn ausführt, in die Aufgabenliste der Ereignisschleife – also ihre Sammlung von Aufträgen – eingetragen wird). Der empfohlene Weg, Tasks zu erzeugen, ist asyncio.create_task().
asyncio verknüpft Tasks für dich automatisch mit der Ereignisschleife. Diese automatische Verknüpfung wurde um der Einfachheit willen absichtlich in asyncio eingebaut. Ohne sie müsstest du das Objekt der Ereignisschleife mitführen und an jede Coroutine-Funktion übergeben, die Tasks erzeugen will – überflüssiger Ballast in deinem Code.
coroutine = loudmouth_penguin(magic_number=5)
# This creates a Task object and schedules its execution via the event loop.
task = asyncio.create_task(coroutine)
Vorhin haben wir die Ereignisschleife von Hand erzeugt und sie auf unbegrenzten Betrieb gesetzt. In der Praxis ist es empfehlenswert (und häufig zu sehen), asyncio.run() zu verwenden. Das kümmert sich um die Verwaltung der Ereignisschleife und stellt sicher, dass die übergebene Coroutine fertig ist, bevor es weitergeht. Viele asynchrone Programme folgen zum Beispiel diesem Aufbau:
import asyncio
async def main():
# Perform all sorts of wacky, wild asynchronous things...
...
if __name__ == "__main__":
asyncio.run(main())
# The program will not reach the following print statement until the
# coroutine main() finishes.
print("coroutine main() is done!")
Es ist wichtig zu wissen, dass nicht der Task selbst in die Ereignisschleife eingetragen wird, sondern nur ein Rückruf auf den Task. Das spielt eine Rolle, wenn das von dir erzeugte Task-Objekt von der Speicherbereinigung eingesammelt wird, bevor die Ereignisschleife es aufruft. Betrachte zum Beispiel dieses Programm:
1async def hello():
2 print("hello!")
3
4async def main():
5 asyncio.create_task(hello())
6 # Other asynchronous instructions which run for a while
7 # and cede control to the event loop...
8 ...
9
10asyncio.run(main())
Weil es keine Referenz auf das in Zeile 5 erzeugte Task-Objekt gibt, kann es von der Speicherbereinigung eingesammelt werden, bevor die Ereignisschleife es aufruft. Spätere Anweisungen in der Coroutine main() geben die Kontrolle an die Ereignisschleife zurück, damit sie andere Aufträge aufrufen kann. Wenn die Ereignisschleife den Task irgendwann ausführen will, kann sie scheitern und feststellen, dass das Task-Objekt nicht existiert! Das kann auch passieren, wenn eine Coroutine zwar eine Referenz auf einen Task hält, aber fertig wird, bevor dieser Task fertig ist. Beim Verlassen der Coroutine verlassen lokale Variablen ihren Gültigkeitsbereich und können der Speicherbereinigung zum Opfer fallen. In der Praxis arbeiten asyncio und die Speicherbereinigung von Python ziemlich hart daran, dass so etwas nicht passiert. Aber das ist kein Grund, leichtsinnig zu sein!
await¶
await ist ein Python-Schlüsselwort, das üblicherweise auf eine von zwei Weisen verwendet wird:
await task
await coroutine
In einem entscheidenden Punkt hängt das Verhalten von await davon ab, welcher Art das erwartete Objekt ist.
Tasks erwarten¶
Einen Task zu erwarten gibt die Kontrolle vom aktuellen Task oder der aktuellen Coroutine an die Ereignisschleife ab. Beim Abgeben der Kontrolle passieren ein paar wichtige Dinge. Wir verwenden das folgende Codebeispiel zur Veranschaulichung:
async def plant_a_tree():
dig_the_hole_task = asyncio.create_task(dig_the_hole())
await dig_the_hole_task
# Other instructions associated with planting a tree.
...
Stell dir in diesem Beispiel vor, die Ereignisschleife hat die Kontrolle an den Anfang der Coroutine plant_a_tree() übergeben. Wie oben zu sehen, erzeugt die Coroutine einen Task und erwartet ihn dann. Die Anweisung await dig_the_hole_task trägt einen Rückruf (der plant_a_tree() fortsetzt) in die Rückrufliste des Objekts dig_the_hole_task ein. Und dann gibt die Anweisung die Kontrolle an die Ereignisschleife ab. Einige Zeit später übergibt die Ereignisschleife die Kontrolle an dig_the_hole_task, und der Task erledigt, was er zu erledigen hat. Sobald der Task fertig ist, trägt er seine verschiedenen Rückrufe in die Ereignisschleife ein – in diesem Fall einen Aufruf, der plant_a_tree() fortsetzt.
Allgemein gesagt wird, sobald der erwartete Task (dig_the_hole_task) fertig ist, der ursprüngliche Task oder die ursprüngliche Coroutine (plant_a_tree()) wieder in die Aufgabenliste der Ereignisschleife eingetragen, um fortgesetzt zu werden.
Das ist ein einfaches, aber verlässliches Vorstellungsbild. In der Praxis sind die Übergaben der Kontrolle etwas komplexer, aber nicht viel. In Teil 2 gehen wir die Einzelheiten durch, die das möglich machen.
Coroutinen erwarten¶
Anders als bei Tasks gibt das Erwarten einer Coroutine die Kontrolle nicht an die Ereignisschleife zurück! Die Coroutine zuerst in einen Task zu hüllen und diesen dann zu erwarten würde die Kontrolle abgeben. Das Verhalten von await coroutine ist praktisch dasselbe wie der Aufruf einer gewöhnlichen, synchronen Python-Funktion. Betrachte dieses Programm:
import asyncio
async def coro_a():
print("I am coro_a(). Hi!")
async def coro_b():
print("I am coro_b(). I sure hope no one hogs the event loop...")
async def main():
task_b = asyncio.create_task(coro_b())
num_repeats = 3
for _ in range(num_repeats):
await coro_a()
await task_b
asyncio.run(main())
Die erste Anweisung in der Coroutine main() erzeugt task_b und plant ihn über die Ereignisschleife zur Ausführung ein. Dann wird coro_a() wiederholt erwartet. Die Kontrolle wird nie an die Ereignisschleife abgegeben. Deshalb sehen wir die Ausgabe aller drei coro_a()-Aufrufe vor der Ausgabe von coro_b():
I am coro_a(). Hi!
I am coro_a(). Hi!
I am coro_a(). Hi!
I am coro_b(). I sure hope no one hogs the event loop...
Ändern wir await coro_a() zu await asyncio.create_task(coro_a()), ändert sich das Verhalten. Die Coroutine main() gibt mit dieser Anweisung die Kontrolle an die Ereignisschleife ab. Die Ereignisschleife arbeitet dann ihren Arbeitsvorrat durch, ruft task_b und danach den Task auf, der coro_a() umhüllt, und setzt erst dann die Coroutine main() fort.
I am coro_b(). I sure hope no one hogs the event loop...
I am coro_a(). Hi!
I am coro_a(). Hi!
I am coro_a(). Hi!
Dieses Verhalten von await coroutine kann viele Leute aufs Glatteis führen! Dieses Beispiel zeigt, wie der ausschließliche Gebrauch von await coroutine unbeabsichtigt die Kontrolle von anderen Tasks abziehen und die Ereignisschleife damit praktisch lahmlegen kann. asyncio.run() kann dir helfen, solche Fälle zu erkennen – über das Flag debug=True, das den Debug-Modus einschaltet. Unter anderem protokolliert er alle Coroutinen, die die Ausführung 100 ms oder länger an sich reißen.
Der Entwurf gibt absichtlich etwas begriffliche Klarheit beim Gebrauch von await für bessere Leistung auf. Jedes Mal, wenn ein Task erwartet wird, muss die Kontrolle den gesamten Aufrufstapel hinauf an die Ereignisschleife übergeben werden. Dann muss die Ereignisschleife ihren inneren Zustand verwalten und ihre Verarbeitungslogik durchlaufen, um den nächsten Auftrag fortzusetzen. Das klingt vielleicht nach wenig, aber in einem großen Programm mit vielen await-Anweisungen kann sich dieser Aufwand zu einer nicht zu vernachlässigenden Leistungsbremse summieren.
Ein begrifflicher Überblick, Teil 2: das Innenleben¶
Teil 2 geht ins Detail, welche Mechanismen asyncio verwendet, um den Kontrollfluss zu lenken. Hier geschieht die Magie. Aus diesem Abschnitt gehst du mit dem Wissen heraus, was await hinter den Kulissen tut und wie du deine eigenen asynchronen Operatoren baust.
Das Innenleben von Coroutinen¶
asyncio nutzt vier Bestandteile von Python, um die Kontrolle weiterzugeben.
coroutine.send(arg) ist die Methode, mit der eine Coroutine gestartet oder fortgesetzt wird. War die Coroutine angehalten und wird jetzt fortgesetzt, wird das Argument arg als Rückgabewert der yield-Anweisung hineingegeben, die sie ursprünglich angehalten hat. Wird die Coroutine zum ersten Mal verwendet (und nicht fortgesetzt), muss arg None sein.
1class Rock:
2 def __await__(self):
3 value_sent_in = yield 7
4 print(f"Rock.__await__ resuming with value: {value_sent_in}.")
5 return value_sent_in
6
7async def main():
8 print("Beginning coroutine main().")
9 rock = Rock()
10 print("Awaiting rock...")
11 value_from_rock = await rock
12 print(f"Coroutine received value: {value_from_rock} from rock.")
13 return 23
14
15coroutine = main()
16intermediate_result = coroutine.send(None)
17print(f"Coroutine paused and returned intermediate value: {intermediate_result}.")
18
19print(f"Resuming coroutine and sending in value: 42.")
20try:
21 coroutine.send(42)
22except StopIteration as e:
23 returned_value = e.value
24print(f"Coroutine main() finished and provided value: {returned_value}.")
yield hält die Ausführung wie üblich an und gibt die Kontrolle an den Aufrufer zurück. Im obigen Beispiel wird das yield in Zeile 3 von ... = await rock in Zeile 11 aufgerufen. Allgemeiner gesagt ruft await die Methode __await__() des gegebenen Objekts auf. await tut außerdem noch etwas sehr Besonderes: Es gibt alle yield-Ereignisse, die es empfängt, die Aufrufkette hinauf weiter. In diesem Fall also zurück an ... = coroutine.send(None) in Zeile 16.
Die Coroutine wird über den Aufruf coroutine.send(42) in Zeile 21 fortgesetzt. Die Coroutine macht dort weiter, wo sie in Zeile 3 mit yield angehalten hat, und führt die restlichen Anweisungen ihres Rumpfs aus. Wenn eine Coroutine fertig ist, löst sie eine StopIteration-Ausnahme aus, an deren Attribut value der Rückgabewert hängt.
Dieser Ausschnitt erzeugt diese Ausgabe:
Beginning coroutine main().
Awaiting rock...
Coroutine paused and returned intermediate value: 7.
Resuming coroutine and sending in value: 42.
Rock.__await__ resuming with value: 42.
Coroutine received value: 42 from rock.
Coroutine main() finished and provided value: 23.
Es lohnt sich, hier einen Moment innezuhalten und sicherzustellen, dass du den verschiedenen Wegen gefolgt bist, auf denen Kontrollfluss und Werte weitergegeben wurden. Es wurden viele wichtige Ideen behandelt, und es lohnt sich, dass dein Verständnis gefestigt ist.
Der einzige Weg, aus einer Coroutine ein yield zu machen (also die Kontrolle wirksam abzugeben), ist, ein Objekt mit await zu erwarten, das in seiner __await__-Methode ein yield ausführt. Das klingt für dich vielleicht sonderbar. Du denkst vielleicht:
1. What about a
yielddirectly within the coroutine function? The coroutine function becomes an async generator function, a different beast entirely.2. What about a yield from within the coroutine function to a (plain) generator? That causes the error:
SyntaxError: yield from not allowed in a coroutine.This was intentionally designed for the sake of simplicity – mandating only one way of using coroutines. Despite that,yield fromandawaiteffectively do the same thing. Initiallyyieldwas barred as well, but was re-accepted to allow for async generators.
Futures¶
Ein Future ist ein Objekt, das den Stand und das Ergebnis einer Berechnung darstellen soll. Der Begriff spielt auf die Vorstellung von etwas an, das noch kommt oder noch nicht geschehen ist, und das Objekt ist ein Weg, dieses Etwas im Auge zu behalten.
Ein Future hat ein paar wichtige Attribute. Eines ist sein Zustand, der entweder „pending“, „cancelled“ oder „done“ sein kann. Ein anderes ist sein Ergebnis, das gesetzt wird, wenn der Zustand auf done wechselt. Anders als eine Coroutine stellt ein Future nicht die eigentliche zu erledigende Berechnung dar. Es stellt vielmehr den Stand und das Ergebnis dieser Berechnung dar, ein wenig wie eine Statusampel (rot, gelb oder grün) oder eine Anzeige.
asyncio.Task ist eine Unterklasse von asyncio.Future, um diese verschiedenen Fähigkeiten zu bekommen. Im vorigen Abschnitt stand, dass Tasks eine Liste von Rückrufen speichern, was nicht ganz genau war. Tatsächlich setzt die Klasse Future diese Logik um, und Task erbt sie.
Futures lassen sich auch unmittelbar verwenden (nicht über Tasks). Tasks kennzeichnen sich selbst als fertig, wenn ihre Coroutine abgeschlossen ist. Futures sind viel vielseitiger und werden als fertig gekennzeichnet, wenn du es sagst. So sind sie die flexible Schnittstelle, mit der du deine eigenen Bedingungen fürs Warten und Fortsetzen festlegen kannst.
Ein selbstgebautes asyncio.sleep¶
Wir gehen ein Beispiel dafür durch, wie du mit einem Future deine eigene Variante eines asynchronen Schlafens (async_sleep) bauen könntest, die asyncio.sleep() nachbildet.
Dieser Ausschnitt meldet ein paar Tasks bei der Ereignisschleife an und erwartet dann den von asyncio.create_task erzeugten Task, der die Coroutine async_sleep(3) umhüllt. Wir wollen, dass dieser Task erst fertig wird, nachdem drei Sekunden vergangen sind, ohne aber andere Tasks am Laufen zu hindern.
async def other_work():
print("I like work. Work work.")
async def main():
# Add a few other tasks to the event loop, so there's something
# to do while asynchronously sleeping.
work_tasks = [
asyncio.create_task(other_work()),
asyncio.create_task(other_work()),
asyncio.create_task(other_work())
]
print(
"Beginning asynchronous sleep at time: "
f"{datetime.datetime.now().strftime("%H:%M:%S")}."
)
await asyncio.create_task(async_sleep(3))
print(
"Done asynchronous sleep at time: "
f"{datetime.datetime.now().strftime("%H:%M:%S")}."
)
# asyncio.gather effectively awaits each task in the collection.
await asyncio.gather(*work_tasks)
Unten verwenden wir einen Future, um eigens zu steuern, wann dieser Task als fertig gekennzeichnet wird. Wird future.set_result() (die Methode, die diesen Future als fertig kennzeichnet) nie aufgerufen, wird dieser Task nie fertig. Wir haben außerdem die Hilfe eines weiteren Tasks in Anspruch genommen – den wir gleich sehen –, der überwacht, wie viel Zeit vergangen ist, und entsprechend future.set_result() aufruft.
async def async_sleep(seconds: float):
future = asyncio.Future()
time_to_wake = time.time() + seconds
# Add the watcher-task to the event loop.
watcher_task = asyncio.create_task(_sleep_watcher(future, time_to_wake))
# Block until the future is marked as done.
await future
Unten verwenden wir ein recht nacktes Objekt YieldToEventLoop(), um aus seiner __await__-Methode ein yield zu machen und damit die Kontrolle an die Ereignisschleife abzugeben. Das ist praktisch dasselbe wie der Aufruf von asyncio.sleep(0), aber dieser Weg ist klarer – und es wäre ja auch etwas geschummelt, asyncio.sleep zu verwenden, wenn man zeigt, wie man es umsetzt!
Wie üblich kreist die Ereignisschleife durch ihre Tasks, gibt ihnen die Kontrolle und bekommt sie zurück, wenn sie anhalten oder fertig sind. Der watcher_task, der die Coroutine _sleep_watcher(...) ausführt, wird einmal je vollem Umlauf der Ereignisschleife aufgerufen. Bei jeder Fortsetzung prüft er die Zeit, und wenn noch nicht genug vergangen ist, hält er wieder an und gibt die Kontrolle an die Ereignisschleife zurück. Sobald genug Zeit vergangen ist, kennzeichnet _sleep_watcher(...) den Future als fertig und beendet sich, indem es seine endlose while-Schleife verlässt. Da dieser Hilfstask nur einmal je Umlauf der Ereignisschleife aufgerufen wird, hättest du recht mit der Feststellung, dass dieses asynchrone Schlafen mindestens drei Sekunden schläft und nicht genau drei Sekunden. Beachte, dass das auch für asyncio.sleep gilt.
class YieldToEventLoop:
def __await__(self):
yield
async def _sleep_watcher(future, time_to_wake):
while True:
if time.time() >= time_to_wake:
# This marks the future as done.
future.set_result(None)
break
else:
await YieldToEventLoop()
Hier die Ausgabe des vollständigen Programms:
$ python custom-async-sleep.py
Beginning asynchronous sleep at time: 14:52:22.
I like work. Work work.
I like work. Work work.
I like work. Work work.
Done asynchronous sleep at time: 14:52:25.
Du findest diese Umsetzung des asynchronen Schlafens vielleicht unnötig verwickelt. Und das war sie auch. Das Beispiel sollte die Vielseitigkeit von Futures an einem einfachen Fall zeigen, der sich für komplexere Bedürfnisse nachbauen lässt. Zum Vergleich: Du könntest es auch ohne Futures umsetzen, etwa so:
async def simpler_async_sleep(seconds):
time_to_wake = time.time() + seconds
while True:
if time.time() >= time_to_wake:
return
else:
await YieldToEventLoop()
Aber das ist erst einmal alles. Hoffentlich bist du nun bereit, zuversichtlicher in die asynchrone Programmierung einzutauchen oder dir fortgeschrittene Themen im übrigen Teil der Dokumentation anzusehen.