Sprechen async/await
und asyncio
ist nicht dasselbe. Das erste ist ein grundlegendes Konstrukt auf niedriger Ebene (Coroutinen), während das letztere eine Bibliothek ist, die diese Konstrukte verwendet. Umgekehrt gibt es keine einzige endgültige Antwort.
Das Folgende ist eine allgemeine Beschreibung, wie async/await
und asyncio
-ähnlichen Bibliotheken arbeiten. Das heißt, es gibt vielleicht noch andere Tricks (es gibt ...), aber sie spielen keine Rolle, es sei denn, Sie bauen sie selbst. Der Unterschied sollte vernachlässigbar sein, es sei denn, Sie wissen bereits genug, um eine solche Frage nicht stellen zu müssen.
1. Coroutinen versus Subroutinen in einer Nussschale
Genau wie Unterprogramme (Funktionen, Prozeduren, ...) sind Coroutinen (Generatoren, ...) eine Abstraktion von Aufrufstapel und Anweisungszeiger: Es gibt einen Stapel ausführender Codeteile, und jedes befindet sich an einem bestimmten Befehl.
Die Unterscheidung zwischen def
versus async def
dient lediglich der Klarheit. Der tatsächliche Unterschied ist return
versus yield
. Daraus await
oder yield from
nehmen Sie die Differenz von einzelnen Anrufen ganzen Stapel.
1.1. Unterprogramme
Eine Unterroutine repräsentiert eine neue Stapelebene zum Speichern lokaler Variablen und ein einzelnes Durchlaufen ihrer Anweisungen, um ein Ende zu erreichen. Stellen Sie sich ein Unterprogramm wie das folgende vor:
def subfoo(bar):
qux = 3
return qux * bar
Wenn Sie es ausführen, bedeutet das
- Ordnen Sie Stapelspeicherplatz für
bar
und zuqux
- Führen Sie die erste Anweisung rekursiv aus und springen Sie zur nächsten Anweisung
- einmal ein
return
, drücken Sie den Wert auf den anrufenden Stapel
- Löschen Sie den Stapel (1.) und den Anweisungszeiger (2.).
Insbesondere bedeutet 4., dass eine Unterroutine immer im selben Zustand beginnt. Alles, was nur für die Funktion selbst gilt, geht nach Abschluss verloren. Eine Funktion kann nicht wieder aufgenommen werden, auch wenn danach Anweisungen vorliegen return
.
root -\
: \- subfoo --\
:/--<---return --/
|
V
1.2. Coroutinen als persistente Unterprogramme
Eine Coroutine ist wie eine Subroutine, kann jedoch beendet werden, ohne ihren Zustand zu zerstören. Stellen Sie sich eine Coroutine wie diese vor:
def cofoo(bar):
qux = yield bar # yield marks a break point
return qux
Wenn Sie es ausführen, bedeutet das
- Ordnen Sie Stapelspeicherplatz für
bar
und zuqux
- Führen Sie die erste Anweisung rekursiv aus und springen Sie zur nächsten Anweisung
- einmal auf einer
yield
schieben seinen Wert an den anrufenden Stapel , aber Speichern des Stapels und Befehlszeiger
- Stellen Sie nach dem Aufruf
yield
den Stapel- und Anweisungszeiger wieder her und drücken Sie die Argumente anqux
- einmal ein
return
, drücken Sie den Wert auf den anrufenden Stapel
- Löschen Sie den Stapel (1.) und den Anweisungszeiger (2.).
Beachten Sie die Hinzufügung von 2.1 und 2.2 - eine Coroutine kann an vordefinierten Punkten ausgesetzt und wieder aufgenommen werden. Dies ähnelt dem Anhalten einer Unterroutine beim Aufrufen einer anderen Unterroutine. Der Unterschied besteht darin, dass die aktive Coroutine nicht streng an ihren aufrufenden Stapel gebunden ist. Stattdessen ist eine suspendierte Coroutine Teil eines separaten, isolierten Stapels.
root -\
: \- cofoo --\
:/--<+--yield --/
| :
V :
Dies bedeutet, dass suspendierte Coroutinen frei gelagert oder zwischen Stapeln bewegt werden können. Jeder Aufrufstapel, der Zugriff auf eine Coroutine hat, kann diese fortsetzen.
1.3. Durchlaufen des Aufrufstapels
Bisher geht unsere Coroutine nur mit den Call-Stack runter yield
. Eine Unterroutine kann den Aufrufstapel mit und nach unten und oben gehen . Der Vollständigkeit halber benötigen Coroutinen auch einen Mechanismus, um den Aufrufstapel zu erhöhen. Stellen Sie sich eine Coroutine wie diese vor:return
()
def wrap():
yield 'before'
yield from cofoo()
yield 'after'
Wenn Sie es ausführen, bedeutet dies, dass der Stapel und der Anweisungszeiger weiterhin wie eine Unterroutine zugewiesen werden. Wenn es angehalten wird, ist das immer noch wie das Speichern einer Unterroutine.
Allerdings yield from
tut beides . Es verschiebt Stapel und Befehlszeiger wrap
und läuft cofoo
. Beachten Sie, dass wrap
bis zum cofoo
vollständigen Abschluss ausgesetzt bleibt . Immer wenn cofoo
angehalten oder etwas gesendet wird, cofoo
ist es direkt mit dem aufrufenden Stack verbunden.
1.4. Coroutinen ganz nach unten
yield from
Ermöglicht, wie festgelegt, das Verbinden von zwei Bereichen über einen anderen Zwischenbereich. Bei rekursiver Anwendung bedeutet dies, dass die Oberseite des Stapels mit der Unterseite des Stapels verbunden werden kann.
root -\
: \-> coro_a -yield-from-> coro_b --\
:/ <-+------------------------yield ---/
| :
:\ --+-- coro_a.send----------yield ---\
: coro_b <-/
Beachten Sie das root
und coro_b
wissen nicht voneinander. Dies macht Coroutinen viel sauberer als Rückrufe: Coroutinen bauen immer noch auf einer 1: 1-Beziehung auf, wie Subroutinen. Coroutinen setzen ihren gesamten vorhandenen Ausführungsstapel bis zu einem regulären Aufrufpunkt aus und setzen ihn fort.
Insbesondere root
könnte eine beliebige Anzahl von Coroutinen wieder aufgenommen werden. Es kann jedoch niemals mehr als eine gleichzeitig wieder aufnehmen. Coroutinen derselben Wurzel sind gleichzeitig, aber nicht parallel!
1.5. Pythons async
undawait
Die Erklärung hat bisher ausdrücklich die verwendet yield
und yield from
Vokabular von Generatoren - die zugrunde liegende Funktionalität ist das gleiche. Die neue Python3.5 Syntax async
und await
besteht in erster Linie aus Gründen der Übersichtlichkeit.
def foo(): # subroutine?
return None
def foo(): # coroutine?
yield from foofoo() # generator? coroutine?
async def foo(): # coroutine!
await foofoo() # coroutine!
return None
Die Anweisungen async for
und async with
werden benötigt, da Sie die yield from/await
Kette mit den Anweisungen bare for
und with
Anweisungen unterbrechen würden.
2. Anatomie einer einfachen Ereignisschleife
An sich hat eine Coroutine kein Konzept, einer anderen Coroutine die Kontrolle zu geben . Es kann nur dem Aufrufer am unteren Rand eines Coroutine-Stapels die Kontrolle geben. Dieser Anrufer kann dann zu einer anderen Coroutine wechseln und diese ausführen.
Dieser Wurzelknoten mehrerer Coroutinen ist üblicherweise eine Ereignisschleife : Bei Suspendierung liefert eine Coroutine ein Ereignis, bei dem sie fortgesetzt werden soll. Die Ereignisschleife kann wiederum effizient auf das Auftreten dieser Ereignisse warten. Auf diese Weise kann entschieden werden, welche Coroutine als Nächstes ausgeführt werden soll oder wie gewartet werden soll, bevor die Wiederaufnahme fortgesetzt wird.
Ein solches Design impliziert, dass es eine Reihe vordefinierter Ereignisse gibt, die die Schleife versteht. Mehrere Coroutinen await
einander, bis schließlich ein Ereignis await
ed ist. Dieses Ereignis kann durch Steuerung direkt mit der Ereignisschleife kommunizieren yield
.
loop -\
: \-> coroutine --await--> event --\
:/ <-+----------------------- yield --/
| :
| : # loop waits for event to happen
| :
:\ --+-- send(reply) -------- yield --\
: coroutine <--yield-- event <-/
Der Schlüssel ist, dass die Coroutine-Suspendierung die direkte Kommunikation zwischen Ereignisschleife und Ereignissen ermöglicht. Der Zwischen-Coroutine-Stapel erfordert keine Kenntnisse darüber, welche Schleife ihn ausführt oder wie Ereignisse funktionieren.
2.1.1. Ereignisse in der Zeit
Das am einfachsten zu behandelnde Ereignis ist das Erreichen eines Zeitpunkts. Dies ist ebenfalls ein grundlegender Block von Thread-Code: Ein Thread wiederholt sleep
s, bis eine Bedingung erfüllt ist. Eine reguläre sleep
Blockierung der Ausführung von selbst - wir möchten, dass andere Coroutinen nicht blockiert werden. Stattdessen möchten wir der Ereignisschleife mitteilen, wann sie den aktuellen Coroutine-Stapel wieder aufnehmen soll.
2.1.2. Ein Ereignis definieren
Ein Ereignis ist einfach ein Wert, den wir identifizieren können - sei es über eine Aufzählung, einen Typ oder eine andere Identität. Wir können dies mit einer einfachen Klasse definieren, die unsere Zielzeit speichert. Zusätzlich zum Speichern der Ereignisinformationen können wir await
einer Klasse direkt erlauben .
class AsyncSleep:
"""Event to sleep until a point in time"""
def __init__(self, until: float):
self.until = until
# used whenever someone ``await``s an instance of this Event
def __await__(self):
# yield this Event to the loop
yield self
def __repr__(self):
return '%s(until=%.1f)' % (self.__class__.__name__, self.until)
Diese Klasse speichert nur das Ereignis - sie sagt nicht aus, wie sie tatsächlich behandelt werden soll.
Die einzige Besonderheit ist __await__
- es ist das, wonach das await
Schlüsselwort sucht. Praktisch ist es ein Iterator, aber nicht für die reguläre Iterationsmaschinerie verfügbar.
2.2.1. Warten auf ein Ereignis
Wie reagieren Coroutinen nach einem Ereignis darauf? Wir sollten in der Lage sein, das Äquivalent von sleep
durch await
unsere Veranstaltung auszudrücken . Um besser zu sehen, was los ist, warten wir zweimal die halbe Zeit:
import time
async def asleep(duration: float):
"""await that ``duration`` seconds pass"""
await AsyncSleep(time.time() + duration / 2)
await AsyncSleep(time.time() + duration / 2)
Wir können diese Coroutine direkt instanziieren und ausführen. Ähnlich wie bei einem Generator wird coroutine.send
die Coroutine mit verwendet, bis ein yield
Ergebnis erzielt wird .
coroutine = asleep(100)
while True:
print(coroutine.send(None))
time.sleep(0.1)
Dies gibt uns zwei AsyncSleep
Ereignisse und dann ein, StopIteration
wenn die Coroutine fertig ist. Beachten Sie, dass die einzige Verzögerung von time.sleep
in der Schleife ist! Jeder AsyncSleep
speichert nur einen Versatz von der aktuellen Zeit.
2.2.2. Ereignis + Schlaf
Zu diesem Zeitpunkt stehen uns zwei separate Mechanismen zur Verfügung:
AsyncSleep
Ereignisse, die innerhalb einer Coroutine ausgelöst werden können
time.sleep
das kann warten, ohne die Coroutinen zu beeinträchtigen
Bemerkenswerterweise sind diese beiden orthogonal: Keiner beeinflusst oder löst den anderen aus. Infolgedessen können wir unsere eigene Strategie entwickeln sleep
, um die Verzögerung eines zu bewältigen AsyncSleep
.
2.3. Eine naive Ereignisschleife
Wenn wir mehrere Coroutinen haben, kann jeder uns sagen, wann er geweckt werden möchte. Wir können dann warten, bis der erste von ihnen wieder aufgenommen werden möchte, dann auf den nachfolgenden und so weiter. Insbesondere kümmern wir uns an jedem Punkt nur darum, welcher der nächste ist .
Dies ermöglicht eine einfache Planung:
- Sortieren Sie die Coroutinen nach der gewünschten Weckzeit
- Wählen Sie die erste, die aufwachen möchte
- Warten Sie bis zu diesem Zeitpunkt
- Führen Sie diese Coroutine aus
- Wiederholen von 1.
Eine triviale Implementierung erfordert keine fortgeschrittenen Konzepte. A list
ermöglicht das Sortieren von Coroutinen nach Datum. Warten ist eine regelmäßige time.sleep
. Das Ausführen von Coroutinen funktioniert wie zuvor mit coroutine.send
.
def run(*coroutines):
"""Cooperatively run all ``coroutines`` until completion"""
# store wake-up-time and coroutines
waiting = [(0, coroutine) for coroutine in coroutines]
while waiting:
# 2. pick the first coroutine that wants to wake up
until, coroutine = waiting.pop(0)
# 3. wait until this point in time
time.sleep(max(0.0, until - time.time()))
# 4. run this coroutine
try:
command = coroutine.send(None)
except StopIteration:
continue
# 1. sort coroutines by their desired suspension
if isinstance(command, AsyncSleep):
waiting.append((command.until, coroutine))
waiting.sort(key=lambda item: item[0])
Dies hat natürlich viel Raum für Verbesserungen. Wir können einen Heap für die Warteschlange oder eine Versandtabelle für Ereignisse verwenden. Wir könnten auch Rückgabewerte von der StopIteration
abrufen und sie der Coroutine zuweisen. Das Grundprinzip bleibt jedoch dasselbe.
2.4. Genossenschaftliches Warten
Das AsyncSleep
Ereignis und die run
Ereignisschleife sind eine voll funktionsfähige Implementierung von zeitgesteuerten Ereignissen.
async def sleepy(identifier: str = "coroutine", count=5):
for i in range(count):
print(identifier, 'step', i + 1, 'at %.2f' % time.time())
await asleep(0.1)
run(*(sleepy("coroutine %d" % j) for j in range(5)))
Dies schaltet kooperativ zwischen jeder der fünf Coroutinen um und unterbricht jede für 0,1 Sekunden. Obwohl die Ereignisschleife synchron ist, führt sie die Arbeit in 0,5 Sekunden statt in 2,5 Sekunden aus. Jede Coroutine hält den Zustand und handelt unabhängig.
3. E / A-Ereignisschleife
Eine Ereignisschleife, die unterstützt, sleep
eignet sich zum Abrufen . Das Warten auf E / A in einem Dateihandle kann jedoch effizienter durchgeführt werden: Das Betriebssystem implementiert E / A und weiß somit, welche Handles bereit sind. Im Idealfall sollte eine Ereignisschleife ein explizites "Bereit für E / A" -Ereignis unterstützen.
3.1. Der select
Anruf
Python verfügt bereits über eine Schnittstelle zum Abfragen des Betriebssystems nach Lese-E / A-Handles. Beim Aufruf mit Handles zum Lesen oder Schreiben werden die Handles zurückgegeben , die zum Lesen oder Schreiben bereit sind :
readable, writeable, _ = select.select(rlist, wlist, xlist, timeout)
Zum Beispiel können wir open
eine Datei zum Schreiben erstellen und warten, bis sie fertig ist:
write_target = open('/tmp/foo')
readable, writeable, _ = select.select([], [write_target], [])
Sobald select zurückkehrt, writeable
enthält unsere geöffnete Datei.
3.2. Grundlegendes E / A-Ereignis
Ähnlich wie bei der AsyncSleep
Anforderung müssen wir ein Ereignis für E / A definieren. Mit der zugrunde liegenden select
Logik muss sich das Ereignis auf ein lesbares Objekt beziehen - beispielsweise eine open
Datei. Außerdem speichern wir, wie viele Daten gelesen werden sollen.
class AsyncRead:
def __init__(self, file, amount=1):
self.file = file
self.amount = amount
self._buffer = ''
def __await__(self):
while len(self._buffer) < self.amount:
yield self
# we only get here if ``read`` should not block
self._buffer += self.file.read(1)
return self._buffer
def __repr__(self):
return '%s(file=%s, amount=%d, progress=%d)' % (
self.__class__.__name__, self.file, self.amount, len(self._buffer)
)
Wie bei AsyncSleep
speichern wir meist nur die Daten, die für den zugrunde liegenden Systemaufruf erforderlich sind. Dieses Mal kann __await__
es mehrmals fortgesetzt werden - bis unser Wunsch amount
gelesen wurde. Darüber hinaus erhalten wir return
das E / A-Ergebnis, anstatt nur fortzufahren.
3.3. Erweitern einer Ereignisschleife mit Lese-E / A.
Die Basis für unsere Ereignisschleife ist immer noch die run
zuvor definierte. Zuerst müssen wir die Leseanforderungen verfolgen. Dies ist kein sortierter Zeitplan mehr, wir ordnen nur Leseanforderungen Coroutinen zu.
# new
waiting_read = {} # type: Dict[file, coroutine]
Da select.select
ein Timeout-Parameter benötigt wird, können wir ihn anstelle von verwenden time.sleep
.
# old
time.sleep(max(0.0, until - time.time()))
# new
readable, _, _ = select.select(list(reads), [], [])
Dies gibt uns alle lesbaren Dateien - falls vorhanden, führen wir die entsprechende Coroutine aus. Wenn es keine gibt, haben wir lange genug darauf gewartet, dass unsere aktuelle Coroutine ausgeführt wird.
# new - reschedule waiting coroutine, run readable coroutine
if readable:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read[readable[0]]
Schließlich müssen wir tatsächlich auf Leseanfragen warten.
# new
if isinstance(command, AsyncSleep):
...
elif isinstance(command, AsyncRead):
...
3.4. Etwas zusammensetzen
Das Obige war eine kleine Vereinfachung. Wir müssen etwas wechseln, um schlafende Coroutinen nicht zu verhungern, wenn wir immer lesen können. Wir müssen damit umgehen, nichts zu lesen oder zu warten. Das Endergebnis passt jedoch immer noch in 30 LOC.
def run(*coroutines):
"""Cooperatively run all ``coroutines`` until completion"""
waiting_read = {} # type: Dict[file, coroutine]
waiting = [(0, coroutine) for coroutine in coroutines]
while waiting or waiting_read:
# 2. wait until the next coroutine may run or read ...
try:
until, coroutine = waiting.pop(0)
except IndexError:
until, coroutine = float('inf'), None
readable, _, _ = select.select(list(waiting_read), [], [])
else:
readable, _, _ = select.select(list(waiting_read), [], [], max(0.0, until - time.time()))
# ... and select the appropriate one
if readable and time.time() < until:
if until and coroutine:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read.pop(readable[0])
# 3. run this coroutine
try:
command = coroutine.send(None)
except StopIteration:
continue
# 1. sort coroutines by their desired suspension ...
if isinstance(command, AsyncSleep):
waiting.append((command.until, coroutine))
waiting.sort(key=lambda item: item[0])
# ... or register reads
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
3.5. Genossenschaftliche E / A.
Die AsyncSleep
, AsyncRead
und run
Implementierungen sind jetzt voll funktionsfähig zu schlafen und / oder zu lesen. Wie für sleepy
können wir einen Helfer definieren, um das Lesen zu testen:
async def ready(path, amount=1024*32):
print('read', path, 'at', '%d' % time.time())
with open(path, 'rb') as file:
result = return await AsyncRead(file, amount)
print('done', path, 'at', '%d' % time.time())
print('got', len(result), 'B')
run(sleepy('background', 5), ready('/dev/urandom'))
Wenn wir dies ausführen, können wir sehen, dass unsere E / A mit der Warteaufgabe verschachtelt ist:
id background round 1
read /dev/urandom at 1530721148
id background round 2
id background round 3
id background round 4
id background round 5
done /dev/urandom at 1530721148
got 1024 B
4. Nicht blockierende E / A.
Während E / A für Dateien das Konzept vermittelt, ist es für eine Bibliothek wie diese nicht wirklich geeignet asyncio
: Der select
Aufruf wird immer für Dateien und beides zurückgegeben open
und read
kann auf unbestimmte Zeit blockiert werden . Dies blockiert alle Coroutinen einer Ereignisschleife - was schlecht ist. Bibliotheken wie aiofiles
Threads und Synchronisation verwenden, um nicht blockierende E / A und Ereignisse in der Datei zu fälschen.
Sockets ermöglichen jedoch nicht blockierende E / A - und ihre inhärente Latenz macht sie viel kritischer. Bei Verwendung in einer Ereignisschleife kann das Warten auf Daten und das erneute Versuchen abgeschlossen werden, ohne dass etwas blockiert wird.
4.1. Nicht blockierendes E / A-Ereignis
Ähnlich wie bei uns AsyncRead
können wir ein Suspend-and-Read-Ereignis für Sockets definieren. Anstatt eine Datei zu nehmen, nehmen wir einen Socket - der nicht blockierend sein darf. Auch unsere __await__
Verwendungen socket.recv
anstelle von file.read
.
class AsyncRecv:
def __init__(self, connection, amount=1, read_buffer=1024):
assert not connection.getblocking(), 'connection must be non-blocking for async recv'
self.connection = connection
self.amount = amount
self.read_buffer = read_buffer
self._buffer = b''
def __await__(self):
while len(self._buffer) < self.amount:
try:
self._buffer += self.connection.recv(self.read_buffer)
except BlockingIOError:
yield self
return self._buffer
def __repr__(self):
return '%s(file=%s, amount=%d, progress=%d)' % (
self.__class__.__name__, self.connection, self.amount, len(self._buffer)
)
Im Gegensatz zu AsyncRead
, __await__
blockiert nicht-führt wirklich I / O. Wenn Daten verfügbar sind, werden sie immer gelesen. Wenn keine Daten verfügbar sind, werden diese immer angehalten. Das heißt, die Ereignisschleife wird nur blockiert, während wir nützliche Arbeit leisten.
4.2. Entsperren der Ereignisschleife
An der Ereignisschleife ändert sich nicht viel. Das Ereignis, auf das gewartet werden soll, ist immer noch dasselbe wie für Dateien - ein Dateideskriptor, der als bereit markiert ist select
.
# old
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
# new
elif isinstance(command, AsyncRead):
waiting_read[command.file] = coroutine
elif isinstance(command, AsyncRecv):
waiting_read[command.connection] = coroutine
An diesem Punkt sollte es offensichtlich sein, dass AsyncRead
und AsyncRecv
sind die gleiche Art von Ereignis. Wir könnten sie leicht zu einem Ereignis mit einer austauschbaren E / A-Komponente umgestalten . Tatsächlich trennen die Ereignisschleife, Coroutinen und Ereignisse einen Scheduler, einen beliebigen Zwischencode und die tatsächliche E / A sauber voneinander .
4.3. Die hässliche Seite der nicht blockierenden E / A.
Im Prinzip sollten Sie an dieser Stelle die Logik von read
as recv
for wiederholen AsyncRecv
. Dies ist jetzt jedoch viel hässlicher - Sie müssen mit frühen Rückgaben umgehen, wenn Funktionen im Kernel blockieren, aber Ihnen die Kontrolle geben. Zum Beispiel ist das Öffnen einer Verbindung viel länger als das Öffnen einer Datei:
# file
file = open(path, 'rb')
# non-blocking socket
connection = socket.socket()
connection.setblocking(False)
# open without blocking - retry on failure
try:
connection.connect((url, port))
except BlockingIOError:
pass
Kurz gesagt, es bleiben ein paar Dutzend Zeilen für die Ausnahmebehandlung. Die Ereignisse und die Ereignisschleife funktionieren bereits zu diesem Zeitpunkt.
id background round 1
read localhost:25000 at 1530783569
read /dev/urandom at 1530783569
done localhost:25000 at 1530783569 got 32768 B
id background round 2
id background round 3
id background round 4
done /dev/urandom at 1530783569 got 4096 B
id background round 5
Nachtrag
Beispielcode bei github
BaseEventLoop
implementiert ist: github.com/python/cpython/blob/…