async/await in Python: Nebenläufigkeit leicht gemacht
Seite 2: Don’t call us, we'll call you
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).
Nicht-blockierende I/O mit async await
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.
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.
Coroutines und Awaitable Objects
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.
Nebenläufige Abläufe mit Tasks erzeugen
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
.
Ü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