Se você chegou até aqui provavelmente já passou pelo profiling e encontrou um gargalo. A tentação imediata é jogar async/await em cima do problema e torcer para que o tempo de execução caia. Na maioria das vezes, não cai. Às vezes, piora.
Este artigo começa mostrando exatamente esse cenário — código assíncrono que não resolve nada — e explica por quê. Depois mostra um caso onde asyncio faz diferença real, e só então desce para o mecanismo que explica os dois resultados.
O exemplo que não funciona
Suponha que o profiling revelou que a função abaixo consome 80% do tempo de CPU:
| |
Tempo típico numa máquina moderna: ~18 segundos.
O desenvolvedor lê sobre asyncio e reescreve assim:
| |
Tempo: ~18 segundos. Idêntico. O async/await não mudou nada.
O exemplo que funciona
Agora um cenário diferente: buscar dados de uma API externa para uma lista de IDs.
| |
Tempo típico: ~3,5 segundos (10 requests sequenciais, ~350ms cada).
Versão com asyncio:
| |
Tempo típico: ~0,4 segundos. Quase 9x mais rápido.
A diferença entre os dois cenários é o coração de tudo que vem a seguir.
O que o event loop realmente faz
O CPython tem uma limitação estrutural chamada GIL (Global Interpreter Lock) que impede que múltiplas threads executem bytecode Python simultaneamente. Para processamento CPU-bound, isso significa que threads e corrotinas não ajudam — só um processo por vez avança no cálculo.
asyncio não contorna o GIL. Ele opera num modelo de concorrência cooperativa com um único thread. O mecanismo central é o event loop: um laço que despacha corrotinas, verifica quais estão aguardando operações de I/O, e retoma as que já podem continuar.
O ponto crucial: uma corrotina só libera o controle para o event loop quando encontra um await em uma operação que vai bloquear aguardando recursos externos — resposta de rede, leitura de disco, timer. Enquanto isso não acontece, ela monopoliza o event loop exatamente como código síncrono monopoliza o thread.
No primeiro exemplo, calcular_fibonacci_async nunca encontra um await real. O asyncio.gather agenda as cinco corrotinas, mas cada uma executa de ponta a ponta sem ceder controle. O resultado é idêntico ao código síncrono sequencial, com overhead extra de agendamento.
No segundo exemplo, cada session.get() dispara uma conexão TCP e imediatamente suspende a corrotina aguardando a resposta. O event loop retoma as demais. Dez conexões ficam abertas em paralelo — do ponto de vista da rede — e as respostas chegam aproximadamente ao mesmo tempo.
Diagrama de execução
Síncrono:
Thread único
│
├── request 1 ──────── aguarda 350ms ──────── resposta
├── request 2 ──────────────────────────────── aguarda 350ms ── resposta
├── ...
└── request 10 ────────────────────────────────────────────────── ...
Total: ~3500ms
Assíncrono com asyncio:
Event loop (thread único)
│
├── inicia request 1 ──┐
├── inicia request 2 ──┤
├── inicia request 3 ──┤ todos aguardando em paralelo
├── ... │
└── inicia request 10 ─┘
│
└── respostas chegam ~ao mesmo tempo
Total: ~400ms (tempo do request mais lento)
Anatomia de uma corrotina
| |
async def transforma uma função em uma função corrotina — uma factory que retorna um objeto corrotina quando chamada. O objeto corrotina só executa quando entregue ao event loop via asyncio.run(), await, ou asyncio.create_task().
await tem duas funções: suspende a corrotina atual enquanto aguarda o resultado de outra corrotina (ou qualquer objeto awaitable), e devolve o controle ao event loop para que outras corrotinas possam progredir.
asyncio.gather vs asyncio.create_task
Há duas formas principais de executar múltiplas corrotinas concorrentemente, e elas têm semânticas distintas.
asyncio.gather
| |
gather retorna os resultados na mesma ordem dos argumentos, independente de qual terminou primeiro. Por padrão, se uma corrotina lança exceção, as demais são canceladas. Para comportamento diferente, use return_exceptions=True.
asyncio.create_task
| |
Atenção: fazer
await tarefa_aseguido deawait tarefa_bem sequência não cancela a concorrência entre as tasks — elas continuam rodando em paralelo no event loop — mas força o código a esperar A terminar antes de processar o resultado de B. Se B terminar primeiro, o resultado fica parado esperando.
Para processar os resultados à medida que chegam, use asyncio.as_completed:
| |
create_task é mais flexível: permite cancelar tarefas individualmente, verificar se completaram, ou aguardar com timeout. Em geral, use gather quando você tem um conjunto fixo de corrotinas e quer todos os resultados; use create_task quando precisa de controle granular sobre cada tarefa.
O problema do bloqueio acidental
O erro mais comum em código asyncio de produção não é usar async/await onde não deveria — é misturar código bloqueante dentro de corrotinas sem perceber.
| |
time.sleep é uma chamada bloqueante de sistema. Quando executada numa corrotina, trava o event loop inteiro pelo tempo do sleep — nenhuma outra corrotina avança. A versão correta usa asyncio.sleep:
| |
O mesmo problema ocorre com qualquer operação bloqueante: leitura de arquivo com open(), queries com drivers síncronos como psycopg2, requests HTTP com requests. Para cada uma há uma alternativa assíncrona: aiofiles, asyncpg/databases, aiohttp.
Quando você precisa de código bloqueante
Às vezes não há alternativa assíncrona disponível, ou o custo de migrar não se justifica. Nesses casos, use loop.run_in_executor para executar o código bloqueante numa thread pool sem travar o event loop:
| |
run_in_executor retorna um objeto awaitable que o event loop aguarda enquanto a função executa numa thread separada. As threads podem rodar em paralelo porque operações de I/O não precisam do GIL.
CPU-bound: a solução correta é multiprocessing
Voltando ao exemplo do Fibonacci: se o problema é CPU-bound, a resposta não é asyncio nem threads — é multiprocessing, que cria processos separados, cada um com seu próprio GIL.
| |
Numa máquina com 4+ núcleos, o tempo cai de ~18s para ~5s. Os cinco cálculos rodam em paralelo real em processos separados.
Se precisar combinar CPU-bound com código assíncrono, asyncio tem integração direta com ProcessPoolExecutor via run_in_executor:
| |
Timeout e cancelamento
Em produção, toda operação de I/O precisa de timeout. Sem isso, uma conexão que nunca responde trava a corrotina indefinidamente.
| |
asyncio.timeout foi adicionado no Python 3.11 e é a forma recomendada. Em versões anteriores, use asyncio.wait_for(coro, timeout=5.0).
Padrão produtor-consumidor com filas
Para processar um volume arbitrário de itens com controle de concorrência, filas assíncronas são a solução idiomática:
| |
maxsize=5 garante que o produtor não enfileira infinitamente se os workers não estão acompanhando. task_done() permite usar fila.join() como barreira de sincronização alternativa.
Resumo: qual ferramenta usar
| Problema | Ferramenta |
|---|---|
| Múltiplas requisições HTTP em paralelo | asyncio + aiohttp |
| Queries em banco de dados concorrentes | asyncio + asyncpg ou databases |
| Cálculos pesados em CPU | multiprocessing / ProcessPoolExecutor |
| Biblioteca bloqueante sem alternativa async | loop.run_in_executor com ThreadPoolExecutor |
| Processamento de arquivos grandes | Generators (próximo artigo) |
A pergunta que precisa ser feita antes de qualquer refatoração: o gargalo é I/O ou CPU? Se for I/O — requisições de rede, queries, leitura de disco — asyncio é a ferramenta certa. Se for CPU — parsing intensivo, criptografia, computação numérica — asyncio não ajuda e multiprocessing é o caminho.
O profiling com cProfile e memory_profiler já dá essa resposta. Se as funções no topo do relatório são chamadas de sistema de rede ou banco de dados, o gargalo é I/O. Se são funções Python puras com alta contagem de chamadas recursivas ou loops numéricos, é CPU.
Esse assunto tem muitas arestas — asyncio com frameworks web como FastAPI e Starlette, integração com ORMs assíncronos, debugging de deadlocks em event loops. Se quiser continuar a conversa, me encontra no Fediverse em @riverfount@bolha.us.
