Come funziona effettivamente asyncio?


120

Questa domanda è motivata da un'altra mia domanda: come aspettare in cdef?

Ci sono tonnellate di articoli e post di blog sul web asyncio, ma sono tutti molto superficiali. Non sono riuscito a trovare alcuna informazione su come asyncioè effettivamente implementato e cosa rende I / O asincrono. Stavo cercando di leggere il codice sorgente, ma sono migliaia di righe di codice C non di altissimo livello, molte delle quali si occupano di oggetti ausiliari, ma soprattutto, è difficile connettersi tra la sintassi Python e il codice C che tradurrebbe in.

La documentazione di Asycnio è ancora meno utile. Non ci sono informazioni su come funziona, solo alcune linee guida su come usarlo, che a volte sono anche fuorvianti / scritte molto male.

Ho familiarità con l'implementazione delle coroutine da parte di Go e speravo che Python facesse la stessa cosa. Se fosse così, il codice che ho trovato nel post collegato sopra avrebbe funzionato. Dato che non è stato così, sto cercando di capire perché. La mia ipotesi migliore finora è la seguente, per favore correggimi dove sbaglio:

  1. Le definizioni di procedura del modulo async def foo(): ...vengono effettivamente interpretate come metodi di eredità di una classe coroutine.
  2. Forse, async defè effettivamente suddiviso in più metodi da awaitistruzioni, dove l'oggetto, su cui sono chiamati questi metodi, è in grado di tenere traccia dei progressi compiuti fino a quel momento attraverso l'esecuzione.
  3. Se quanto sopra è vero, quindi, essenzialmente, l'esecuzione di una coroutine si riduce alla chiamata di metodi di un oggetto coroutine da parte di qualche gestore globale (loop?).
  4. Il gestore globale è in qualche modo (come?) Consapevole di quando le operazioni di I / O vengono eseguite dal codice Python (solo?) Ed è in grado di scegliere uno dei metodi coroutine in sospeso da eseguire dopo che il metodo di esecuzione corrente ha rinunciato al controllo (premere l' awaitistruzione ).

In altre parole, ecco il mio tentativo di "desugaring" di qualche asynciosintassi in qualcosa di più comprensibile:

async def coro(name):
    print('before', name)
    await asyncio.sleep()
    print('after', name)

asyncio.gather(coro('first'), coro('second'))

# translated from async def coro(name)
class Coro(coroutine):
    def before(self, name):
        print('before', name)

    def after(self, name):
        print('after', name)

    def __init__(self, name):
        self.name = name
        self.parts = self.before, self.after
        self.pos = 0

    def __call__():
        self.parts[self.pos](self.name)
        self.pos += 1

    def done(self):
        return self.pos == len(self.parts)


# translated from asyncio.gather()
class AsyncIOManager:

    def gather(*coros):
        while not every(c.done() for c in coros):
            coro = random.choice(coros)
            coro()

Se la mia ipotesi dovesse risultare corretta: allora ho un problema. Come avviene effettivamente l'I / O in questo scenario? In un thread separato? L'intero interprete è sospeso e l'I / O avviene all'esterno dell'interprete? Cosa si intende esattamente per I / O? Se la mia procedura Python ha chiamato la procedura C open(), e a sua volta ha inviato interrupt al kernel, cedendogli il controllo, come fa l'interprete Python a saperlo ed è in grado di continuare a eseguire un altro codice, mentre il codice del kernel esegue l'I / O effettivo e finché risveglia la procedura Python che ha inviato originariamente l'interrupt? Come può l'interprete Python in linea di principio essere consapevole di ciò che sta accadendo?


2
La maggior parte della logica viene gestita dall'implementazione del ciclo di eventi. Guarda come BaseEventLoopè implementato CPython : github.com/python/cpython/blob/…
Blender

@ Blender ok, penso di aver finalmente trovato quello che volevo, ma ora non capisco il motivo per cui il codice è stato scritto così com'era. Perché _run_once, qual è in realtà l'unica funzione utile in questo intero modulo, è resa "privata"? L'implementazione è orribile, ma questo è un problema minore. Perché l'unica funzione che vorresti chiamare in loop di eventi è contrassegnata come "non chiamarmi"?
wvxvw

Questa è una domanda per la mailing list. Quale caso d'uso richiederebbe di toccare _run_oncein primo luogo?
Blender

8
Questo però non risponde alla mia domanda. Come risolveresti qualsiasi problema utile usando solo _run_once? asyncioè complesso e ha i suoi difetti, ma per favore mantieni la discussione civile. Non parlare male degli sviluppatori dietro codice che tu stesso non capisci.
Blender

1
@ user8371915 Se credi che ci sia qualcosa che non ho trattato, puoi aggiungere o commentare la mia risposta.
Bharel

Risposte:


203

Come funziona asyncio?

Prima di rispondere a questa domanda dobbiamo comprendere alcuni termini di base, saltali se ne conosci già qualcuno.

Generatori

I generatori sono oggetti che ci permettono di sospendere l'esecuzione di una funzione python. I generatori curati dagli utenti vengono implementati utilizzando la parola chiave yield. Creando una normale funzione contenente la yieldparola chiave, trasformiamo quella funzione in un generatore:

>>> def test():
...     yield 1
...     yield 2
...
>>> gen = test()
>>> next(gen)
1
>>> next(gen)
2
>>> next(gen)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

Come puoi vedere, invocare next()il generatore fa sì che l'interprete carichi il frame di test e restituisca il yieldvalore ed. Chiamando di next()nuovo, il frame viene caricato di nuovo nello stack dell'interprete e continua yieldcon un altro valore.

Alla terza next()chiamata, il nostro generatore era finito ed è StopIterationstato lanciato.

Comunicare con un generatore

Una caratteristica meno nota dei generatori è il fatto che puoi comunicare con loro usando due metodi: send()e throw().

>>> def test():
...     val = yield 1
...     print(val)
...     yield 2
...     yield 3
...
>>> gen = test()
>>> next(gen)
1
>>> gen.send("abc")
abc
2
>>> gen.throw(Exception())
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 4, in test
Exception

Al momento della chiamata gen.send(), il valore viene passato come valore restituito dalla yieldparola chiave.

gen.throw()d'altra parte, consente di lanciare eccezioni all'interno dei generatori, con l'eccezione sollevata nello stesso punto in cui è yieldstata chiamata.

Restituzione di valori dai generatori

Restituendo un valore da un generatore, il valore viene inserito StopIterationnell'eccezione. In seguito possiamo recuperare il valore dall'eccezione e utilizzarlo secondo le nostre necessità.

>>> def test():
...     yield 1
...     return "abc"
...
>>> gen = test()
>>> next(gen)
1
>>> try:
...     next(gen)
... except StopIteration as exc:
...     print(exc.value)
...
abc

Ecco, una nuova parola chiave: yield from

Python 3.4 è venuto con l'aggiunta di una nuova parola chiave: yield from. Che cosa quella parola chiave ci permette di fare, è trasmettere qualsiasi next(), send()e throw()in un generatore interno più nidificato. Se il generatore interno restituisce un valore, è anche il valore di ritorno di yield from:

>>> def inner():
...     inner_result = yield 2
...     print('inner', inner_result)
...     return 3
...
>>> def outer():
...     yield 1
...     val = yield from inner()
...     print('outer', val)
...     yield 4
...
>>> gen = outer()
>>> next(gen)
1
>>> next(gen) # Goes inside inner() automatically
2
>>> gen.send("abc")
inner abc
outer 3
4

Ho scritto un articolo per approfondire questo argomento.

Mettere tutto insieme

Dopo aver introdotto la nuova parola chiave yield fromin Python 3.4, ora siamo stati in grado di creare generatori all'interno di generatori che, proprio come un tunnel, passano i dati avanti e indietro dal generatore più interno a quello più esterno. Questo ha generato un nuovo significato per i generatori: le coroutine .

Le coroutine sono funzioni che possono essere interrotte e riprese durante l'esecuzione. In Python, vengono definiti utilizzando la async defparola chiave. Proprio come i generatori, anche loro usano la loro forma yield fromche è await. Prima asynce awaitsono stati introdotti in Python 3.5, abbiamo creato coroutine nello stesso modo in cui sono stati creati i generatori (con yield frominvece di await).

async def inner():
    return 1

async def outer():
    await inner()

Come ogni iteratore o generatore che implementa il __iter__()metodo, le coroutine implementano __await__()che consente loro di continuare ogni volta che await coroviene chiamato.

C'è un bel diagramma di sequenza all'interno della documentazione di Python che dovresti controllare.

In asyncio, oltre alle funzioni coroutine, abbiamo 2 oggetti importanti: task e futures .

Futures

I futures sono oggetti che hanno il __await__()metodo implementato e il loro compito è mantenere un certo stato e risultato. Lo stato può essere uno dei seguenti:

  1. IN ATTESA: il futuro non ha alcun risultato o gruppo di eccezioni.
  2. ANNULLATO - il futuro è stato annullato utilizzando fut.cancel()
  3. FINITO: il futuro è stato terminato, tramite un set di risultati utilizzando fut.set_result()o un insieme di eccezioni utilizzandofut.set_exception()

Il risultato, proprio come hai intuito, può essere un oggetto Python, che verrà restituito, o un'eccezione che può essere sollevata.

Un'altra caratteristica importante degli futureoggetti è che contengono un metodo chiamato add_done_callback(). Questo metodo consente di chiamare le funzioni non appena l'attività è terminata, indipendentemente dal fatto che abbia sollevato un'eccezione o sia terminata.

Compiti

Gli oggetti task sono futuri speciali, che avvolgono le coroutine e comunicano con le coroutine più interne ed esterne. Ogni volta che una coroutine awaitsa futuro, il futuro viene riconsegnato al compito (proprio come in yield from), e il compito lo riceve.

Successivamente, l'attività si lega al futuro. Lo fa invocando add_done_callback()il futuro. D'ora in poi, se il futuro sarà mai fatto, cancellando, passando un'eccezione o passando un oggetto Python come risultato, verrà chiamato il callback dell'attività e tornerà all'esistenza.

Asyncio

L'ultima domanda scottante a cui dobbiamo rispondere è: come viene implementato l'IO?

Nel profondo di asyncio, abbiamo un ciclo di eventi. Un ciclo di eventi di attività. Il compito del ciclo di eventi è chiamare le attività ogni volta che sono pronte e coordinare tutto lo sforzo in un'unica macchina funzionante.

La parte IO del ciclo degli eventi è costruita su una singola funzione cruciale chiamata select. Select è una funzione di blocco, implementata dal sistema operativo sottostante, che consente di attendere sui socket i dati in entrata o in uscita. Dopo che i dati vengono ricevuti, si sveglia e restituisce i socket che hanno ricevuto i dati o quelli pronti per la scrittura.

Quando si tenta di ricevere o inviare dati su un socket tramite asyncio, ciò che effettivamente accade di seguito è che il socket viene prima controllato se ha dati che possono essere letti o inviati immediatamente. Se il suo .send()buffer è pieno, o il .recv()buffer è vuoto, il socket viene registrato alla selectfunzione (semplicemente aggiungendolo a una delle liste, rlistfor recve wlistfor send) e la funzione appropriata è awaitun futureoggetto appena creato , legato a quel socket.

Quando tutte le attività disponibili sono in attesa del futuro, il ciclo di eventi chiama selecte attende. Quando uno dei socket ha dati in entrata, o il suo sendbuffer si è esaurito, asyncio controlla il futuro oggetto legato a quel socket e lo imposta su fatto.

Ora accade tutta la magia. Il futuro è pronto, il compito che si è aggiunto prima con add_done_callback()risale in vita e chiama .send()la coroutine che riprende la coroutine più interna (a causa della awaitcatena) e tu leggi i dati appena ricevuti da un buffer vicino è stato versato.

Di nuovo la catena di metodi, in caso di recv():

  1. select.select aspetta.
  2. Viene restituito un socket pronto, con i dati.
  3. I dati dal socket vengono spostati in un buffer.
  4. future.set_result() è chiamato.
  5. L'attività che si è aggiunta con add_done_callback()è ora svegliata.
  6. Task chiama .send()la coroutine che arriva fino alla coroutine più interna e la sveglia.
  7. I dati vengono letti dal buffer e restituiti al nostro umile utente.

In sintesi, asyncio utilizza le capacità del generatore, che consentono di mettere in pausa e riprendere le funzioni. Utilizza yield fromfunzionalità che consentono di passare i dati avanti e indietro dal generatore più interno a quello più esterno. Utilizza tutti questi elementi per interrompere l'esecuzione della funzione mentre è in attesa del completamento dell'IO (utilizzando la selectfunzione OS ).

E il migliore di tutti? Mentre una funzione è in pausa, un'altra può essere eseguita e alternarsi con il tessuto delicato, che è asincrono.


12
Se sono necessarie ulteriori spiegazioni, non esitare a commentare. A proposito, non sono del tutto sicuro se avrei dovuto scriverlo come articolo del blog o come risposta in stackoverflow. La domanda è lunga a cui rispondere.
Bharel

1
Su un socket asincrono, il tentativo di inviare o ricevere dati controlla prima il buffer del sistema operativo. Se stai tentando di ricevere e non ci sono dati nel buffer, la funzione di ricezione sottostante restituirà un valore di errore che si propagherà come eccezione in Python. Stessa cosa con send e un buffer pieno. Quando viene sollevata l'eccezione, Python a sua volta invia quei socket alla funzione di selezione che sospende il processo. Ma non è così che funziona asyncio, è come funzionano select e socket che è anche altamente specifico del sistema operativo.
Bharel

2
@ user8371915 Sempre qui per aiutare :-) Tieni presente che per capire Asyncio devi sapere come funzionano i generatori, la comunicazione e il generatore yield from. Tuttavia ho notato in alto che è ignorabile nel caso in cui il lettore lo sappia già :-) C'è qualcos'altro che credi dovrei aggiungere?
Bharel

