Asynchrones Programmieren: async minus await
Viele Programmiersprachen bieten das Keyword await. Dieses Tutorial zeigt anhand eines Python-Beispiels, was bei seinem Einsatz geschieht.
- Clemens Sielaff
Das Keyword await lässt sich dazu nutzen, asynchrone Funktionen zu schreiben. Dieser Artikel soll dabei helfen, eine Intuition dafür zu entwickeln, was man dem Computer durch await
mitteilt. Als Beispiel dient das Schreiben einer kleinen Async-Library in Python, ohne dabei das await
-Keyword zu verwenden. Anschließend folgt ein Vergleich mit Code, der await
mit dem Python-Standardmodul asyncio nutzt.
Von synchron zu asynchron
Das Beispiel ist denkbar einfach: Alice und Bob möchten beide einen drei Sekunden langen Countdown herunterzählen. Gleichzeitig, und zwar ohne dass der eine vom anderen weiß. Listing 1 und 2 zeigen eine synchrone Implementation.
from time import sleep
def countdown(name: str, n: int):
while n >= 0:
print(name, n)
sleep(1)
n -= 1
countdown("Alice", 3)
countdown("Bob", 3)
Listing 1: Ein einfacher, synchroner Countdown
Alice 3
Alice 2
Alice 1
Alice 0
Bob 3
Bob 2
Bob 1
Bob 0
Listing 2: Output von Listing 1, zwei Countdowns nacheinander
Alice zählt ihre drei Sekunden, danach Bob seine. Was man stattdessen möchte, ist ein gleichzeitiger Fortschritt in beiden Countdowns. Aber da keine Threads zum Einsatz kommen, können die beiden Funktionen nicht parallel ablaufen. Die erste Einsicht beim asynchronen Programmieren ist, dass Warten auch Fortschritt bedeuten kann. Tatsächlich können beliebig viele Funktionen gleichzeitig warten – und das auf einem einzigen Thread.
Sollen sich Alice und Bob mit dem Zählen abwechseln, können sie problemlos gemeinsam warten. Dazu ist ein Scheduler nötig, der in Listing 3 zu sehen ist. Dessen Output zeigt Listing 4. Zuerst ein paar Worte zur Implementierung des Schedulers (Listing 3):
[1] Um den Kontrollfluss zuverlässig zu steuern, darf es nur einen Scheduler geben. Er ist deshalb als Singleton geschrieben.
[2] Die while-Schleife wird von countdown
in den Scheduler verlagert. Dieser kann somit mehrere gleichzeitige Schleifen verzahnen.
[3] Der Scheduler kann nur Funktionen ohne Parameter ausführen, countdown
benötigt allerdings einen Namen und die nächste Zahl. Deshalb wird der eigentliche Aufruf in eine parameterlose Lambda-Funktion verpackt. Wer mehr wissen möchte, kann nach Closures oder Pythons functools.partial
suchen.
from time import sleep
from typing import * # type hints – not necessary but informative
class Scheduler: # [1]
ready: List[Callable] = list() # functions ready to execute
@classmethod
def call_soon(cls, func: Callable):
cls.ready.append(func)
@classmethod
def run(cls):
while cls.ready:
current = cls.ready.pop(0)
current()
def countdown(name: str, n: int):
if n >= 0: # no longer a loop
print(name, n)
sleep(1)
Scheduler.call_soon(lambda: countdown(name, n - 1)) # [2]
Scheduler.call_soon(lambda: countdown("Alice ", 3)) # [3]
Scheduler.call_soon(lambda: countdown("Bob ", 3))
Scheduler.run()
Listing 3: Countdown mit einem einfachen Scheduler
Alice 3
Bob 3
Alice 2
Bob 2
Alice 1
Bob 1
Alice 0
Bob 0
Listing 4: Output von Listing 3, zwei Countdowns zur selben Zeit
Und tatsächlich, Alice und Bob zählen nun wie gefordert abwechselnd. Zusammen brauchen die beiden Countdowns allerdings immer noch sechs statt drei Sekunden. Das Problem findet sich im Aufruf von sleep
. Jedes Mal, wenn Bob oder Alice warten, blockieren sie nicht nur sich selbst, sondern auch den jeweils anderen. Schlimmer noch, auch den gesamten Scheduler. Es reicht also nicht nur, dass countdown
keine eigene Schleife mehr haben darf – die Funktion selbst darf ebenfalls nicht mehr schlafen. Auch das übernimmt der Scheduler (Listing 5).
from time import sleep, time as now
from typing import *
class Scheduler:
ready: List[Callable] = list()
sleeping: List[Tuple[float, Callable]] = list() # sleeping functions
@classmethod
def call_soon(cls, func: Callable):
cls.ready.append(func)
@classmethod
def call_later(cls, delay: float, func: Callable):
start_time = now() + delay
cls.sleeping.append((start_time, func))
cls.sleeping.sort() # sort by start_time
@classmethod
def run(cls):
while cls.ready or cls.sleeping:
if not cls.ready:
start_time, func = cls.sleeping.pop(0) # get next
delta = start_time - now()
if delta > 0:
sleep(delta) # sleep until next wakes up
cls.call_soon(func)
while cls.ready:
current = cls.ready.pop(0)
current()
def countdown(name: str, n: int):
if n >= 0:
print(name, n)
# no sleep
Scheduler.call_later(1, lambda: countdown(name, n - 1))
Scheduler.call_soon(lambda: countdown("Alice ", 3))
Scheduler.call_soon(lambda: countdown("Bob ", 3))
Scheduler.run()
Listing 5: Wait Smarter, Not Harder
Nun zählen Bob und Alice gleichzeitig herunter.
Besseres Async mit Generatoren
Während diese Art des asynchronen Programmierens funktioniert, ist sie deutlich weniger intuitiv als traditionelle, synchrone Programmierung:
- Anstatt einer lokalen while-Schleife muss sich die Funktion selbst aufrufen.
- Um die Argumente einzufangen, ist es nötig, den Aufruf in eine Closure zu integrieren …
- … und diese dann explizit an einen globalen Scheduler zu übergeben.
Aber was wäre, wenn es auch einfacher ginge? Noch soll nicht await
ins Spiel kommen, sondern ein anderes Keyword, das Python anbietet (und weshalb dieses Tutorial nicht beispielsweise in JavaScript geschrieben ist): yield
. Im Vergleich zum Beispiel in Listing 1 lässt sich damit der in Listing 6 gezeigte Code schreiben.
[...]
def countdown(name: str, n: int):
while n >= 0:
print(name, n)
yield 1 # sleep for 1 second
n -= 1
[...]
Listing 6: Countdown als Generator-Funktion
Um zu verstehen, was genau hier vor sich geht, lohnt sich ein Blick auf das Konstrukt hinter yield
.
Was ist ein Generator?
Das yield
-Keyword gibt einen Wert an die aufrufende Funktion zurück, wie ein return
. Anders als return
lässt sich jedoch eine Funktion nach einem yield
weiter ausführen, und zwar beginnend mit dem nächsten Statement. Alle Variablen innerhalb der Funktion behalten dabei ihre Werte bei.
def foo():
i = 1
yield f"one: {i}"
i += 1
yield f"two: {i}"
Listing 7: Eine triviale Generator-Funktion als Beispiel
Der technische Ausdruck für eine Funktion in Python, die ein oder mehrere yield
-Statements enthält, ist ein Generator. In der Fachliteratur sind Generatoren klassifiziert als ein Subtyp von Koroutinen, die wiederum die theoretische Grundlage von asynchroner Programmierung bilden. Anders als asynchrone Funktionen, die nach außen sichtbar mit dem Keyword async
zu kennzeichnen sind, sehen Generatoren wie gewöhnliche Funktionen aus. In der Praxis fällt der Unterschied aber spätestens beim Aufruf des Generators auf. Denn anders als eine Funktion gibt dieser nicht etwa einen Wert zurück, sondern ein Generatorobjekt (vgl. Listing 7):
gen = foo() # <generator object foo at 0x7f…>
Generatoren kommen, wie der Name vermuten lässt, meist zur Generierung von Werten – etwa für eine Iteration – zum Einsatz. Um den Generator den nächsten Wert erzeugen zu lassen, verwendet Python die "magische" Generator.__next__()
-Methode, die für Anwender besser aufrufbar ist als next()
:
print(next(gen)) # "one: 1"
print(next(gen)) # "two: 2"
print(next(gen)) # produces a StopIteration exception
All das übernimmt Python beim Aufruf eines Generators in einer for x in
-Schleife automatisch. x
bekommt dann so lange den zuletzt generierten Wert zugewiesen, bis der Generator eine StopIteration
erzeugt, was die Schleife beendet.
Wie beim await
-Keyword kann man dieselbe Funktion auch selbst und ohne Python-Magie implementieren. Beim Scheduler ist nur ein Anpassen der run
-Methode erforderlich. Wurde zuvor eine Funktion aufgerufen, ist nun ein durch yield
ausgegebener Wert beziehungsweise eine StopIteration
zu handhaben (Listing 8).
[...]
class Scheduler:
[...]
@classmethod
def run(cls):
while cls.ready or cls.sleeping:
[...]
while cls.ready:
current = cls.ready.pop(0)
try:
delay = next(current)
except StopIteration:
continue # discard finished generators
if delay is not None:
cls.call_later(delay, current)
[...]
Scheduler.call_soon(countdown("Alice ", 3))
Scheduler.call_soon(countdown("Bob ", 3))
Scheduler.run()
Listing 8: Scheduler mit Generatoren
Das führt zu deutlich weniger Boilerplate-Code bei gleichem Output. Das Streichen der Lambda-Funktionen, die bislang für das Versehen des ersten Aufrufs von countdown
mit Argumenten nötig waren, ist nun ebenfalls möglich.