Parlarne async/awaite asyncionon è 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/awaite 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 defversus async defè solo per chiarezza. La differenza effettiva è returnrispetto a yield. Da questo, awaito yield fromprendi 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
barequx
- 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
barequx
- 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 returne (). 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 fromfa entrambe le cose . Sospende lo stack e il puntatore di istruzioni wrap e viene eseguito cofoo. Nota che wraprimane sospeso fino al cofootermine completo. Ogni volta che cofoosospende o qualcosa viene inviato, cofooè direttamente connesso allo stack chiamante.
1.4. Coroutine fino in fondo
Come stabilito, yield fromconsente 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 roote coro_bnon 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, rootpotrebbe 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 asynceawait
La spiegazione ha finora utilizzato esplicitamente il vocabolario yielde yield fromdei generatori - la funzionalità sottostante è la stessa. La nuova sintassi di Python3.5 asynced awaitesiste 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 fore async withsono necessarie perché spezzeresti la yield from/awaitcatena con le dichiarazioni nude fore 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 awaitvicenda, fino a quando finalmente un evento è awaitedito. Questo evento può comunicare direttamente con il loop di eventi tramite yieldil 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 sleepfinché una condizione non è vera. Tuttavia, una normale sleepesecuzione 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 awaitdirettamente 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 awaitparola 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 sleepda awaiting 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.sendesegue la coroutine fino a quando non si ottiene yieldun risultato.
coroutine = asleep(100)
while True:
print(coroutine.send(None))
time.sleep(0.1)
Questo ci dà due AsyncSleepeventi e poi uno StopIterationquando la coroutine è finita. Notare che l'unico ritardo è time.sleepnel loop! Ciascuno AsyncSleepmemorizza 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 sleepfar 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 listpermette 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 StopIteratione assegnarli alla coroutine. Tuttavia, il principio fondamentale rimane lo stesso.
2.4. Attesa cooperativa
L' AsyncSleepevento e il runciclo 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 selectchiamata
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 openun 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, writeablecontiene il nostro file aperto.
3.2. Evento I / O di base
Analogamente alla AsyncSleeprichiesta, dobbiamo definire un evento per I / O. Con la selectlogica sottostante , l'evento deve fare riferimento a un oggetto leggibile, ad esempio un openfile. 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 AsyncSleepla 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 returnil 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 rundefinita 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.selectrichiede 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, AsyncReade le runimplementazioni 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 selectchiamata ritorna sempre per i file , ed entrambi opene readpossono bloccarsi indefinitamente . Questo blocca tutte le coroutine di un ciclo di eventi, il che è negativo. Librerie come aiofilesthread 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.recvinvece 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 AsyncReade AsyncRecvsono 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 readas a recvfor 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/…