2
Le cose prima della sezione Asyncio sono forse le più critiche, poiché sono l'unica cosa che la lingua fa effettivamente da sola. Anche il selectpuò qualificarsi, poiché è il modo in cui le chiamate di sistema I / O non bloccanti funzionano sul sistema operativo. I asynciocostrutti effettivi e il ciclo di eventi sono solo codice a livello di app costruito da queste cose.
MisterMiyagi

3
Questo post contiene informazioni sulla spina dorsale dell'I / O asincrono in Python. Grazie per questa gentile spiegazione.
mjkim

83

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

  1. allocare lo spazio dello stack per barequx
  2. esegue ricorsivamente la prima istruzione e salta all'istruzione successiva
  3. una volta alla volta return, sposta il suo valore nello stack chiamante
  4. 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

  1. allocare lo spazio dello stack per barequx
  2. esegue ricorsivamente la prima istruzione e salta all'istruzione successiva
    1. una volta alla volta yield, sposta il suo valore nello stack chiamante ma memorizza lo stack e il puntatore dell'istruzione
    2. una volta chiamato yield, ripristina lo stack e il puntatore dell'istruzione e invia gli argomenti aqux
  3. una volta alla volta return, sposta il suo valore nello stack chiamante
  4. 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:

  1. ordina le coroutine in base all'ora di sveglia desiderata
  2. scegli il primo che vuole svegliarsi
  3. attendere fino a questo momento
  4. eseguire questa coroutine
  5. 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


