Ein konzeptioneller Überblick über asyncio

Dieser HOWTO-Artikel zielt darauf ab, Ihnen beim Aufbau eines soliden mentalen Modells zu helfen, wie asyncio grundlegend funktioniert, und Ihnen zu helfen, das Wie und Warum hinter den empfohlenen Mustern zu verstehen.

Sie sind vielleicht neugierig auf einige wichtige asyncio-Konzepte. Am Ende dieses Artikels werden Sie in der Lage sein, diese Fragen bequem zu beantworten:

  • Was passiert hinter den Kulissen, wenn ein Objekt erwartet wird?

  • Wie unterscheidet asyncio zwischen einer Aufgabe, die keine CPU-Zeit benötigt (wie eine Netzwerkanfrage oder ein Dateilesevorgang), und einer Aufgabe, die dies tut (wie die Berechnung von n-Fakultät)?

  • Wie schreibt man eine asynchrone Variante einer Operation, wie z. B. ein asynchrones Sleep oder eine Datenbankanfrage.

Siehe auch

Ein konzeptioneller Überblick Teil 1: Das High-Level

In Teil 1 behandeln wir die wichtigsten, übergeordneten Bausteine von asyncio: die Ereignisschleife, Coroutinenfunktionen, Coroutine-Objekte, Tasks und await.

Ereignisschleife

Alles in asyncio geschieht relativ zur Ereignisschleife. Sie ist der Star der Show. Sie ist wie ein Orchesterdirigent. Sie ist hinter den Kulissen und verwaltet Ressourcen. Ein Teil der Macht wird ihr ausdrücklich gewährt, aber viel von ihrer Fähigkeit, Dinge zu erledigen, beruht auf dem Respekt und der Kooperation ihrer fleißigen Bienen.

Technisch gesehen enthält die Ereignisschleife eine Sammlung von auszuführenden Jobs. Einige Jobs werden direkt von Ihnen hinzugefügt, andere indirekt von asyncio. Die Ereignisschleife nimmt einen Job aus ihrem Arbeitsrückstand und ruft ihn auf (oder „gibt ihm die Kontrolle“), ähnlich dem Aufruf einer Funktion, und dann läuft dieser Job. Sobald er pausiert oder abgeschlossen ist, gibt er die Kontrolle an die Ereignisschleife zurück. Die Ereignisschleife wählt dann einen anderen Job aus ihrem Pool aus und ruft ihn auf. Sie können sich die Sammlung von Jobs *ungefähr* wie eine Warteschlange vorstellen: Jobs werden hinzugefügt und dann nacheinander verarbeitet, im Allgemeinen (aber nicht immer) in der Reihenfolge, in der sie hinzugefügt wurden. Dieser Prozess wiederholt sich unendlich, wobei die Ereignisschleife endlos weiterläuft. Wenn keine Jobs mehr zur Ausführung anstehen, ist die Ereignisschleife schlau genug, sich auszuruhen und unnötige CPU-Zyklen zu vermeiden, und wird zurückkehren, wenn mehr Arbeit zu erledigen ist.

Effektive Ausführung hängt davon ab, dass Jobs gut zusammenarbeiten und kooperieren; ein gieriger Job könnte die Kontrolle monopolisieren und die anderen Jobs verhungern lassen, was den gesamten Ereignisschleifenansatz ziemlich nutzlos macht.

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 grundlegende, 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."
    )

Das Aufrufen einer regulären Funktion ruft ihre Logik oder ihren Körper auf

>>> 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, im Gegensatz zu einem einfachen def, macht dies zu einer asynchronen Funktion (oder „Coroutinenfunktion“). Ihr Aufruf erstellt und gibt ein Coroutine-Objekt 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}."
    )

Das Aufrufen der asynchronen Funktion, loudmouth_penguin, führt die print-Anweisung nicht aus; stattdessen erstellt es ein Coroutine-Objekt

>>> loudmouth_penguin(magic_number=3)
<coroutine object loudmouth_penguin at 0x104ed2740>

