async/await in Python: Nebenläufigkeit leicht gemacht

Seite 2: Don’t call us, we'll call you

Inhaltsverzeichnis

Um ein Blockieren bei Ressourcen-Zugriffen zu vermeiden, kann man konzeptuell entsprechend des Hollywood Principle "Don’t call us, we'll call you" eine Rückruf-Funktion angeben (Callback).

def on_opened(f):
	read_line_noblock(f, on_line_read)

def on_line_read(f, line):
	pass


open_nonblocking('file.txt', 'r', on_opened)

Listing 5: Nicht-blockierendes Öffnen einer Datei via Callback anhand eines Pseudo-Beispiels

In diesem Beispiel erhält die Pseudo-Funktion open_nonblocking die Anweisung, die Datei file.txt lesend zu öffnen und, nachdem die Datei erfolgreich geöffnet wurde, den Callback on_opened auszuführen.

Der Aufruf an open_nonblocking blockiert nicht, und open_nonblocking kehrt sofort zurück. Während im Hintergrund das Betriebssystem die Datei öffnet, läuft das Programm weiter. Sobald das Ereignis eintritt, dass die Datei geöffnet wurde, erfolgt das Ausführen des Callbacks on_opened. Der genaue Zeitpunkt, zu dem der Callback-Aufruf geschieht, ist unbekannt.

Die Blockade aus Listing 4 ließ sich eliminieren, indem nicht mehr das Programm das Betriebssystem ruft und auf die Antwort wartet, sondern ein Callback platziert wird. Das Betriebssystem meldet sich dann, sobald das Ergebnis vorliegt.

Allerdings sind auch in diesem Fall einige Nachteile auszumachen:

  • Les- und Wartbarkeit sind schlechter als zuvor, denn eigentlich soll das Programm eine Datei öffnen und dann einlesen. Dieser Prozess erstreckt sich nun über mehrere Funktionen. Der intendierte Ablauf lässt sich aus dem Quelltext nicht mehr implizit herauslesen.
  • Das Debugging ist erschwert, da die Ausgangssituation und ihre Variablenwerte zum Zeitpunkt des Aufrufs von open_nonblocking beim Ausführen des Callbacks nicht mehr bekannt sind.
  • Durch Debugging verändert man den zeitlichen Ablauf einzelner asynchroner Funktionen, wodurch das Verhalten anders sein kann als beim normalen Ablauf des Programms.
  • Es ist nicht mehr möglich, Exceptions zusammenhängend abzufangen, sondern lediglich innerhalb des einzelnen Callbacks.
  • Bei Callbacks mit vielen Verschachtelungen spricht man auch von der "Callback Hell" (der Rückruf-Hölle).

Mit async await lässt sich ein nicht-blockierender Dateizugriff umsetzen, der aber nicht die genannten Nachteile mit sich bringt. Die implizierte Reihenfolge des Quelltextes bleibt erhalten.

import asyncio
from aiofile import async_open

async def main():
	async with async_open("./hello.txt", 'r') as afp:
	line: str = await afp.readline()

asyncio.run(main())

Listing 6: Nicht-blockierendes Öffnen einer Datei mit async/await

Das Beispiel im vorangehenden Listing verwendet das async with-Konstrukt. Es verhält sich wie ein herkömmlicheswith – die Coroutine bietet zusätzlich an, das Ausführen an dieser Stelle zu unterbrechen.

Der Thread blockiert jedoch nicht, sondern die Kontrolle geht an die Event-Loop zurück. Sie kann nun andere Coroutines starten oder fortsetzen.

Konzeptuelle Darstellung nicht-blockierender I/O mittels Event-Loop (Abb. 2)

Sobald das Betriebssystem die Datei geöffnet hat, sendet es das Ereignis an den CPython-Prozess. Dort nimmt die Event-Loop das Ereignis an. Sobald eine laufende Coroutine beendet oder mit await eine mögliche Unterbrechung markiert ist, bewirkt die Event-Loop den Wiedereintritt in die Beispiel-Routine.

Das Listing 6 zeigt vor allem die Verwendung von async await. Der eigentliche Vorteil ergibt sich jedoch erst in einer echten Applikation wie einem Webserver, der pro Sekunde Tausende von Anfragen verarbeitet.

Der Aufruf einer Coroutine löst nicht deren Ausführung aus, sondern das Erzeugen eines Awaitable Object, das sich anschließend mit await verwenden lässt. Erst beim Verwenden erfolgt der Aufruf der Coroutine. Ein Awaitable Object lässt sich nur einmal mit await verwenden. Außerhalb einer Event-Loop ist es nicht verwendbar.