L'utilizzo yield selfin AsyncSleep mi dà un Task got back yielderrore, perché? Vedo che il codice in asyncio.Futures lo usa. Usare una resa nuda funziona bene.
Ron Serruya

1
I cicli di eventi di solito si aspettano solo i propri eventi. In genere non è possibile combinare eventi e loop di eventi tra le librerie; gli eventi mostrati qui funzionano solo con il ciclo di eventi mostrato. In particolare, asyncio usa solo None (cioè un semplice rendimento) come segnale per il loop degli eventi. Gli eventi interagiscono direttamente con l'oggetto loop di eventi per registrare i wakeup.
MisterMiyagi

12

Il tuo corodesugaring è concettualmente corretto, ma leggermente incompleto.

awaitnon sospende incondizionatamente, ma solo se incontra una chiamata di blocco. Come fa a sapere che una chiamata sta bloccando? Questo è deciso dal codice in attesa. Ad esempio, un'implementazione attesa della lettura del socket potrebbe essere desugared a:

def read(sock, n):
    # sock must be in non-blocking mode
    try:
        return sock.recv(n)
    except EWOULDBLOCK:
        event_loop.add_reader(sock.fileno, current_task())
        return SUSPEND

Nell'asincio reale il codice equivalente modifica lo stato di a Futureinvece di restituire valori magici, ma il concetto è lo stesso. Se opportunamente adattato a un oggetto simile a un generatore, il codice sopra può essere modificato await.

