Falar sobre async/await
e asyncio
não é a mesma coisa. A primeira é uma construção fundamental de baixo nível (corrotinas), enquanto a última é uma biblioteca que usa essas construções. Por outro lado, não existe uma resposta definitiva única.
O seguinte é uma descrição geral de como async/await
e asyncio
bibliotecas -como trabalho. Ou seja, pode haver outros truques no topo (existem ...), mas eles são irrelevantes, a menos que você os construa sozinho. A diferença deve ser insignificante, a menos que você já saiba o suficiente para não ter que fazer essa pergunta.
1. Corrotinas versus sub-rotinas em uma casca de noz
Assim como as sub-rotinas (funções, procedimentos, ...), co-rotinas (geradores, ...) são uma abstração da pilha de chamadas e do ponteiro de instrução: há uma pilha de peças de código em execução e cada uma está em uma instrução específica.
A distinção de def
versus async def
é apenas para maior clareza. A diferença real é return
versus yield
. A partir disso, await
ou yield from
veja a diferença de chamadas individuais para pilhas inteiras.
1.1. Sub-rotinas
Uma sub-rotina representa um novo nível de pilha para armazenar variáveis locais e uma única passagem de suas instruções para chegar ao fim. Considere uma sub-rotina como esta:
def subfoo(bar):
qux = 3
return qux * bar
Quando você o executa, isso significa
- alocar espaço de pilha para
bar
equx
- executar recursivamente a primeira instrução e pular para a próxima instrução
- uma vez por vez
return
, envia seu valor para a pilha de chamadas
- limpe a pilha (1.) e o ponteiro de instrução (2.)
Notavelmente, 4. significa que uma sub-rotina sempre começa no mesmo estado. Tudo o que é exclusivo da função em si é perdido na conclusão. Uma função não pode ser retomada, mesmo se houver instruções depois return
.
root -\
: \- subfoo --\
:/--<---return --/
|
V
1.2. Corrotinas como sub-rotinas persistentes
Uma co-rotina é como uma sub-rotina, mas pode sair sem destruir seu estado. Considere uma co-rotina como esta:
def cofoo(bar):
qux = yield bar # yield marks a break point
return qux
Quando você o executa, isso significa
- alocar espaço de pilha para
bar
equx
- executar recursivamente a primeira instrução e pular para a próxima instrução
- uma vez por vez
yield
, envia seu valor para a pilha de chamada, mas armazena a pilha e o ponteiro de instrução
- uma vez chamando para
yield
, restaure a pilha e o ponteiro de instrução e empurre os argumentos paraqux
- uma vez por vez
return
, envia seu valor para a pilha de chamadas
- limpe a pilha (1.) e o ponteiro de instrução (2.)
Observe a adição de 2.1 e 2.2 - uma co-rotina pode ser suspensa e reiniciada em pontos predefinidos. Isso é semelhante a como uma sub-rotina é suspensa durante a chamada de outra sub-rotina. A diferença é que a co-rotina ativa não está estritamente ligada à sua pilha de chamada. Em vez disso, uma co-rotina suspensa faz parte de uma pilha separada e isolada.
root -\
: \- cofoo --\
:/--<+--yield --/
| :
V :
Isso significa que co-rotinas suspensas podem ser armazenadas livremente ou movidas entre pilhas. Qualquer pilha de chamadas que tenha acesso a uma co-rotina pode decidir retomá-la.
1.3. Atravessando a pilha de chamadas
Até agora, nossa co-rotina apenas desce na pilha de chamadas com yield
. Uma sub-rotina pode descer e subir na pilha de chamadas com return
e ()
. Para completar, as corrotinas também precisam de um mecanismo para subir na pilha de chamadas. Considere uma co-rotina como esta:
def wrap():
yield 'before'
yield from cofoo()
yield 'after'
Quando você o executa, isso significa que ele ainda aloca a pilha e o ponteiro de instrução como uma sub-rotina. Quando ele é suspenso, ainda é como armazenar uma sub-rotina.
No entanto, yield from
faz ambos . Ele suspende a pilha e o ponteiro de instrução wrap
e é executado cofoo
. Observe que wrap
permanece suspenso até cofoo
terminar completamente. Sempre que cofoo
suspende ou algo é enviado, cofoo
é conectado diretamente à pilha de chamada.
1.4. Corrotinas até o fim
Conforme estabelecido, yield from
permite conectar dois escopos em outro intermediário. Quando aplicado recursivamente, significa que o topo da pilha pode ser conectado à parte inferior da pilha.
root -\
: \-> coro_a -yield-from-> coro_b --\
:/ <-+------------------------yield ---/
| :
:\ --+-- coro_a.send----------yield ---\
: coro_b <-/
Observe isso root
e coro_b
não se conheçam. Isso torna as co-rotinas muito mais limpas do que os callbacks: as corrotinas ainda são construídas em uma relação 1: 1 como as sub-rotinas. As corrotinas suspendem e retomam toda a pilha de execução existente até um ponto de chamada regular.
Notavelmente, root
poderia ter um número arbitrário de corrotinas para retomar. No entanto, nunca pode retomar mais de um ao mesmo tempo. As co-rotinas da mesma raiz são concorrentes, mas não paralelas!
1,5. Python's async
eawait
A explicação até agora usou explicitamente o vocabulário yield
e yield from
dos geradores - a funcionalidade subjacente é a mesma. A nova sintaxe Python3.5 async
e await
existe principalmente para maior clareza.
def foo(): # subroutine?
return None
def foo(): # coroutine?
yield from foofoo() # generator? coroutine?
async def foo(): # coroutine!
await foofoo() # coroutine!
return None
As instruções async for
e async with
são necessárias porque você quebraria a yield from/await
cadeia com as instruções for
e nuas with
.
2. Anatomia de um loop de evento simples
Por si só, uma co-rotina não tem o conceito de ceder o controle a outra co-rotina. Ele só pode ceder o controle ao chamador na parte inferior de uma pilha de co-rotinas. Esse chamador pode então mudar para outra co-rotina e executá-la.
Este nó raiz de várias co-rotinas é comumente um loop de eventos : na suspensão, uma co-rotina produz um evento no qual deseja retomar. Por sua vez, o loop de eventos é capaz de esperar com eficiência que esses eventos ocorram. Isso permite que ele decida qual co-rotina executar a seguir ou como esperar antes de retomar.
Tal design implica que existe um conjunto de eventos predefinidos que o loop entende. Várias corrotinas await
entre si, até que finalmente um evento é await
ed. Este evento pode se comunicar diretamente com o loop de eventos por meio yield
do controle.
loop -\
: \-> coroutine --await--> event --\
:/ <-+----------------------- yield --/
| :
| : # loop waits for event to happen
| :
:\ --+-- send(reply) -------- yield --\
: coroutine <--yield-- event <-/
A chave é que a suspensão da co-rotina permite que o loop de eventos e os eventos se comuniquem diretamente. A pilha de co-rotina intermediária não requer nenhum conhecimento sobre qual loop a está executando, nem como os eventos funcionam.
2.1.1. Eventos no tempo
O evento mais simples de lidar é chegar a um determinado momento. Este é um bloco fundamental de código encadeado também: um encadeamento repetidamente sleep
até que uma condição seja verdadeira. No entanto, uma sleep
execução normal bloqueia por si só - queremos que outras corrotinas não sejam bloqueadas. Em vez disso, queremos dizer ao loop de eventos quando ele deve retomar a pilha de co-rotinas atual.
2.1.2. Definindo um Evento
Um evento é simplesmente um valor que podemos identificar - seja por meio de um enum, um tipo ou outra identidade. Podemos definir isso com uma classe simples que armazena nosso tempo alvo. Além de armazenar as informações do evento, podemos permitir a await
uma classe diretamente.
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)
Essa classe apenas armazena o evento - não diz como tratá-lo de fato.
A única característica especial é __await__
- é o que a await
palavra - chave procura. Praticamente, é um iterador, mas não está disponível para a máquina de iteração regular.
2.2.1. Esperando um evento
Agora que temos um evento, como as corrotinas reagem a ele? Devemos ser capazes de expressar o equivalente a sleep
por await
ing nosso evento. Para ver melhor o que está acontecendo, esperamos duas vezes na metade do 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)
Podemos instanciar e executar essa co-rotina diretamente. Semelhante a um gerador, o uso de coroutine.send
executa a co-rotina até o yield
resultado.
coroutine = asleep(100)
while True:
print(coroutine.send(None))
time.sleep(0.1)
Isso nos dá dois AsyncSleep
eventos e um StopIteration
quando a co-rotina é concluída. Observe que o único atraso é do time.sleep
loop! Cada AsyncSleep
um armazena apenas um deslocamento da hora atual.
2.2.2. Evento + Sono
Neste ponto, temos dois mecanismos separados à nossa disposição:
AsyncSleep
Eventos que podem ser gerados de dentro de uma co-rotina
time.sleep
que pode esperar sem impactar as corrotinas
Notavelmente, esses dois são ortogonais: nenhum afeta ou ativa o outro. Como resultado, podemos criar nossa própria estratégia sleep
para atender ao atraso de um AsyncSleep
.
2.3. Um ciclo de eventos ingênuo
Se tivermos várias corrotinas, cada uma pode nos dizer quando deseja ser acordada. Podemos então esperar até que o primeiro deles queira ser retomado, depois o que vem depois e assim por diante. Notavelmente, em cada ponto, só nos importamos com qual é o próximo .
Isso torna o agendamento direto:
- classificar as corrotinas pelo tempo de despertar desejado
- escolha o primeiro que quer acordar
- espere até este ponto no tempo
- execute esta co-rotina
- repita a partir de 1.
Uma implementação trivial não precisa de nenhum conceito avançado. A list
permite classificar as corrotinas por data. Esperar é normal time.sleep
. A execução de corrotinas funciona exatamente como antes com 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])
Claro, isso tem muito espaço para melhorias. Podemos usar um heap para a fila de espera ou uma tabela de despacho para eventos. Também poderíamos buscar valores de retorno de StopIteration
e atribuí-los à co-rotina. No entanto, o princípio fundamental permanece o mesmo.
2.4. Espera Cooperativa
O AsyncSleep
evento e o run
loop de evento são uma implementação totalmente funcional de eventos cronometrados.
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)))
Isso alterna cooperativamente entre cada uma das cinco co-rotinas, suspendendo cada uma por 0,1 segundos. Mesmo que o loop de eventos seja síncrono, ele ainda executa o trabalho em 0,5 segundos em vez de 2,5 segundos. Cada co-rotina mantém o estado e atua independentemente.
3. Loop de evento de I / O
Um loop de evento compatível sleep
é adequado para pesquisa . No entanto, esperar por E / S em um identificador de arquivo pode ser feito com mais eficiência: o sistema operacional implementa E / S e, portanto, sabe quais identificadores estão prontos. Idealmente, um loop de evento deve oferecer suporte a um evento "pronto para I / O" explícito.
3.1. A select
chamada
Python já tem uma interface para consultar o sistema operacional para ler as alças de E / S. Quando chamado com identificadores para ler ou gravar, ele retorna os identificadores prontos para ler ou gravar:
readable, writeable, _ = select.select(rlist, wlist, xlist, timeout)
Por exemplo, podemos open
escrever um arquivo e esperar que esteja pronto:
write_target = open('/tmp/foo')
readable, writeable, _ = select.select([], [write_target], [])
Uma vez que o select retorna, writeable
contém nosso arquivo aberto.
3.2. Evento de I / O básico
Semelhante à AsyncSleep
solicitação, precisamos definir um evento para E / S. Com a select
lógica subjacente , o evento deve se referir a um objeto legível - digamos um open
arquivo. Além disso, armazenamos quantos dados devemos ler.
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)
)
Como acontece com a AsyncSleep
maioria, apenas armazenamos os dados necessários para a chamada de sistema subjacente. Desta vez, __await__
pode ser reiniciado várias vezes - até que o nosso desejado amount
seja lido. Além disso, obtemos return
o resultado de E / S em vez de apenas retomar.
3.3. Aumentando um loop de evento com I / O de leitura
A base para nosso loop de eventos ainda é a run
definida anteriormente. Primeiro, precisamos rastrear as solicitações de leitura. Este não é mais um cronograma classificado, apenas mapeamos solicitações de leitura para corrotinas.
# new
waiting_read = {} # type: Dict[file, coroutine]
Uma vez que select.select
leva um parâmetro de tempo limite, podemos usá-lo no lugar de time.sleep
.
# old
time.sleep(max(0.0, until - time.time()))
# new
readable, _, _ = select.select(list(reads), [], [])
Isso nos dá todos os arquivos legíveis - se houver algum, executamos a co-rotina correspondente. Se não houver nenhum, esperamos o suficiente para que nossa corrotina atual seja executada.
# new - reschedule waiting coroutine, run readable coroutine
if readable:
waiting.append((until, coroutine))
waiting.sort()
coroutine = waiting_read[readable[0]]
Finalmente, temos que realmente ouvir as solicitações de leitura.
# new
if isinstance(command, AsyncSleep):
...
elif isinstance(command, AsyncRead):
...
3.4. Juntar as peças
O texto acima foi um pouco simplificado. Precisamos fazer algumas mudanças para não deixar as corrotinas adormecidas de fome, se sempre pudermos ler. Precisamos lidar com o fato de não termos nada para ler ou nada pelo que esperar. No entanto, o resultado final ainda se encaixa em 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. E / S cooperativa
Os AsyncSleep
, AsyncRead
e run
implementações estão agora totalmente funcional para dormir e / ou leitura. Da mesma forma que para sleepy
, podemos definir um auxiliar para testar a leitura:
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'))
Executando isso, podemos ver que nosso I / O é intercalado com a tarefa de espera:
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. E / S sem bloqueio
Embora a E / S em arquivos transmita o conceito, não é realmente adequado para uma biblioteca como asyncio
: a select
chamada sempre retorna para arquivos , e ambos open
e read
pode bloquear indefinidamente . Isso bloqueia todas as co-rotinas de um loop de evento - o que é ruim. Bibliotecas como o aiofiles
uso de threads e sincronização para falsificar E / S sem bloqueio e eventos no arquivo.
No entanto, os soquetes permitem E / S sem bloqueio - e sua latência inerente o torna muito mais crítico. Quando usado em um loop de evento, a espera por dados e a nova tentativa podem ser quebradas sem bloquear nada.
4.1. Evento de I / O sem bloqueio
Semelhante ao nosso AsyncRead
, podemos definir um evento suspend-and-read para sockets. Em vez de pegar um arquivo, pegamos um socket - que deve ser não bloqueador. Além disso, nossos __await__
usos em socket.recv
vez de 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)
)
Em contraste com AsyncRead
, __await__
executa E / S verdadeiramente sem bloqueio. Quando os dados estão disponíveis, ele sempre lê. Quando nenhum dado está disponível, ele sempre é suspenso. Isso significa que o loop de eventos só é bloqueado enquanto realizamos um trabalho útil.
4.2. Desbloqueando o loop de eventos
No que diz respeito ao loop de eventos, nada muda muito. O evento a ser escutado ainda é o mesmo que o dos arquivos - um descritor de arquivo marcado como pronto por 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
Neste ponto, deve ser óbvio que AsyncRead
e AsyncRecv
são o mesmo tipo de evento. Poderíamos facilmente refatorá-los para ser um evento com um componente de E / S intercambiável. Na verdade, o loop de eventos, as corrotinas e os eventos separam claramente um planejador, código intermediário arbitrário e a E / S real.
4.3. O lado feio do I / O sem bloqueio
Em princípio, o que você deve fazer neste ponto é replicar a lógica de read
como um recv
para AsyncRecv
. No entanto, isso é muito mais feio agora - você tem que lidar com os retornos iniciais quando as funções bloqueiam dentro do kernel, mas fornecem o controle para você. Por exemplo, abrir uma conexão em vez de abrir um arquivo é muito mais demorado:
# 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
Para encurtar a história, o que resta são algumas dezenas de linhas de tratamento de exceções. Os eventos e o loop de eventos já funcionam neste ponto.
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
Termo aditivo
Exemplo de código no github
BaseEventLoop
é implementado: github.com/python/cpython/blob/…