Sprechen async/awaitund asyncioist 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/awaitund 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 defversus async defdient lediglich der Klarheit. Der tatsächliche Unterschied ist returnversus yield. Daraus awaitoder yield fromnehmen 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
barund 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
barund zuqux
- Führen Sie die erste Anweisung rekursiv aus und springen Sie zur nächsten Anweisung
- einmal auf einer
yieldschieben seinen Wert an den anrufenden Stapel , aber Speichern des Stapels und Befehlszeiger
- Stellen Sie nach dem Aufruf
yieldden 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 fromtut beides . Es verschiebt Stapel und Befehlszeiger wrap und läuft cofoo. Beachten Sie, dass wrapbis zum cofoovollständigen Abschluss ausgesetzt bleibt . Immer wenn cofooangehalten oder etwas gesendet wird, cofooist es direkt mit dem aufrufenden Stack verbunden.
1.4. Coroutinen ganz nach unten
yield fromErmö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 rootund coro_bwissen 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 rootkö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 asyncundawait
Die Erklärung hat bisher ausdrücklich die verwendet yieldund yield fromVokabular von Generatoren - die zugrunde liegende Funktionalität ist das gleiche. Die neue Python3.5 Syntax asyncund awaitbesteht 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 forund async withwerden benötigt, da Sie die yield from/awaitKette mit den Anweisungen bare forund withAnweisungen 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 awaiteinander, bis schließlich ein Ereignis awaited 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 sleeps, bis eine Bedingung erfüllt ist. Eine reguläre sleepBlockierung 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 awaiteiner 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 awaitSchlü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 sleepdurch awaitunsere 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.senddie Coroutine mit verwendet, bis ein yieldErgebnis erzielt wird .
coroutine = asleep(100)
while True:
print(coroutine.send(None))
time.sleep(0.1)
Dies gibt uns zwei AsyncSleepEreignisse und dann ein, StopIterationwenn die Coroutine fertig ist. Beachten Sie, dass die einzige Verzögerung von time.sleepin der Schleife ist! Jeder AsyncSleepspeichert 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 listermö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 StopIterationabrufen und sie der Coroutine zuweisen. Das Grundprinzip bleibt jedoch dasselbe.
2.4. Genossenschaftliches Warten
Das AsyncSleepEreignis und die runEreignisschleife 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, sleepeignet 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 selectAnruf
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 openeine 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, writeableenthält unsere geöffnete Datei.
3.2. Grundlegendes E / A-Ereignis
Ähnlich wie bei der AsyncSleepAnforderung müssen wir ein Ereignis für E / A definieren. Mit der zugrunde liegenden selectLogik muss sich das Ereignis auf ein lesbares Objekt beziehen - beispielsweise eine openDatei. 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 AsyncSleepspeichern 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 amountgelesen wurde. Darüber hinaus erhalten wir returndas 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 runzuvor 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.selectein 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, AsyncReadund runImplementierungen sind jetzt voll funktionsfähig zu schlafen und / oder zu lesen. Wie für sleepykö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 selectAufruf wird immer für Dateien und beides zurückgegeben openund readkann auf unbestimmte Zeit blockiert werden . Dies blockiert alle Coroutinen einer Ereignisschleife - was schlecht ist. Bibliotheken wie aiofilesThreads 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 AsyncReadkö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.recvanstelle 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 AsyncReadund AsyncRecvsind 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 readas recvfor 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
BaseEventLoopimplementiert ist: github.com/python/cpython/blob/…