Dal lato del chiamante, quando la tua coroutine contiene:

data = await read(sock, 1024)

Si scarica in qualcosa di simile a:

data = read(sock, 1024)
if data is SUSPEND:
    return SUSPEND
self.pos += 1
self.parts[self.pos](...)

Le persone che hanno familiarità con i generatori tendono a descrivere quanto sopra in termini di yield fromcui fa la sospensione automaticamente.

La catena di sospensione continua fino al ciclo degli eventi, che nota che la coroutine è sospesa, la rimuove dal set eseguibile e continua a eseguire le coroutine eseguibili, se presenti. Se nessuna coroutine è eseguibile, il ciclo attendeselect() fino quando un descrittore di file a cui una coroutine è interessata diventa pronto per IO. (Il ciclo degli eventi mantiene una mappatura del descrittore di file su coroutine.)

Nell'esempio sopra, una volta che select()il ciclo di eventi dice che sockè leggibile, verrà aggiunto corodi nuovo al set eseguibile, quindi verrà continuato dal punto di sospensione.

In altre parole:

  1. Per impostazione predefinita, tutto accade nello stesso thread.

  2. Il ciclo degli eventi è responsabile della pianificazione delle coroutine e del loro risveglio quando ciò che stavano aspettando (in genere una chiamata IO che normalmente si bloccherebbe o un timeout) diventa pronto.