import asyncio


async def say_hello():
    print("Greetings from say hello!")


async def main():
    await say_hello_aw
    # await say_hello_aw => can not reuse
    local_awaitable = say_hello()
    await say_hello()


say_hello_aw = say_hello()

asyncio.run(main())

Listing 7: Wie oft wird “say hello” ausgeführt?

In Zeile 15 des Listings erzeugt der Aufruf von say_hello ein Awaitable Object. Es zeichnet sich durch eine "magische" __await__-Methode aus. In Zeile 9 wird das Awaitable Object innerhalb der Event-Loop mit await verwendet. Anschließend erfolgt das erstmalige Ausführen von say_hello.

Zeile 11 zeigt das Erstellen eines weiteren Awaitable Object und dessen Binden an eine lokale Variable. Diese wird jedoch nicht mit await eingesetzt und say_hello nicht durch das Awaitable Object ausgeführt. Beim Verlassen von main() gibt CPython daher am Terminal eine Warnung aus.

In Zeile 12 folgt das dritte Awaitable Object, das nun direkt mit await verwendet wird und anschließend das zweite Ausführen von say_hello auslöst.

Mit Tasks kann ein Programm in einen neuen nebenläufigen (concurrent) Ablaufzweig ausscheren. Die Funktion asyncio.create_task erzeugt Tasks. Als Argument übergibt man ein noch nicht verwendetes Awaitable Object.

Erzeugen eines nebenläufigen Ablaufs mit createTask (Abb. 3)

Übergibt man den Aufruf einer Coroutine an create_task, führt die Event-Loop die Coroutine so bald wie möglich nebenläufig aus – nicht erst beim Verwenden mit await. Es ist dabei unbekannt und unerheblich, ob das Ausführen tatsächlich parallel oder verzahnt erfolgt. Es handelt sich lediglich um ein Implementierungsdetail der Event-Loop. Wartet man mit await auf den Ablaufzweig, so wird der aktuelle Ablauf mit dem anderen Zweig synchronisiert: Dabei unterbricht der aktuelle Ablauf, bis der erwartete Task beendet ist. Konzeptuell ist das vergleichbar mit dem Thread.join(), technisch kommt es aber nicht zum Blockieren. Die Event-Loop stellt automatisch die erforderliche Synchronisierung her.

Mithilfe von Tasks lassen sich auch Werte aus nebenläufigen Vorgängen zurückgeben. Das ist eine Aufgabe, die mit Threads bereits einigen Aufwand erfordern würde, einen Lock, eine while-Schleife oder ein notifyAll().

t1 = asyncio.create_task(...)
i:int = await t1

Das folgende Listing macht die Unterschiede beim Verwenden von Tasks im Gegensatz zum direkten Aufrufen der Koroutinen deutlich.


import asyncio
import time


async def provide_after(delay_sec: float, to_say: str):
    await asyncio.sleep(delay_sec)
    print(to_say + f" said at {time.strftime('%X')}")
    return to_say


async def main():
    print(f"started at {time.strftime('%X')}")
    await provide_after(1, "1st awaitable")
    await provide_after(1, "2nd awaitable")
    print(f"finished at {time.strftime('%X')}")
    await asyncio.sleep(2)  # just to have some gap
    t1 = asyncio.create_task(provide_after(1., "1st task"))
    t2 = asyncio.create_task(provide_after(1., "2nd task"))
    print(f"started at {time.strftime('%X')}")
    await t1
    await t2
    print(f"finished at {time.strftime('%X')}")

asyncio.run(main())

Listing 8: Einsatz von Tasks und Koroutinen im Vergleich

Zeile 14 des Codes wird eine Sekunde nach Zeile 13 ausgeführt. In der Coroutine provide_after erfolgt der Aufruf von asyncio.sleep, der die Kontrolle an die Event-Loop zurückgibt und einen Wiedereintritt nach der übergebenen Zeitspanne herbeiführt. Entsprechend wird Zeile 15 eine Sekunde nach Zeile 14 aufgerufen.

Dagegen kehren die Aufrufe an create_task in den Zeilen 17 und 18 sofort zurück, während nebenläufig die Funktion provide_after zur Ausführung kommt.

#Ausgabe von Listing 8:

started at 10:24:49
1st awaitable said at 10:24:50
2nd awaitable said at 10:24:51
finished at 10:24:51
started at 10:24:53
1st task said at 10:24:54
2nd task said at 10:24:54
finished at 10:24:54