Die Begriffe „Coroutine-Funktion“ und „Coroutine-Objekt“ werden oft als Coroutine verwechselt. Das kann verwirrend sein! In diesem Artikel bezieht sich Coroutine speziell auf ein Coroutine-Objekt oder genauer gesagt auf eine Instanz von types.CoroutineType (native Coroutine). Beachten Sie, dass Coroutinen auch als Instanzen von collections.abc.Coroutine existieren können – eine Unterscheidung, die für die Typüberprüfung wichtig ist.

Eine Coroutine repräsentiert den Körper oder die Logik der Funktion. Eine Coroutine muss explizit gestartet werden; nur das Erstellen der Coroutine startet sie nicht. Bemerkenswerterweise kann die Coroutine an verschiedenen Punkten im Körper der Funktion pausiert und fortgesetzt werden. Diese Fähigkeit zum Pausieren und Fortsetzen ist das, was asynchrone Verhaltensweisen ermöglicht!

Coroutinen und Coroutinenfunktionen wurden durch die Nutzung der Funktionalität von Generatoren und Generatorfunktionen aufgebaut. Denken Sie daran, dass eine Generatorfunktion eine Funktion ist, die yieldt, 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 Coroutinenfunktion führt das Aufrufen einer Generatorfunktion diese nicht aus. Stattdessen erstellt es ein Generatorobjekt

>>> get_random_number()
<generator object get_random_number at 0x1048671c0>

Sie können mit der integrierten Funktion next() zum nächsten yield eines Generators übergehen. Mit anderen Worten, der Generator läuft, pausiert dann. Zum Beispiel:

>>> generator = get_random_number()
>>> next(generator)
Hi
1
>>> next(generator)
Hello
7

Tasks

Grob gesagt, sind Tasks Coroutinen (nicht Coroutinenfunktionen), die an eine Ereignisschleife gebunden sind. Ein Task unterhält auch eine Liste von Callback-Funktionen, deren Bedeutung in einem Moment klar wird, wenn wir await besprechen. Der empfohlene Weg, Tasks zu erstellen, ist über asyncio.create_task().

Das Erstellen eines Tasks plant ihn automatisch zur Ausführung (indem ein Callback hinzugefügt wird, um ihn in der To-Do-Liste der Ereignisschleife auszuführen, d. h. der Sammlung von Jobs).

Da es nur eine Ereignisschleife gibt (in jedem Thread), kümmert sich asyncio darum, den Task für Sie mit der Ereignisschleife zu verknüpfen. Daher ist es nicht notwendig, die Ereignisschleife anzugeben.

coroutine = loudmouth_penguin(magic_number=5)
# This creates a Task object and schedules its execution via the event loop.
task = asyncio.create_task(coroutine)

Zuvor haben wir die Ereignisschleife manuell erstellt und sie so eingestellt, dass sie ewig läuft. In der Praxis wird empfohlen, asyncio.run() zu verwenden (und es ist üblich, es zu sehen), das sich um die Verwaltung der Ereignisschleife kümmert und sicherstellt, dass die bereitgestellte Coroutine abgeschlossen ist, bevor fortgefahren wird. Zum Beispiel folgen viele asynchrone Programme dieser Einrichtung:

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 der Task selbst nicht zur Ereignisschleife hinzugefügt wird, sondern nur ein Callback zu diesem Task. Das spielt eine Rolle, wenn das von Ihnen erstellte Task-Objekt vor seinem Aufruf durch die Ereignisschleife vom Garbage Collector eingesammelt wird. Betrachten Sie 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())

Da keine Referenz auf das Task-Objekt besteht, das in Zeile 5 erstellt wurde, *könnte* es vom Garbage Collector eingesammelt werden, bevor die Ereignisschleife es aufruft. Spätere Anweisungen in der Coroutine main() geben die Kontrolle an die Ereignisschleife zurück, damit diese andere Jobs aufrufen kann. Wenn die Ereignisschleife schließlich versucht, den Task auszuführen, kann sie fehlschlagen und feststellen, dass das Task-Objekt nicht existiert! Dies kann auch geschehen, selbst wenn eine Coroutine eine Referenz auf einen Task behält, aber abgeschlossen wird, bevor dieser Task beendet ist. Wenn die Coroutine beendet wird, gehen lokale Variablen aus dem Gültigkeitsbereich und können der Garbage Collection unterliegen. In der Praxis arbeiten asyncio und der Garbage Collector von Python hart daran, sicherzustellen, 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 Arten verwendet wird:

await task
await coroutine

Auf entscheidende Weise hängt das Verhalten von await vom Typ des erwarteten Objekts ab.

Das Warten auf einen Task gibt die Kontrolle vom aktuellen Task oder der Coroutine an die Ereignisschleife ab. Im Prozess der Kontrolle abzugeben passieren einige wichtige Dinge. Wir werden das folgende Codebeispiel zur Veranschaulichung verwenden:

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.
    ...

In diesem Beispiel stellen wir uns vor, die Ereignisschleife hat die Kontrolle an den Anfang der Coroutine plant_a_tree() übergeben. Wie oben gesehen, erstellt die Coroutine einen Task und wartet dann darauf. Die Anweisung await dig_the_hole_task fügt einen Callback (der plant_a_tree() fortsetzen wird) zur Callback-Liste des Objekts dig_the_hole_task hinzu. Und dann gibt die Anweisung die Kontrolle an die Ereignisschleife ab. Einige Zeit später gibt die Ereignisschleife die Kontrolle an dig_the_hole_task weiter, und der Task erledigt, was er tun muss. Sobald der Task abgeschlossen ist, fügt er seine verschiedenen Callbacks zur Ereignisschleife hinzu, in diesem Fall einen Aufruf zur Fortsetzung von plant_a_tree().

Im Allgemeinen wird, wenn der erwartete Task abgeschlossen ist (dig_the_hole_task), der ursprüngliche Task oder die Coroutine (plant_a_tree()) wieder in die To-Do-Liste der Ereignisschleife aufgenommen, um fortgesetzt zu werden.

Dies ist ein grundlegendes, aber zuverlässiges mentales Modell. In der Praxis sind die Übergaben der Kontrolle etwas komplexer, aber nicht viel. In Teil 2 werden wir die Details durchgehen, die dies ermöglichen.

Im Gegensatz zu Tasks gibt das Warten auf eine Coroutine die Kontrolle nicht an die Ereignisschleife zurück! Das Verpacken einer Coroutine zuerst in einen Task und das anschließende Warten darauf würde die Kontrolle abgeben. Das Verhalten von await coroutine ist effektiv dasselbe wie der Aufruf einer regulären, synchronen Python-Funktion. Betrachten Sie 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() erstellt task_b und plant dessen Ausführung über die Ereignisschleife. Dann wird coro_a() wiederholt erwartet. Die Kontrolle wird nie an die Ereignisschleife abgegeben, weshalb wir die Ausgabe aller drei Aufrufe von coro_a() vor der Ausgabe von coro_b() sehen.

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...

Wenn wir await coro_a() zu await asyncio.create_task(coro_a()) ändern, ändert sich das Verhalten. Die Coroutine main() gibt mit dieser Anweisung die Kontrolle an die Ereignisschleife ab. Die Ereignisschleife geht dann ihren Arbeitsrückstand durch und ruft task_b und dann den Task auf, der coro_a() umschließt, bevor die Coroutine main() fortgesetzt wird.

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 durcheinanderbringen! Dieses Beispiel zeigt, wie die alleinige Verwendung von await coroutine unbeabsichtigt Kontrolle von anderen Tasks stehlen und die Ereignisschleife effektiv blockieren könnte. asyncio.run() kann Ihnen helfen, solche Vorkommnisse über das Flag debug=True zu erkennen, das den Debug-Modus aktiviert. Unter anderem protokolliert es alle Coroutinen, die die Ausführung für 100 ms oder länger monopolisieren.

Das Design tauscht bewusst etwas konzeptionelle Klarheit über die Verwendung von await gegen verbesserte Leistung ein. Jedes Mal, wenn ein Task erwartet wird, muss die Kontrolle den gesamten Aufrufstapel zur Ereignisschleife hinaufgereicht werden. Das mag geringfügig klingen, aber in einem großen Programm mit vielen await-Anweisungen und einem tiefen Aufrufstapel kann dieser Overhead zu einer spürbaren Leistungseinbuße führen.