Per informazioni sui loop di eventi che guidano la coroutine, consiglio questo discorso di Dave Beazley, in cui dimostra come codificare un loop di eventi da zero di fronte a un pubblico dal vivo.


Grazie, questo è più vicino a quello che sto cercando, ma questo ancora non spiega perché async.wait_for()non fa quello che dovrebbe ... Perché è un grosso problema aggiungere un callback al loop di eventi e dirlo elaborare tutte le richiamate necessarie, inclusa quella appena aggiunta? La mia frustrazione asyncioè in parte dovuta al fatto che il concetto sottostante è molto semplice e, ad esempio, Emacs Lisp è stato implementato per anni, senza usare parole d'ordine ... (cioè create-async-processe accept-process-output- e questo è tutto ciò che è necessario ... (segue)
wvxvw

10
@wvxvw Ho fatto tutto il possibile per rispondere alla domanda che hai postato, per quanto possibile dato che solo l'ultimo paragrafo contiene sei domande. E così andiamo avanti - non è che wait_for non fa quello che dovrebbe (fa, è una coroutine che dovresti aspettare), è che le tue aspettative non corrispondono a ciò per cui il sistema è stato progettato e implementato. Penso che il tuo problema potrebbe essere abbinato all'asincio se il ciclo di eventi fosse in esecuzione in un thread separato, ma non conosco i dettagli del tuo caso d'uso e, onestamente, il tuo atteggiamento non rende molto divertente aiutarti.
user4815162342

5
@wvxvw My frustration with asyncio is in part due to the fact that the underlying concept is very simple, and, for example, Emacs Lisp had implementation for ages, without using buzzwords...- Niente ti impedisce di implementare questo semplice concetto senza parole d'ordine per Python, allora :) Perché usi questo brutto asincio? Implementa il tuo da zero. Ad esempio, puoi iniziare creando la tua async.wait_for()funzione che fa esattamente quello che dovrebbe.
Mikhail Gerasimov

1
@MikhailGerasimov sembri pensare che sia una domanda retorica. Ma vorrei dissipare il mistero per te. La lingua è progettata per parlare agli altri. Non posso scegliere per gli altri quale lingua parlano, anche se credo che la lingua che parlano sia spazzatura, il meglio che posso fare è cercare di convincerli che è così. In altre parole, se fossi libero di scegliere, non sceglierei mai Python per cominciare, figuriamoci asyncio. Ma, in linea di principio, non è una mia decisione da prendere. Sono costretto a usare il linguaggio spazzatura tramite en.wikipedia.org/wiki/Ultimatum_game .
wvxvw

4

Tutto si riduce alle due sfide principali che asyncio sta affrontando:

  • Come eseguire più I / O in un singolo thread?
  • Come implementare il multitasking cooperativo?

La risposta al primo punto esiste da molto tempo e viene chiamata ciclo di selezione . In python, è implementato nel modulo selettori .

La seconda domanda è legata al concetto di coroutine , cioè funzioni che possono interromperne l'esecuzione ed essere ripristinate successivamente. In python, le coroutine sono implementate usando i generatori e l' istruzione yield from . Questo è ciò che si nasconde dietro la sintassi async / await .

Più risorse in questa risposta .


EDIT: indirizzando il tuo commento sulle goroutine:

