3 pontos por GN⁺ 2024-05-09 | 1 comentários | Compartilhar no WhatsApp
  • 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 yield e então passar para a próxima tarefa
  • sleep pode ser criado como um subgerador que faz yield até o tempo passar, e yield from permite 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, e create_task e run passam a se parecer com a API real do asyncio
  • Ao trocar as chamadas do jacobio por asyncio, 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 range no 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
  • 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óximo yield
    • Quando não há mais yield, ocorre a exceção StopIteration
  • yield from permite 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 yield no momento em que precisa esperar, como em operações I/O-bound, e devolve o controle à event loop
  • No exemplo em que task1() e task2() imprimem algo e depois fazem yield, 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

Criando sleep com yield from

  • sleep(seconds) é implementado como um gerador que registra o horário inicial e continua fazendo yield até 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) ou yield from sleep(5)
    • Enquanto sleep continua fazendo yield, a tarefa permanece parada
    • Quando tempo suficiente passa, o loop while de sleep termina
    • Como não há mais yield, ocorre StopIteration, e yield from continua na próxima linha da função da tarefa
  • No exemplo, task1 imprime a cada 1 segundo e task2 imprime a cada 5 segundos
    • A saída fica no formato Task 1, Task 2, depois Task 1 várias vezes e então Task 2 novamente

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 Task por meio de funções como asyncio.create_task
    • O objeto Task herda de Future no asyncio
    • O objeto Future possui o método __await__
  • 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
  • await pode ser visto como algo parecido com yield from, mas com regras extras de validação
    • await object acaba fazendo yield a partir do __await__ da instância do objeto, ou aguardando outra coroutine
  • No código-fonte de Future do asyncio, também é possível ver que, quando Future ou Task ainda não terminou, __await__ basicamente chama yield

Implementação simples de Task, create_task e run

  • A implementação customizada usa uma Queue como 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 Task armazena o objeto gerador e o estado de conclusão
    • self.iter guarda o objeto gerador
    • self.finished começa como False
    • Quando o gerador gera StopIteration, ele é tratado como concluído
    • done() retorna o estado de conclusão
  • Task.__await__() continua chamando yield self enquanto a tarefa não termina
    • Esse comportamento devolve o controle à event loop
  • create_task(generator) envolve o gerador em um Task, 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 o asyncio.run() real
    • O main recebido inicialmente é envolvido em um Task e 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 uso de task.iter.send(None) no lugar de next(task.iter) se deve às características de trabalhar com as palavras-chave async/await, mas aqui cumpre o mesmo papel

sleep compatível com async e exemplo com jacobio

  • O sleep anterior era uma função geradora, mas await não pode ser combinado diretamente com uma função geradora
    • O alvo de await precisa ser um objeto com __await__ ou uma função coroutine
  • A lógica real de espera é movida para o gerador _sleep(seconds)
    • _sleep faz yield até que um certo tempo tenha passado
  • async def sleep(seconds) cria uma tarefa com _sleep(seconds) e então faz await dessa tarefa
    • await task chama Task.__await__()
    • Se a tarefa ainda não terminou, o controle é devolvido à event loop via yield
  • O arquivo customizado final jacobio.py inclui os seguintes elementos
    • fila da event loop
    • _sleep
    • async sleep
    • Task
    • create_task
    • run
  • No exemplo de uso, o yield from existente é trocado por await, e funções que usam await recebem async
    • task1 imprime duas vezes e, em cada vez, espera jacobio.sleep(1)
    • task2 imprime três vezes e, em cada vez, espera jacobio.sleep(0)
    • main cria as duas tarefas, espera ambas e depois imprime done
  • 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 de jacobio por asyncio transforma 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 await nas duas, também é possível lidar com várias tarefas com funções como asyncio.gather()
  • Como leitura relacionada, o artigo aponta para handling asyncio tasks like a pro

1 comentários

 
GN⁺ 2024-05-09
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.sleep se 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 semanas
    A 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 def

    • Talvez seja uma ideia ruim, mas às vezes penso se não faria mais sentido inverter o modelo atual, em que todas as chamadas são síncronas por padrão
      Operaçõ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
    • O Temporal é realmente incrível
      Quando descobri como eles implementaram o loop de eventos do asyncio, foi realmente um momento de explodir a cabeça
    • Fico me perguntando por que não usam threads
      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
    • No exemplo de workflow, Purchaser.purchase não deveria ser do_purchase?
    • Não deve ser fácil detectar isso estaticamente
      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 select com timeout

    • No próprio asyncio, também é possível usar implementações de loop de eventos personalizado além da implementação padrão
      Uma 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 select que foram mencionadas
    • Isso soa bem parecido com SimPy [1] / Simpy.io [2], mas o SimPy surgiu vários anos antes do asyncio
      [1] https://simpy.readthedocs.io/en/latest/
      [2] https://gitlab.com/team-simpy/simpy.io
    • De um certo ponto de vista, não há exatamente um problema com essa abordagem
      O loop de eventos nem precisa necessariamente ficar repetindo; você pode simplesmente executar main para começar, e quando ele terminar, tudo termina junto. Pense em iniciar um servidor com um loop while true esperando por sockets e, quando uma condição de saída ou interrupção acontecer, ele termina e o programa também encerra
      Do 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
    • Qual seria o melhor livro para aprender esse tipo de coisa?
  • 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 yield de jeito nenhum. Sinceramente, essa é a parte que parece realmente mágica
    Uma 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 def para usá-lo na criação de objetos que na verdade não são funções
    No mínimo, poderiam ter criado outra palavra-chave

    • É sim uma função, só que transformada em uma função com tipo de retorno diferente
      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, Iterable e Awaitable mais simples são preferidos
    • Qual é exatamente o problema? Ou isso é apenas uma discussão sobre pureza da linguagem?
    • Não vejo isso como uma tragédia
      Parece 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 async ser uma função assíncrona
    • Um problema maior do Python é que, para começar, você precisa usar def para 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
    • Você está falando de decoradores? No contexto de Python, é bem confuso entender o que você quer dizer