Ein konzeptioneller Überblick Teil 2: Das Innenleben

Teil 2 geht ins Detail auf die Mechanismen ein, die asyncio zur Verwaltung des Kontrollflusses verwendet. Hier passiert die Magie. Nach diesem Abschnitt werden Sie wissen, was await hinter den Kulissen tut und wie Sie Ihre eigenen asynchronen Operatoren erstellen können.

Die inneren Abläufe von Coroutinen

asyncio nutzt vier Komponenten, um die Kontrolle weiterzugeben.

coroutine.send(arg) ist die Methode, die zum Starten oder Fortsetzen einer Coroutine verwendet wird. Wenn die Coroutine pausiert und nun fortgesetzt wird, wird das Argument arg als Rückgabewert der yield-Anweisung gesendet, die sie ursprünglich pausiert hat. Wenn die Coroutine zum ersten Mal verwendet wird (im Gegensatz zur Fortsetzung), 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, wie üblich, pausiert die Ausführung 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 gesprochen ruft await die __await__()-Methode des gegebenen Objekts auf. await tut noch etwas sehr Besonderes: es propagiert (oder „reicht weiter“) alle yields, die es empfängt, nach oben im Aufrufstapel. In diesem Fall zurück zu ... = coroutine.send(None) in Zeile 16.

Die Coroutine wird über den Aufruf coroutine.send(42) in Zeile 21 fortgesetzt. Die Coroutine nimmt dort wieder auf, wo sie yielded (oder pausiert) hat, in Zeile 3, und führt die verbleibenden Anweisungen in ihrem Körper aus. Wenn eine Coroutine abgeschlossen ist, löst sie eine StopIteration-Ausnahme aus, wobei der Rückgabewert im Attribut value angehängt ist.

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 kurz innezuhalten und sicherzustellen, dass Sie die verschiedenen Wege, auf denen der Kontrollfluss und die Werte übergeben wurden, nachvollzogen haben. Viele wichtige Ideen wurden abgedeckt, und es lohnt sich, sicherzustellen, dass Ihr Verständnis fest ist.

Der einzige Weg, aus einer Coroutine zu yielden (oder effektiv die Kontrolle abzugeben), ist das awaiten eines Objekts, das in seiner __await__-Methode yieldet. Das mag Ihnen seltsam vorkommen. Sie denken vielleicht:

1. Was ist mit einem yield direkt innerhalb der Coroutinenfunktion? Die Coroutinenfunktion wird zu einer Async-Generatorfunktion, einer ganz anderen Sache.

2. Was ist mit einem yield from innerhalb der Coroutinenfunktion zu einem (regulären) Generator? Das führt zu einem Fehler: SyntaxError: yield from not allowed in a coroutine. Dies wurde absichtlich aus Gründen der Einfachheit entworfen – nur eine Verwendungsart für Coroutinen mandatiert. Anfangs war auch yield verboten, wurde aber wieder zugelassen, um Async-Generatoren zu ermöglichen. Trotzdem tun yield from und await effektiv dasselbe.

Futures

Ein Future ist ein Objekt, das den Status und das Ergebnis einer Berechnung repräsentieren soll. Der Begriff ist eine Anspielung auf die Idee von etwas, das noch kommt oder noch nicht geschehen ist, und das Objekt ist eine Möglichkeit, dieses Etwas im Auge zu behalten.

Ein Future hat einige wichtige Attribute. Eines ist sein Status, der entweder "pending" (ausstehend), "cancelled" (abgebrochen) oder "done" (erledigt) sein kann. Ein weiteres ist sein Ergebnis, das gesetzt wird, wenn der Status auf "done" wechselt. Im Gegensatz zu einer Coroutine repräsentiert ein Future nicht die tatsächliche auszuführende Berechnung; stattdessen repräsentiert es den Status und das Ergebnis dieser Berechnung, so etwas wie eine Statusleuchte (rot, gelb oder grün) oder eine Anzeige.

