O que acontece quando você esquece de agendar uma coroutine no loop de eventos
Você escreve uma função async, chama ela em outro lugar, e nada acontece. O código simplesmente pula para a próxima linha como se aquela corrotina nunca tivesse existido. Isso é o problema mais comum que vejo em projetos Python usando asyncio, e a solução é o conceito de tarefa do maternal — uma abstração que transforma uma coroutine em algo que o event loop realmente executa. A diferença entre simplesmente chamar uma função assíncrona e agendá-la como tarefa é brutal. Quando você faz await minha_corrotina(), o controle para ali até a coroutine terminar. Quando você cria uma tarefa com asyncio.create_task(), a coroutine ganha vida própria no loop de eventos e continua executando em paralelo. A tarefa do maternal é esse mecanismo de agendamento que permite concorrência real sem threads.
Um detalhe que poucos entendem direito: criar uma tarefa não inicia a execução da coroutine no momento da criação. Ela é agendada para rodar na próxima iteração do event loop. Isso significa que se você cria múltiplas tarefas dentro de um loop for antes de qualquer await, todas elas começam quase simultaneamente, mas a ordem de execução depende de quem libera o loop primeiro. Já vi isso causar condições de corrida sutis em processos ETL onde a ordem de processamento era assumida implicitamente e quebrava a integridade dos dados.
tarefa do maternal: criação, cancelamento e tratamento de exceções
A forma padrão de criar é com asyncio.create_task(coro). Ela retorna um objeto asyncio.Task que você pode monitorar. O retorno é imediato, diferente de loop.create_task() que exige acesso explícito ao loop e é a forma antiga que ainda funciona mas não deveria ser usada em código novo. Aqui vai um caso específico que me custou duas horas de debug num projetoreal: eu estava criando tarefas para processar requisições HTTP concorrentes e uma delas lançava uma exceção silenciosa porque eu não estava aguardando o resultado. O Python emitia um warning de "Task exception was never retrieved", mas como eu estava rodando em produção com log nivelado, o aviso passava despercebido. A solução foi adicionar task.add_done_callback(lambda t: t.exception()) em cada tarefa, forçando a exceção a ser consumida. Sem isso, oGC coleta a tarefa e a exceção some, mas o comportamento pode variar entre versões do Python.
Para cancelamento, task.cancel() lança CancelledError dentro da coroutine. O problema é que se o código dentro dela não capturar essa exceção nos pontos apropriados de await, a tarefa morre de forma abrupta e recursos como conexões de banco ou arquivos abertos podem não ser liberados corretamente. Eu resolvi isso definindo um wrapper que sempre captura CancelledError, fecha os recursos necessários, e re-lança a exceção. Esse padrão de cleanup tornou-se rotina no meu código. Outro ponto importante: task.result() bloqueia até a tarefa terminar. Se você chamar isso sem verificar primeiro se a tarefa já finalizou, pode criar um deadlock disfarçado no seu fluxo assíncrono. Use asyncio.wait() ou asyncio.gather() para controlar melhor o ciclo de vida de múltiplas tarefas.
👉 Clique no botão abaixo para saber mais sobre o assunto!
Erros comuns que parecem mágica negra
O primeiro erro é achar que funções normais (não-assíncronas) podem esperar por tarefas sem travar o event loop. Se você chama uma coroutine dentro de uma função sync usando await, o código nem compila. A solução óbvia é tornar a função chamadora também async, mas isso cria um efeito dominó que se espalha por toda a camada de chamada, e muita gente trava aí porque não quer refatorar tudo. O segundo erro é subestimar o uso de memória. Cada tarefa do maternal mantém um frame de execução completo na memória. Em cenários onde você cria milhares de tarefas concorrentes — digamos, processar um arquivo CSV com 50 mil linhas e spawnar uma tarefa por linha — o uso de memória explode porque cada frame contém variáveis locais, ponteiros de retorno e o estado da coroutine. Eu já vi um processo consumir 4GB de RAM num cenário desses que facilmente poderia ser resolvido com asyncio.Semaphore limitando a 100 tarefas simultâneas.
O terceiro erro, e o mais perigoso, é a falta de tratamento de exceções nas tasks. Quando uma task falha e ninguém retrieve o resultado, o event loop logging apenas avisa e continua. Para projetos pequenos isso é irritante. Para sistemas de produção, especialmente os que dependem de filas de mensagens ou processos críticos, pode significar perda silenciosa de dados. Sempre use asyncio.gather(*tasks, return_exceptions=True) quando precisar executar várias tarefas e tratar erros coletivamente. O parâmetro return_exceptions=True faz com que exceções sejam retornadas como valores normais na lista de resultados em vez de propagadas imediatamente.
Quando a tarefa do maternal não é a resposta certa
Asyncio e suas tasks brilham em I/O bound — muitas operações de rede, arquivos, bancos de dados. Mas se o seu gargalo é processamento intensivo de CPU, como processamento de imagem ou cálculos numéricos pesados, tarefas assíncronas não vão ajudar. O event loop fica bloqueado esperando o processamento terminar porque não há await suficientes para yield. Nesse caso, concurrent.futures.ProcessPoolExecutor ou ThreadPoolExecutor são mais adequados. Às vezes o melhor é misturar: usar asyncio para a coordenação das requisições de rede e delegar o processamento pesado para threads ou processos separados. Também não recomendo asyncio puro para sistemas que precisam de tempo real estrito ou latência extremamente previsível. O event loop é único e qualquer operação lenta, mesmo dentro de uma coroutine bem escrita, bloqueia tudo. Para esses casos, soluções como Rust com Tokio ou Go com goroutines oferecem muito mais controle sobre scheduler e priorização.
Se você está começando agora, a curva de aprendizado é mais pronunciada do que parece. A mentalidade assíncrona exige repensar como o fluxo de controle funciona. Coisas que antes eram lineares viram grafos de dependências entre tarefas. Leva tempo para desenvolver intuição sobre quando spawnar uma task, quando esperar, e quando simplesmente usar uma função normal. A prática constante com exemplos pequenos ajuda, mas o melhor método é revisar o código de bibliotecas consolidadas como aiohttp e asyncpg para ver como profissionais estruturam o ciclo de vida das tasks.