Parlarne async/await
e asyncio
non è la stessa cosa. Il primo è un costrutto fondamentale di basso livello (coroutine) mentre il secondo è una libreria che utilizza questi costrutti. Al contrario, non esiste un'unica risposta definitiva.
Quella che segue è una descrizione generale di come funzionano le librerie async/await
e asyncio
-like. Cioè, potrebbero esserci altri trucchi in cima (ci sono ...) ma sono irrilevanti a meno che non li costruisca da solo. La differenza dovrebbe essere trascurabile a meno che tu non sappia già abbastanza da non dover fare una domanda del genere.
1. Coroutine contro subroutine in un guscio di noce
Proprio come le subroutine (funzioni, procedure, ...), le coroutine (generatori, ...) sono un'astrazione dello stack di chiamate e del puntatore di istruzioni: c'è uno stack di pezzi di codice in esecuzione, e ognuno si trova in un'istruzione specifica.
La distinzione di def
versus async def
è solo per chiarezza. La differenza effettiva è return
rispetto a yield
. Da questo, await
o yield from
prendi la differenza dalle chiamate individuali a interi stack.
1.1. Sottoprogrammi
Una subroutine rappresenta un nuovo livello di stack per contenere variabili locali e un singolo attraversamento delle sue istruzioni per raggiungere una fine. Considera una subroutine come questa:
def subfoo(bar):
qux = 3
return qux * bar
Quando lo esegui, significa
- allocare lo spazio dello stack per
bar
equx
- esegue ricorsivamente la prima istruzione e salta all'istruzione successiva
- una volta alla volta
return
, sposta il suo valore nello stack chiamante
- cancella lo stack (1.) e il puntatore dell'istruzione (2.)
In particolare, 4. significa che una subroutine inizia sempre nello stesso stato. Tutto ciò che è esclusivo della funzione stessa viene perso al completamento. Una funzione non può essere ripresa, anche se ci sono istruzioni dopo return
.
root -\
: \- subfoo --\
:/--<---return --/
|
V
1.2. Coroutine come subroutine persistenti
Una coroutine è come una subroutine, ma può uscire senza distruggere il suo stato. Considera una coroutine come questa:
def cofoo(bar):
qux = yield bar # yield marks a break point
return qux
Quando lo esegui, significa
- allocare lo spazio dello stack per
bar
equx
- esegue ricorsivamente la prima istruzione e salta all'istruzione successiva
- una volta alla volta
yield
, sposta il suo valore nello stack chiamante ma memorizza lo stack e il puntatore dell'istruzione
- una volta chiamato
yield
, ripristina lo stack e il puntatore dell'istruzione e invia gli argomenti aqux
- una volta alla volta
return
, sposta il suo valore nello stack chiamante
- cancella lo stack (1.) e il puntatore dell'istruzione (2.)
Notare l'aggiunta di 2.1 e 2.2: una coroutine può essere sospesa e ripresa in punti predefiniti. Questo è simile a come una subroutine viene sospesa durante la chiamata di un'altra subroutine. La differenza è che la coroutine attiva non è strettamente vincolata al suo stack di chiamate. Invece, una coroutine sospesa fa parte di uno stack separato e isolato.
root -\
: \- cofoo --\
:/--<+--yield --/
| :
V :
Ciò significa che le coroutine sospese possono essere conservate o spostate liberamente tra le pile. Qualsiasi stack di chiamate che ha accesso a una coroutine può decidere di ripristinarlo.
1.3. Attraversare lo stack di chiamate
Finora, la nostra coroutine scende nello stack di chiamate solo con yield
. Una subroutine può salire e scendere nello stack di chiamate con return
e ()
. Per completezza, le coroutine necessitano anche di un meccanismo per salire nello stack di chiamate. Considera una coroutine come questa:
def wrap():
yield 'before'
yield from cofoo()
yield 'after'
Quando lo esegui, significa che alloca ancora lo stack e il puntatore dell'istruzione come una subroutine. Quando si sospende, è ancora come memorizzare una subroutine.
Tuttavia, yield from
fa entrambe le cose . Sospende lo stack e il puntatore di istruzioni wrap
e viene eseguito cofoo
. Nota che wrap
rimane sospeso fino al cofoo
termine completo. Ogni volta che cofoo
sospende o qualcosa viene inviato, cofoo
è direttamente connesso allo stack chiamante.
1.4. Coroutine fino in fondo
Come stabilito, yield from
consente di collegare due ambiti attraverso un altro intermedio. Se applicato in modo ricorsivo, ciò significa che la parte superiore dello stack può essere collegata alla parte inferiore dello stack.
root -\
: \-> coro_a -yield-from-> coro_b --\
:/ <-+------------------------yield ---/
| :
:\ --+-- coro_a.send----------yield ---\
: coro_b <-/
Nota che root
e coro_b
non sappiamo l'uno dell'altro. Questo rende le coroutine molto più pulite delle callback: le coroutine sono ancora costruite su una relazione 1: 1 come le subroutine. Le coroutine sospendono e riprendono l'intero stack di esecuzione esistente fino a un normale punto di chiamata.
In particolare, root
potrebbe avere un numero arbitrario di coroutine da riprendere. Tuttavia, non può mai riprenderne più di uno alla volta. Le coroutine della stessa radice sono concorrenti ma non parallele!
1.5. Python async
eawait
La spiegazione ha finora utilizzato esplicitamente il vocabolario yield
e yield from
dei generatori - la funzionalità sottostante è la stessa. La nuova sintassi di Python3.5 async
ed await
esiste principalmente per chiarezza.
def foo(): # subroutine?
return None
def foo(): # coroutine?
yield from foofoo() # generator? coroutine?
async def foo(): # coroutine!
await foofoo() # coroutine!
return None
Le dichiarazioni async for
e async with
sono necessarie perché spezzeresti la yield from/await
catena con le dichiarazioni nude for
e with
.
2. Anatomia di un semplice loop di eventi
Di per sé, una coroutine non ha il concetto di cedere il controllo a un'altra coroutine. Può solo cedere il controllo al chiamante in fondo a uno stack di coroutine. Questo chiamante può quindi passare a un'altra coroutine ed eseguirla.
Questo nodo radice di diverse coroutine è comunemente un loop di eventi : in sospensione, una coroutine produce un evento su cui vuole riprendere. A sua volta, il ciclo di eventi è in grado di attendere in modo efficiente che si verifichino questi eventi. Ciò gli consente di decidere quale coroutine eseguire dopo o come attendere prima di riprendere.
Un tale progetto implica che esiste un insieme di eventi predefiniti che il ciclo comprende. Diverse coroutine a await
vicenda, fino a quando finalmente un evento è await
edito. Questo evento può comunicare direttamente con il loop di eventi tramite yield
il controllo.
loop -\
: \-> coroutine --await--> event --\
:/ <-+----------------------- yield --/
| :
| : # loop waits for event to happen
| :
:\ --+-- send(reply) -------- yield --\
: coroutine <--yield-- event <-/
La chiave è che la sospensione della coroutine consente al ciclo di eventi e agli eventi di comunicare direttamente. Lo stack coroutine intermedio non richiede alcuna conoscenza su quale ciclo lo sta eseguendo, né su come funzionano gli eventi.
2.1.1. Eventi nel tempo
L'evento più semplice da gestire è raggiungere un punto nel tempo. Anche questo è un blocco fondamentale del codice con thread: un thread viene ripetuto sleep
finché una condizione non è vera. Tuttavia, una normale sleep
esecuzione di blocchi da sola - vogliamo che altre coroutine non vengano bloccate. Invece, vogliamo dire al ciclo di eventi quando dovrebbe riprendere lo stack coroutine corrente.
2.1.2. Definizione di un evento
Un evento è semplicemente un valore che possiamo identificare, sia tramite un'enumerazione, un tipo o un'altra identità. Possiamo definirlo con una semplice classe che memorizza il nostro tempo target. Oltre a memorizzare le informazioni sull'evento, possiamo consentire await
direttamente a una classe.
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)
Questa classe memorizza solo l'evento, non dice come gestirlo effettivamente.
L'unica caratteristica speciale è __await__
che è ciò che la await
parola chiave cerca. In pratica, è un iteratore ma non è disponibile per il normale meccanismo di iterazione.
2.2.1. In attesa di un evento
Ora che abbiamo un evento, come reagiscono le coroutine? Dovremmo essere in grado di esprimere l'equivalente di sleep
da await
ing nostro evento. Per vedere meglio cosa sta succedendo, aspettiamo due volte per la metà del tempo:
import time
async def asleep(duration: float):
"""await that ``duration`` seconds pass"""
await AsyncSleep(time.time() + duration / 2)
await AsyncSleep(time.time() + duration / 2)
Possiamo istanziare ed eseguire direttamente questa coroutine. Simile a un generatore, utilizzando coroutine.send
esegue la coroutine fino a quando non si ottiene yield
un risultato.
coroutine = asleep(100)
while True:
print(coroutine.send(None))
time.sleep(0.1)
Questo ci dà due AsyncSleep
eventi e poi uno StopIteration
quando la coroutine è finita. Notare che l'unico ritardo è time.sleep
nel loop! Ciascuno AsyncSleep
memorizza solo un offset dall'ora corrente.
2.2.2. Evento + Sonno
A questo punto, abbiamo due meccanismi separati a nostra disposizione:
AsyncSleep
Eventi che si possono cogliere dall'interno di una coroutine
time.sleep
che può aspettare senza influire sulle coroutine
In particolare, questi due sono ortogonali: nessuno dei due influenza o innesca l'altro. Di conseguenza, possiamo elaborare la nostra strategia per sleep
far fronte al ritardo di un file AsyncSleep
.
2.3. Un ingenuo ciclo di eventi
Se abbiamo più coroutine, ognuna può dirci quando vuole essere svegliata. Possiamo quindi aspettare fino a quando il primo di loro vuole essere ripreso, poi quello dopo e così via. In particolare, in ogni punto ci interessa solo quale è il prossimo .
Ciò consente una pianificazione semplice:
- ordina le coroutine in base all'ora di sveglia desiderata
- scegli il primo che vuole svegliarsi
- attendere fino a questo momento
- eseguire questa coroutine
- ripetere da 1.
Un'implementazione banale non necessita di concetti avanzati. A list
permette di ordinare le coroutine per data. L'attesa è una cosa normale time.sleep
. L'esecuzione di coroutine funziona proprio come prima con 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])
Naturalmente, questo ha ampi margini di miglioramento. Possiamo usare un heap per la coda di attesa o una tabella di invio per gli eventi. Potremmo anche recuperare i valori di ritorno da StopIteration
e assegnarli alla coroutine. Tuttavia, il principio fondamentale rimane lo stesso.
2.4. Attesa cooperativa
L' AsyncSleep
evento e il run
ciclo di eventi sono un'implementazione completamente funzionante di eventi a tempo.
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)))
Questo commuta in modo cooperativo tra ciascuna delle cinque coroutine, sospendendole ciascuna per 0,1 secondi. Anche se il ciclo di eventi è sincrono, esegue comunque il lavoro in 0,5 secondi invece che in 2,5 secondi. Ogni coroutine detiene lo stato e agisce in modo indipendente.
3. Loop di eventi di I / O
Un ciclo di eventi che supporta sleep
è adatto per il polling . Tuttavia, l'attesa dell'I / O su un handle di file può essere eseguita in modo più efficiente: il sistema operativo implementa l'I / O e quindi sa quali handle sono pronti. Idealmente, un ciclo di eventi dovrebbe supportare un evento esplicito "pronto per I / O".
3.1. La select
chiamata
Python ha già un'interfaccia per interrogare il sistema operativo per gli handle di I / O di lettura. Quando viene chiamato con gli handle per leggere o scrivere, restituisce gli handle pronti per leggere o scrivere:
readable, writeable, _ = select.select(rlist, wlist, xlist, timeout)
Ad esempio, possiamo open
un file per la scrittura e attendere che sia pronto:
write_target = open('/tmp/foo')
readable, writeable, _ = select.select([], [write_target], [])
Una volta selezionati i resi, writeable
contiene il nostro file aperto.
3.2. Evento I / O di base
Analogamente alla AsyncSleep
richiesta, dobbiamo definire un evento per I / O. Con la select
logica sottostante , l'evento deve fare riferimento a un oggetto leggibile, ad esempio un open
file. Inoltre, memorizziamo la quantità di dati da leggere.
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)
)
Come per AsyncSleep
la maggior parte dei casi, memorizziamo solo i dati richiesti per la chiamata di sistema sottostante. Questa volta, __await__
può essere ripreso più volte, fino a quando il nostro desiderio non amount
è stato letto. Inoltre, abbiamo return
il risultato I / O invece di riprendere.
3.3. Aumentare un ciclo di eventi con I / O di lettura
La base per il nostro ciclo di eventi è ancora quella run
definita in precedenza. Innanzitutto, dobbiamo tenere traccia delle richieste di lettura. Questa non è più una pianificazione ordinata, mappiamo solo le richieste di lettura alle coroutine.
# new
waiting_read = {} # type: Dict[file, coroutine]
Poiché select.select
richiede un parametro di timeout, possiamo usarlo al posto di time.sleep
.
# old
time.sleep(max(0.0, until - time.time()))
# new
readable, _, _ = select.select(list(reads), [], [])
Questo ci dà tutti i file leggibili: se ce ne sono, eseguiamo la coroutine corrispondente. Se non ce ne sono, abbiamo aspettato abbastanza a lungo perché la nostra coroutine corrente funzionasse.
# new - reschedule waiting coroutine, run readable coroutine
if readable:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read[readable[0]]
Infine, dobbiamo effettivamente ascoltare le richieste di lettura.
# new
if isinstance(command, AsyncSleep):
...
elif isinstance(command, AsyncRead):
...
3.4. Mettendolo insieme
Quanto sopra è stato un po 'una semplificazione. Dobbiamo fare un po 'di cambiamento per non morire di fame coroutine addormentate se possiamo sempre leggere. Dobbiamo gestire il non avere niente da leggere o niente da aspettare. Tuttavia, il risultato finale rientra ancora 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. I / O cooperativo
I AsyncSleep
, AsyncRead
e le run
implementazioni sono ora pienamente funzionale a dormire e / o di lettura. Come per sleepy
, possiamo definire un helper per testare la lettura:
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'))
Eseguendolo, possiamo vedere che il nostro I / O è intercalato con l'attività in attesa:
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. I / O non bloccante
Sebbene l'I / O sui file trasmetta il concetto, non è realmente adatto per una libreria come asyncio
: la select
chiamata ritorna sempre per i file , ed entrambi open
e read
possono bloccarsi indefinitamente . Questo blocca tutte le coroutine di un ciclo di eventi, il che è negativo. Librerie come aiofiles
thread di utilizzo e sincronizzazione per fingere I / O non bloccanti e eventi su file.
Tuttavia, i socket consentono l'I / O non bloccante e la loro latenza intrinseca lo rende molto più critico. Quando viene utilizzato in un ciclo di eventi, l'attesa dei dati e il nuovo tentativo possono essere inseriti senza bloccare nulla.
4.1. Evento I / O non bloccante
Simile al nostro AsyncRead
, possiamo definire un evento di sospensione e lettura per i socket. Invece di prendere un file, prendiamo un socket, che deve essere non bloccante. Inoltre, i nostri __await__
usi socket.recv
invece di 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)
)
Al contrario AsyncRead
, __await__
esegue I / O veramente non bloccanti. Quando i dati sono disponibili, si legge sempre . Quando non sono disponibili dati, si sospende sempre . Ciò significa che il ciclo degli eventi viene bloccato solo mentre eseguiamo un lavoro utile.
4.2. Sblocco del ciclo di eventi
Per quanto riguarda il ciclo di eventi, non cambia molto. L'evento da ascoltare è sempre lo stesso dei file: un descrittore di file contrassegnato come pronto da 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
A questo punto, dovrebbe essere ovvio che AsyncRead
e AsyncRecv
sono lo stesso tipo di evento. Potremmo facilmente rifattorizzarli come un evento con un componente I / O intercambiabile. In effetti, il ciclo di eventi, le coroutine e gli eventi separano nettamente uno scheduler, un codice intermedio arbitrario e l'I / O effettivo.
4.3. Il lato brutto dell'I / O non bloccante
In linea di principio, ciò che dovresti fare a questo punto è replicare la logica di read
as a recv
for AsyncRecv
. Tuttavia, questo è molto più brutto ora: devi gestire i primi ritorni quando le funzioni si bloccano all'interno del kernel, ma ti danno il controllo. Ad esempio, l'apertura di una connessione rispetto all'apertura di un file è molto più lunga:
# 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
Per farla breve, ciò che rimane sono poche dozzine di righe di gestione delle eccezioni. Gli eventi e il ciclo di eventi funzionano già a questo punto.
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
Addendum
Codice di esempio su GitHub
BaseEventLoop
è implementato CPython : github.com/python/cpython/blob/…