asyncio.Task ist eine Unterklasse von asyncio.Future, um diese verschiedenen Fähigkeiten zu erlangen. Der vorherige Abschnitt besagte, dass Tasks eine Liste von Callbacks speichern, was nicht ganz richtig war. Tatsächlich ist es die Klasse Future, die diese Logik implementiert, welche Task erbt.

Futures können auch direkt verwendet werden (nicht über Tasks). Tasks markieren sich selbst als erledigt, wenn ihre Coroutine abgeschlossen ist. Futures sind vielseitiger und werden als erledigt markiert, wenn Sie es sagen. Auf diese Weise sind sie die flexible Schnittstelle für Sie, um Ihre eigenen Bedingungen für das Warten und Fortsetzen zu erstellen.

Ein selbstgemachtes asyncio.sleep

Wir werden ein Beispiel dafür durchgehen, wie Sie ein Future nutzen könnten, um Ihre eigene Variante eines asynchronen Sleep (async_sleep) zu erstellen, die asyncio.sleep() nachahmt.

Dieser Ausschnitt registriert einige Tasks bei der Ereignisschleife und wartet dann auf den Task, der von asyncio.create_task erstellt wurde, welcher die Coroutine async_sleep(3) umschließt. Wir möchten, dass dieser Task erst nach Ablauf von drei Sekunden abgeschlossen wird, ohne jedoch 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 ein Future, um die benutzerdefinierte Kontrolle darüber zu ermöglichen, wann dieser Task als erledigt markiert wird. Wenn future.set_result() (die Methode, die für die Markierung dieses Futures als erledigt verantwortlich ist) niemals aufgerufen wird, dann wird dieser Task nie abgeschlossen. Wir haben auch die Hilfe eines anderen Tasks in Anspruch genommen, der, wie wir gleich sehen werden, überwacht, wie viel Zeit verstrichen 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 eher nacktes YieldToEventLoop()-Objekt, um aus seiner __await__-Methode zu yielden, wodurch die Kontrolle an die Ereignisschleife abgegeben wird. Dies ist effektiv dasselbe wie der Aufruf von asyncio.sleep(0), aber dieser Ansatz bietet mehr Klarheit, ganz zu schweigen davon, dass es ein wenig schummeln ist, asyncio.sleep zu verwenden, wenn man zeigt, wie man es implementiert!

Wie üblich durchläuft die Ereignisschleife ihre Tasks, gibt ihnen Kontrolle und erhält die Kontrolle zurück, wenn sie pausieren oder abgeschlossen sind. Der watcher_task, der die Coroutine _sleep_watcher(...) ausführt, wird einmal pro vollem Zyklus der Ereignisschleife aufgerufen. Bei jeder Fortsetzung prüft er die Zeit, und wenn noch nicht genug Zeit verstrichen ist, pausiert er erneut und gibt die Kontrolle an die Ereignisschleife zurück. Sobald genug Zeit verstrichen ist, markiert _sleep_watcher(...) das Future als erledigt und schließt sich durch Beendigung seiner unendlichen while-Schleife ab. Da dieser Hilfstask nur einmal pro Zyklus der Ereignisschleife aufgerufen wird, haben Sie Recht, wenn Sie bemerken, dass dieses asynchrone Sleep *mindestens* drei Sekunden schläft, anstatt genau drei Sekunden. Beachten Sie, dass dies 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 ist die vollständige Ausgabe des 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.

Sie mögen das Gefühl haben, dass diese Implementierung des asynchronen Sleeps unnötig kompliziert war. Und, nun ja, das war sie auch. Das Beispiel sollte die Vielseitigkeit von Futures mit einem einfachen Beispiel aufzeigen, das für komplexere Bedürfnisse nachgeahmt werden könnte. Zur Referenz könnten Sie es auch ohne Futures implementieren, wie folgt:

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 war's für jetzt. Hoffentlich sind Sie bereit, sich zuversichtlicher in die Async-Programmierung zu stürzen oder sich fortgeschrittene Themen im Rest der Dokumentation anzusehen.