L'equivalente più vicino a una goroutine in asyncio non è in realtà una coroutine ma un'attività (vedere la differenza nella documentazione ). In Python, una coroutine (o un generatore) non sa nulla dei concetti di loop di eventi o I / O. È semplicemente una funzione che può interrompere la sua esecuzione utilizzando yieldmantenendo il suo stato corrente, in modo che possa essere ripristinata in seguito. La yield fromsintassi consente di concatenarli in modo trasparente.

Ora, all'interno di un compito asincrono, la coroutine in fondo alla catena finisce sempre per fornire un futuro . Questo futuro quindi bolle fino al ciclo degli eventi e viene integrato nel meccanismo interno. Quando il futuro è impostato per essere eseguito da un altro callback interno, il ciclo di eventi può ripristinare l'attività rimandando il futuro nella catena coroutine.


EDIT: rispondere ad alcune delle domande nel tuo post:

Come avviene effettivamente l'I / O in questo scenario? In un thread separato? L'intero interprete è sospeso e l'I / O avviene all'esterno dell'interprete?

No, non accade nulla in un thread. L'I / O è sempre gestito dal ciclo di eventi, principalmente tramite descrittori di file. Tuttavia la registrazione di quei descrittori di file è solitamente nascosta da coroutine di alto livello, facendo il lavoro sporco per te.

Cosa si intende esattamente per I / O? Se la mia procedura Python ha chiamato la procedura C open (), e a sua volta ha inviato interrupt al kernel, cedendogli il controllo, come fa l'interprete Python a saperlo ed è in grado di continuare a eseguire qualche altro codice, mentre il codice del kernel esegue l'effettivo I / O e fino a quando non si attiva la procedura Python che ha inviato l'interrupt originariamente? Come può l'interprete Python in linea di principio essere consapevole di ciò che sta accadendo?

Un I / O è una chiamata di blocco. In asyncio, tutte le operazioni di I / O dovrebbero passare attraverso il ciclo di eventi, perché come hai detto, il ciclo di eventi non ha modo di essere a conoscenza che una chiamata di blocco viene eseguita in un codice sincrono. Ciò significa che non dovresti usare un sincrono opennel contesto di una coroutine. Utilizzare invece una libreria dedicata come aiofiles che fornisce una versione asincrona di open.


Dire che le coroutine sono implementate usando yield fromnon dice davvero nulla. yield fromè solo un costrutto di sintassi, non è un elemento fondamentale che i computer possono eseguire. Allo stesso modo, per il ciclo di selezione. Sì, le coroutine in Go usano anche il ciclo di selezione, ma quello che stavo cercando di fare avrebbe funzionato in Go, ma non in Python. Ho bisogno di risposte più dettagliate per capire perché non ha funzionato.
wvxvw

Scusa ... no, non proprio. "futuro", "compito", "modo trasparente", "rendimento da" sono solo parole d'ordine, non sono oggetti del dominio della programmazione. la programmazione ha variabili, procedure e strutture. Quindi, dire che "la goroutine è un compito" è solo un'affermazione circolare che pone una domanda. In definitiva, una spiegazione di ciò che asynciofa, per me, si ridurrebbe al codice C che illustra in cosa è stata tradotta la sintassi di Python.
wvxvw

Per spiegare ulteriormente perché la tua risposta non risponde alla mia domanda: con tutte le informazioni che hai fornito, non ho idea del motivo per cui il mio tentativo dal codice che ho pubblicato nella domanda collegata non ha funzionato. Sono assolutamente certo di poter scrivere un ciclo di eventi in modo tale che questo codice funzioni. In effetti, questo sarebbe il modo in cui scriverei un loop di eventi, se dovessi scriverne uno.
wvxvw

7
@wvxvw Non sono d'accordo. Quelle non sono "parole d'ordine" ma concetti di alto livello che sono stati implementati in molte biblioteche. Ad esempio, un'attività asincrona, un greenlet gevent e una goroutine corrispondono tutti alla stessa cosa: un'unità di esecuzione che può essere eseguita contemporaneamente all'interno di un singolo thread. Inoltre non penso che il C sia necessario per capire l'asincio, a meno che tu non voglia entrare nel funzionamento interno dei generatori Python.
Vincent,

@wvxvw Vedi la mia seconda modifica. Questo dovrebbe eliminare alcuni malintesi.
Vincent,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.