Capítulo 47, Avançado
asyncio
O `asyncio` roda milhares de tarefas que esperam em **uma única thread**, alternando entre elas nos pontos em que cada uma precisa esperar. É a ferramenta certa para muita conexão e pouco cálculo.
Código deste capítulo: avancado/cap47_assincrono.py
Corrotinas e o laço de eventos
Uma função async def cria uma corrotina. Chamá-la não executa nada: devolve um objeto que precisa ser agendado. O await marca o ponto em que a corrotina cede o controle para o laço de eventos poder rodar outra. O asyncio.run cria o laço e executa a corrotina principal:
import asyncio
async def buscar(nome, atraso):
await asyncio.sleep(atraso)
return f"{nome} pronto"
async def principal():
print(await buscar("a", 0.1))
asyncio.run(principal())
a pronto
A concorrência é cooperativa: só existe troca de tarefa em um await. Isso torna o código mais fácil de raciocinar do que com threads (não há dois trechos rodando ao mesmo tempo), mas cobra uma regra rígida: nunca bloqueie o laço.
Várias tarefas ao mesmo tempo
O asyncio.gather agenda várias corrotinas juntas e devolve os resultados na ordem em que você as passou. Três esperas de 0,3, 0,1 e 0,2 segundo levam cerca de 0,3 no total, e não 0,6:
async def varios():
resultados = await asyncio.gather(
buscar("lento", 0.3),
buscar("rápido", 0.1),
buscar("médio", 0.2),
)
print(resultados)
asyncio.run(varios())
['lento pronto', 'rápido pronto', 'médio pronto']
Quando você quer tratar cada resultado conforme ele chega, use as_completed:
async def por_ordem_de_chegada():
tarefas = [buscar("lento", 0.3), buscar("rápido", 0.1), buscar("médio", 0.2)]
for futura in asyncio.as_completed(tarefas):
print(await futura)
asyncio.run(por_ordem_de_chegada())
rápido pronto
médio pronto
lento pronto
TaskGroup e tempo limite
Desde o Python 3.11, o TaskGroup é a forma recomendada de agrupar tarefas: se uma falha, as outras são canceladas, e o bloco só termina quando todas acabam. O asyncio.timeout impõe um prazo a um trecho:
async def com_grupo():
async with asyncio.TaskGroup() as grupo:
t1 = grupo.create_task(buscar("x", 0.1))
t2 = grupo.create_task(buscar("y", 0.2))
print(t1.result(), t2.result())
async def com_timeout():
try:
async with asyncio.timeout(0.1):
await buscar("demorado", 1)
except TimeoutError:
print("estourou o tempo")
asyncio.run(com_grupo())
asyncio.run(com_timeout())
x pronto y pronto
estourou o tempo
Quando várias tarefas do grupo falham ao mesmo tempo, o erro chega como um ExceptionGroup, e o except* (também do 3.11) trata cada tipo separadamente:
async def falha(nome):
await asyncio.sleep(0.05)
raise ValueError(nome)
async def grupo_com_erros():
try:
async with asyncio.TaskGroup() as grupo:
grupo.create_task(falha("a"))
grupo.create_task(falha("b"))
except* ValueError as grupo_de_erros:
print(sorted(str(e) for e in grupo_de_erros.exceptions))
asyncio.run(grupo_com_erros())
['a', 'b']
Limitar a concorrência
Disparar dez mil requisições de uma vez derruba o servidor do outro lado (e o seu). O Semaphore limita quantas tarefas passam pela região ao mesmo tempo:
async def limitado():
limite = asyncio.Semaphore(2)
ativos = 0
maximo = 0
async def trabalho(i):
nonlocal ativos, maximo
async with limite:
ativos += 1
maximo = max(maximo, ativos)
await asyncio.sleep(0.05)
ativos -= 1
await asyncio.gather(*(trabalho(i) for i in range(6)))
print("máximo simultâneo:", maximo)
asyncio.run(limitado())
máximo simultâneo: 2
O erro mais caro: bloquear o laço
Uma chamada bloqueante dentro de uma corrotina (time.sleep, requests.get, uma consulta síncrona) para todas as tarefas enquanto ela roda. Quando não há alternativa assíncrona, mande a chamada para uma thread com asyncio.to_thread:
import time
def pesado():
time.sleep(0.2)
return "terminou"
async def sem_bloquear():
resultado, _ = await asyncio.gather(asyncio.to_thread(pesado), asyncio.sleep(0.05))
print(resultado)
asyncio.run(sem_bloquear())
terminou
Threads ou asyncio
Eu escolho
asyncioquando há muitas conexões simultâneas (milhares) e as bibliotecas que preciso têm versão assíncrona (httpx, aiohttp, drivers async de banco). Para poucas dezenas de chamadas bloqueantes,ThreadPoolExecutoré mais simples e basta. Misturar os dois mundos custa caro em complexidade, então prefira um só por serviço.
Exercício 1
Buscar vários com um limite
Escreva buscar_todos(nomes, limite=2) que use Semaphore e devolva um dicionário nome: resultado.