Causas da queda de desempenho em código assíncrono e como resolver (resumo técnico)
Este vídeo aborda as causas mais comuns de o código asyncio em Python ficar mais lento do que o código síncrono e as abordagens técnicas para corrigir isso.
1. Conceitos centrais do Asyncio
- Loop de eventos (Event Loop): é o núcleo de toda aplicação assíncrona. Ele é iniciado com
asyncio.run()e gerencia e agenda a execução de tarefas em uma única thread. - Corrotinas (Coroutines): são funções assíncronas declaradas com
async def. Ao encontrar a palavra-chaveawait, podem pausar a execução e devolver o controle ao loop de eventos. - Tarefas (Tasks): encapsulam corrotinas e as agendam para execução concorrente no loop de eventos. São criadas com
asyncio.create_task(). - Futures: são objetos de baixo nível que representam o resultado final de uma operação assíncrona.
2. Exemplo de conversão de código síncrono para assíncrono
Substitui-se o time.sleep() síncrono por await asyncio.sleep() assíncrono, declara-se a função com async def e executa-se a corrotina principal com asyncio.run().
Erros comuns que causam queda de desempenho e suas soluções
Erro 1: execução sequencial (Sequential Execution)
Se tarefas independentes forem aguardadas com await de forma sequencial em vez de executadas em paralelo, o tempo total de execução será a soma do tempo de todas as tarefas.
-
Exemplo incorreto (sequencial):
# Cada await espera o término da operação anterior await get_user_notifications() await get_recent_activity() await get_unread_messages() -
Solução (paralelo): use
asyncio.gatherouasyncio.TaskGrouppara executar tarefas independentes ao mesmo tempo. O tempo total cai para o da tarefa mais demorada.# As três operações começam ao mesmo tempo await asyncio.gather( get_user_notifications(), get_recent_activity(), get_unread_messages() )
Comparação entre ferramentas de execução paralela
asyncio.gather:- Executa várias corrotinas ao mesmo tempo.
- Desvantagem: o tratamento de erros é limitado. Se uma tarefa gerar exceção, outras tarefas em execução podem ser canceladas.
asyncio.create_task:- Permite controle e tratamento de erro por tarefa.
- É útil para execução em segundo plano, mas traz o incômodo de precisar fazer
awaitde várias tarefas individualmente.
asyncio.TaskGroup(Python 3.11+):- É a alternativa moderna para “concorrência estruturada”.
- Usa a sintaxe
async withpara gerenciar um grupo de tarefas, garantindo que ao sair do contexto todas as tarefas tenham sido concluídas ou tratado exceções.
async with asyncio.TaskGroup() as tg: tg.create_task(some_coro_1()) tg.create_task(some_coro_2()) # Quando o bloco 'async with' termina, todas as tarefas já foram aguardadas
Erro 2: uso de bibliotecas síncronas
Se você usar bibliotecas síncronas (bloqueantes), como requests ou pathlib, dentro de código asyncio, todo o loop de eventos fica bloqueado. Mesmo dentro de asyncio.gather, na prática o comportamento será sequencial.
- Solução: use bibliotecas dedicadas com suporte assíncrono (não bloqueante), como
aiohttp(no lugar de requests) eaiofiles(no lugar de files/pathlib).
Erro 3: bloqueio do loop de eventos por trabalho CPU-bound
Como o asyncio roda em uma única thread, cálculos pesados (CPU-bound) param o loop de eventos e atrasam outras operações de I/O.
- Solução: use
loop.run_in_executor()para descarregar trabalho CPU-bound em um pool de threads separado (padrão) ou em um pool de processos.loop = asyncio.get_running_loop() # Executa a função intensiva em CPU em uma thread separada await loop.run_in_executor( None, # Usa o pool de threads padrão cpu_bound_function, arg1 )
Erro 4: bloqueio causado por trabalho não essencial
Se você fizer await em tarefas não essenciais, como logging sem relação com a resposta ao usuário, o tempo de resposta aumenta sem necessidade.
- Solução: use
asyncio.create_task()para separar esse trabalho como tarefa em segundo plano e não façaawait.user_profile = await get_user_profile() # Executa o logging em segundo plano sem await asyncio.create_task(send_logs_to_external_service()) return user_profile
Erro 5: criação de tarefas em excesso
Transformar uma grande quantidade de operações muito pequenas em tarefas pode gerar overhead de troca de contexto e piorar o desempenho.
- Solução 1: agrupe operações pequenas em lotes (batching) para formar menos tarefas e mais robustas.
- Solução 2: use
asyncio.Semaphorepara limitar o número máximo de tarefas executadas ao mesmo tempo.# Permite no máximo 10 tarefas simultâneas semaphore = asyncio.Semaphore(10) async with semaphore: await fetch_data()
Outros erros
- Corrotinas “Never Awaited”: você chama a corrotina mas não faz
await, então a tarefa nem chega a executar e falha silenciosamente. Isso pode ser detectado com linters comoflake8-async. - Gerenciamento inadequado de recursos: usar arquivos, conexões de banco de dados etc. sem
try...finallypode causar vazamento de recursos. A solução é usar gerenciadores de contexto assíncronos comasync with.
Depuração e escolha do modelo de concorrência
Modo de depuração do Asyncio
Ao ativar o modo de depuração, que vem desativado por padrão (asyncio.run(debug=True)), fica mais fácil detectar problemas como:
- corrotinas sem
await(RuntimeWarning). - APIs assíncronas chamadas da thread errada.
- callbacks com tempo de execução acima de 100ms.
- operações lentas do seletor de I/O (selector).
Outras ferramentas de depuração
- Scalene: profiler de CPU e memória.
- aio-monitor: monitoramento e CLI para aplicações
asyncio. - pdb: depurador padrão do Python.
- py-stack: imprime stack traces de processos Python em execução para detectar pontos de bloqueio.
Guia para escolher o modelo de concorrência
- Asyncio (thread única): ideal para grande volume de tarefas I/O-bound com alta latência, como requisições de rede e I/O de arquivos.
- Threads (multithread): usadas para tarefas I/O-bound que precisam de acesso a dados compartilhados. Por causa do GIL (Global Interpreter Lock), não há paralelismo real, mas outras threads podem rodar enquanto uma espera por I/O.
- Processes (multiprocessos): usados para tarefas CPU-bound, como processamento de imagem e cálculos pesados. Aproveitam vários núcleos de CPU para obter paralelismo real, mas têm alto overhead de memória e comunicação.
12 comentários
Python é de fato uma excelente linguagem, mas a interface assíncrona parece ser um recurso mal projetado.
Faltou o
eager_start=Trueno item 4. Comocreate_taskcria uma weakref, esse código pode acabar gerando uma tarefa que nunca será executada...> https://rosettalens.com/s/ko/python-to-node
Parece que essa pessoa também migrou para o Node.js por causa do
asyncdo PythonConclusão: a interface assíncrona do Python ainda não é intuitiva.
Na verdade, se o projeto já está em um nível em que vale a pena otimizar a programação assíncrona em Python, em termos de desempenho e estabilidade é muito melhor escrevê-lo em outra linguagem.
Se não for para uma linguagem compilada, a diferença de desempenho é tão grande assim? Em multithreading até faria sentido, já que a existência do GIL cria uma diferença grande, mas se no fim das contas for uma estrutura assíncrona em que o event loop está rodando, fico curioso sobre que tipo de diferença surge dependendo da linguagem.
A presença ou ausência de compilação JIT faz mais diferença do que se imagina. O V8 é muito bem otimizado.
Não conferi o vídeo de origem, mas o código de solução para o erro 4 está incorreto.
A instância de task retornada por
create_task()precisa ser atribuída a pelo menos uma variável, e essa variável deve continuar existindo até a task terminar. Caso contrário, há o risco de a instância da task ser coletada pelo garbage collector enquanto a coroutine ainda está em execução.Se a função que cria a task terminar logo em seguida, como no caso acima, é preciso usar métodos como retornar a instância da task, atribuí-la a uma variável global ou a uma variável de instância.
P.S.)
Mesmo que você realmente não precise do valor de retorno e tenha certeza de que a coroutine vai terminar em pouco tempo, ainda assim é melhor programar de forma que em algum momento seja feito um
awaitsobre a instância da task. Se não quiser fazer isso, então o ideal é montar uma estrutura em que cada coroutine que vai rodar como task tenha um tratamento de exceções bem rigoroso, com mensagens de log emitidas sem falhas. Caso contrário, pode acontecer de, por maior que seja o problema causado pela task, a Exception não ser tratada e falhar silenciosamente.Num projeto que eu desenvolvo e administro profissionalmente, já cheguei a desenhar um padrão em que dezenas de módulos criavam cada um uma task no estilo
while self.ok(): cmd = await self.cmd_queue.get(); await self.process(cmd);e a mantinham rodando continuamente. Até estabelecer um padrão de tratamento de exceções, cada problema que aparecia vinha acompanhado de uma experiência rara de ver minha sanidade ir junto pelos ares, hahaMesmo do ponto de vista de alguém que trabalha em uma empresa que usa C#, que pode ser considerado o precursor(?) do padrão Async/Await, ainda vejo com bastante frequência código incorreto como o erro nº 1, em que
awaité simplesmente colocado em sequência, um atrás do outro.Quando vejo esse tipo de código, tenho a impressão de que, em comum, a pessoa só sabe que precisa usar a palavra-chave
awaitantes de chamar um métodoasync, mas não pensa muito além disso sobre a ordem de execução assíncrona, e é por isso que esse tipo de código aparece.Quando há vários
await, alguns resultados são usados logo na linha de baixo, então faz sentido receber antes o resultado doawaitdo objetoTask<T>; outros só serão usados bem mais adiante, então dá para receber apenas oTask<T>e fazerawaitdepois. Escrever código levando em conta esse fluxo assíncrono exige justamente esse nível de raciocínio.Pelo menos eu costumo escrever métodos declarados como assíncronos considerando esse fluxo de processamento, mas às vezes, ao ver código legado de alguém que saiu da empresa e eu preciso manter, tenho a sensação de estar lendo algo como: “Eu só queria escrever código síncrono de forma simples, mas como o método que preciso usar no meio do caminho só existe em versão assíncrona, vou escrever assim mesmo.”
Se o item 1 for sempre independente, fazer desse jeito até que é bom,
mas se o código for alterado e deixar de ser independente, parece que também há o incômodo de ter que revisar e corrigir todos os lugares que usam essa função.
Se for uma tarefa que não leva tanto tempo assim, talvez fazer
awaitem série seja melhor do ponto de vista de manutenção do código.Acho que faz sentido abordar isso com a ideia de que “como o overhead do multithreading é pesado, a alternativa é dividir uma única thread para resolver o processamento paralelo”. Por isso, no fim das contas, parece correto dizer que, por padrão, dependendo do caso, isso exige ainda mais cuidado do que o multithreading.
Isso também é verdade.
Parece que um código assíncrono bem feito é, por natureza, o tipo de código que exige muita atenção.