Como o Python asyncio funciona: recriando do zero
(jacobpadilla.com)- O asyncio do Python é uma ferramenta para lidar com programas I/O-bound, e o artigo o reconstrói com uma implementação simples sobre geradores para mostrar como ele funciona
- A ideia central é uma event loop com várias tarefas que executa cada uma até recuperar o controle em um
yielde então passar para a próxima tarefa sleeppode ser criado como um subgerador que fazyieldaté o tempo passar, eyield frompermite retomar a tarefa original depois que o gerador interno termina- Ao converter para
async/await,Task.__await__()devolve o controle para a event loop até a conclusão, ecreate_taskerunpassam a se parecer com a API real do asyncio - Ao trocar as chamadas do
jacobioporasyncio, o código passa a usar o pacote real, mas o asyncio de verdade é muito mais complexo que o exemplo e seu fluxo interno também difere em parte
Estrutura básica do asyncio vista com geradores
- O asyncio é usado em Python para tratar programas I/O-bound, e o artigo o reconstrói de forma simplificada com base em geradores
- Assim como
rangeno Python 3+, geradores produzem valores sob demanda sem armazenar toda a sequência na memória- Se
range(100_000_000)fosse criado como uma lista, seria preciso armazenar 100 milhões de elementos, o que é ineficiente em memória - Geradores criam cada valor quando necessário, sem guardar a sequência inteira
- Se
- Funções geradoras são definidas como funções normais, mas usam
yield- Ao chamar a função, o corpo não é executado imediatamente; em vez disso, ela retorna um objeto gerador
- Ao chamar
next(iterator), a execução avança até o próximoyield - Quando não há mais
yield, ocorre a exceçãoStopIteration
yield frompermite que um gerador invoque um subgerador ou um objeto iterável, formando uma cadeia de geradores- A característica importante aqui é que a execução da função pode ser interrompida e retomada depois, preservando seu estado
Simplificando a event loop como uma lista
- A event loop é o núcleo do asyncio, responsável por executar e gerenciar as tarefas atuais
- A event loop real do asyncio é escrita em C, mas em um modelo simples ela pode ser vista como uma lista com as tarefas atuais
- No exemplo inicial, as tarefas são tratadas como objetos geradores
- O gerenciador da event loop percorre a lista de tarefas
- Ele chama
next(task)em cada tarefa para executá-la - A tarefa para com
yieldno momento em que precisa esperar, como em operações I/O-bound, e devolve o controle à event loop
- No exemplo em que
task1()etask2()imprimem algo e depois fazemyield, a saída aparece alternadamente- Como as duas funções têm um loop
while True, a execução continua indefinidamente - A saída se repete como
Task 1,Task 2,Task 1,Task 2
- Como as duas funções têm um loop
Criando sleep com yield from
sleep(seconds)é implementado como um gerador que registra o horário inicial e continua fazendoyieldaté que o tempo especificado tenha passado- A função de tarefa delega a execução ao subgerador sleep com algo como
yield from sleep(1)ouyield from sleep(5)- Enquanto
sleepcontinua fazendoyield, a tarefa permanece parada - Quando tempo suficiente passa, o loop
whiledesleeptermina - Como não há mais
yield, ocorreStopIteration, eyield fromcontinua na próxima linha da função da tarefa
- Enquanto
- No exemplo,
task1imprime a cada 1 segundo etask2imprime a cada 5 segundos- A saída fica no formato
Task 1,Task 2, depoisTask 1várias vezes e entãoTask 2novamente
- A saída fica no formato
Trocando yield por await
- Para usar
await, o objeto de destino precisa ter o método__await__ou ser uma coroutine - No asyncio, normalmente trabalha-se com objetos
Taskpor meio de funções comoasyncio.create_task- O objeto
Taskherda deFutureno asyncio - O objeto
Futurepossui o método__await__
- O objeto
- Ao chamar uma função marcada com
async, é criado um objeto coroutine- Assim como uma função geradora, uma coroutine pode pausar a execução e retomá-la depois
awaitpode ser visto como algo parecido comyield from, mas com regras extras de validaçãoawait objectacaba fazendoyielda partir do__await__da instância do objeto, ou aguardando outra coroutine
- No código-fonte de
Futuredo asyncio, também é possível ver que, quandoFutureouTaskainda não terminou,__await__basicamente chamayield
Implementação simples de Task, create_task e run
- A implementação customizada usa uma
Queuecomo event loop em vez de uma lista- A ideia é tratar em tempo constante as operações de adicionar e remover tarefas do loop
- A classe
Taskarmazena o objeto gerador e o estado de conclusãoself.iterguarda o objeto geradorself.finishedcomeça comoFalse- Quando o gerador gera
StopIteration, ele é tratado como concluído done()retorna o estado de conclusão
Task.__await__()continua chamandoyield selfenquanto a tarefa não termina- Esse comportamento devolve o controle à event loop
create_task(generator)envolve o gerador em umTask, coloca a tarefa na fila da event loop e a retorna- Seu papel é agendar a tarefa na event loop
run(main)inicia a event loop em um formato parecido com oasyncio.run()real- O
mainrecebido inicialmente é envolvido em umTaske colocado na fila - Enquanto a fila não estiver vazia, a próxima tarefa é retirada
- A tarefa avança com
task.iter.send(None) - Se ocorrer
StopIteration,task.finished = Trueé definido - Se não houver exceção, a tarefa é recolocada na fila da event loop
- O
- O uso de
task.iter.send(None)no lugar denext(task.iter)se deve às características de trabalhar com as palavras-chaveasync/await, mas aqui cumpre o mesmo papel
sleep compatível com async e exemplo com jacobio
- O
sleepanterior era uma função geradora, masawaitnão pode ser combinado diretamente com uma função geradora- O alvo de
awaitprecisa ser um objeto com__await__ou uma função coroutine
- O alvo de
- A lógica real de espera é movida para o gerador
_sleep(seconds)_sleepfazyieldaté que um certo tempo tenha passado
async def sleep(seconds)cria uma tarefa com_sleep(seconds)e então fazawaitdessa tarefaawait taskchamaTask.__await__()- Se a tarefa ainda não terminou, o controle é devolvido à event loop via
yield
- O arquivo customizado final
jacobio.pyinclui os seguintes elementos- fila da event loop
_sleepasync sleepTaskcreate_taskrun
- No exemplo de uso, o
yield fromexistente é trocado porawait, e funções que usamawaitrecebemasynctask1imprime duas vezes e, em cada vez, esperajacobio.sleep(1)task2imprime três vezes e, em cada vez, esperajacobio.sleep(0)maincria as duas tarefas, espera ambas e depois imprimedone
- A saída do exemplo é
Task 1,Task 2,Task 2,Task 2,Task 1,done
Trocando para o asyncio real
- No exemplo customizado com
jacobio, substituir tudo dejacobioporasynciotransforma o código em um programa que usa o pacote asyncio real - As funções correspondentes são as seguintes
jacobio.sleep()→asyncio.sleep()jacobio.create_task()→asyncio.create_task()jacobio.run()→asyncio.run()
- O asyncio real faz muito mais internamente
- Esse gerenciador de event loop foi implementado da forma mais simples possível, então ele mostra a ideia básica do asyncio, mas difere um pouco do fluxo real do código-fonte por causa da escala e da complexidade do pacote verdadeiro
- Ao usar o asyncio real, em vez de criar duas tarefas separadas e fazer
awaitnas duas, também é possível lidar com várias tarefas com funções comoasyncio.gather() - Como leitura relacionada, o artigo aponta para handling asyncio tasks like a pro
1 comentários
Comentários do Hacker News
O Asyncio permite substituir a implementação por uma implementação criada diretamente do loop de eventos
No Temporal Python, os workflows são expressos como um loop de eventos asyncio durável personalizado, então até algo como
asyncio.sleepse torna um timer durável. Ou seja, o código pode ser retomado em outra máquina, tornando possível até dormir por várias semanasA forma de implementação é explicada neste texto: https://temporal.io/blog/durable-distributed-asyncio-event-l...
O maior problema do asyncio é que, em Python, é fácil parar o sistema inteiro bloqueando a thread do asyncio com uma chamada síncrona. O Python precisa desesperadamente de uma ferramenta de análise estática que monte um grafo de chamadas para detectar se uma chamada que bloqueia a thread está sendo chamada direta ou indiretamente dentro de um
async defOperações básicas como aritmética ou acesso a estruturas de dados poderiam entrar numa lista de permissões para chamadas síncronas rápidas, e se o loop de eventos girasse rápido demais de forma suspeita, talvez desse para detectar outros trabalhos que também deveriam ser síncronos
Quando descobri como eles implementaram o loop de eventos do asyncio, foi realmente um momento de explodir a cabeça
Ainda tento entender se Python é realmente uma linguagem adequada para concorrência. Asyncio sempre pareceu algo no limite, e para mim a implementação assíncrona do C# é mais elegante
Purchaser.purchasenão deveria serdo_purchase?Até um simples
read()pode bloquear ou não, dependendo do descritor e de como ele foi configurado. Como alguém detectaria isso com análise estática?Esta implementação parece fazer espera ocupada durante o sleep. Ou seja, mesmo quando não há nenhuma tarefa executável no momento, o loop de eventos continua rodando
Lembro de ter visto antes outra implementação de brinquedo em que o próximo momento executável das tarefas era rastreado em ordem, e, quando não havia tarefa pronta para rodar, o próprio loop de eventos entrava em sleep. Pelo que entendo, o asyncio real também funciona assim
Depois isso foi estendido para que a próxima condição de execução não fosse apenas o tempo de relógio de parede, mas também dependências de coisas como sockets, permitindo usar
selectcom timeoutUma implementação famosa é o uvloop(https://github.com/MagicStack/uvloop), que basicamente implementa o loop usando libuv, e o libuv cuida das operações como
selectque foram mencionadas[1] https://simpy.readthedocs.io/en/latest/
[2] https://gitlab.com/team-simpy/simpy.io
O loop de eventos nem precisa necessariamente ficar repetindo; você pode simplesmente executar
mainpara começar, e quando ele terminar, tudo termina junto. Pense em iniciar um servidor com um loopwhile trueesperando por sockets e, quando uma condição de saída ou interrupção acontecer, ele termina e o programa também encerraDo ponto de vista do loop de eventos, não há espera ocupada, nem necessidade de mexer com sleep ou sockets. É a diferença entre executar até concluir e executar para sempre
Se eu fosse fazer um loop de brinquedo, acho melhor nem tentar lidar com o caso de execução eterna
A palestra do David Beazley sobre asyncio é excelente
Com base nela, criei uma ferramenta de simulação de eventos discretos. É bem legal poder implementar você mesmo o asyncio e trocar o relógio do sistema por tempo simulado
É um texto realmente excelente, uma explicação de alto nível que pula muito bem as partes que poderiam entediar quem está lendo isso pela primeira vez
Seria ainda melhor se no final adicionasse algum outro material explicando como isso realmente funciona por dentro
Acho que o texto teria sido melhor se mostrasse a mesma coisa sem usar
yieldde jeito nenhum. Sinceramente, essa é a parte que parece realmente mágicaUma explicação muito mais profunda sobre corrotinas em Python está aqui: https://aosabook.org/en/500L/a-web-crawler-with-asyncio-coro...
Não houve nenhuma menção a
poll()? Se não, então isso é completamente diferente de como o asyncio funcionaÉ completamente estranho que o Python tenha interceptado
defpara usá-lo na criação de objetos que na verdade não são funçõesNo mínimo, poderiam ter criado outra palavra-chave
Ela retorna algo como um gerador ou uma corrotina, e a assinatura de tipo pode ser vista aqui [1]. Mesmo sem açúcar sintático no nível da linguagem, seria possível fazer algo parecido em Python puro usando algo como decoradores
[1] https://docs.python.org/3/library/typing.html#typing.Generat...
Dito isso, a assinatura de tipo foi mencionada mais por interesse acadêmico; em anotações de tipo reais,
IterableeAwaitablemais simples são preferidosParece que colocaram a semântica da experiência do desenvolvedor à frente de algo como pureza da linguagem. Nunca fiquei confuso pelo fato de uma função
asyncser uma função assíncronadefpara funções, então não dá para criar funções inline como com arrow functions do JS ou lambdas do C++Isso não é um problema de pureza da linguagem, é só inconveniente