Asynchrones Programmieren: async minus await

Viele Programmiersprachen bieten das Keyword await. Dieses Tutorial zeigt anhand eines Python-Beispiels, was bei seinem Einsatz geschieht.

In Pocket speichern vorlesen Druckansicht 75 Kommentare lesen

(Bild: chana/Shutterstock.com)

Lesezeit: 14 Min.
Von
  • Clemens Sielaff
Inhaltsverzeichnis

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.

Young Professionals schreiben für Young Professionals

Dieser Beitrag ist Teil einer Artikelserie, zu der die Heise-Redaktion junge Entwickler:innen einlädt – um über aktuelle Trends, Entwicklungen und persönliche Erfahrungen zu informieren. Bist du selbst ein "Young Professional" und willst einen (ersten) Artikel schreiben? Schicke deinen Vorschlag gern an die Redaktion: developer@heise.de. Wir stehen dir beim Schreiben zur Seite.

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.

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.

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.