Chapter 47, Advanced
asyncio
The `asyncio` runs thousands of tasks that wait on **a single thread**, switching between them at the points where each one needs to wait. It is the right tool for a lot of connections and little computation.
Code for this chapter: avancado/cap47_assincrono.py
Coroutines and the event loop
A function async def creates a coroutine. Calling it does not execute anything: it returns an object that needs to be scheduled. The await marks the point where the coroutine yields control to the event loop so it can run another one. The asyncio.run creates the loop and runs the main coroutine:
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
Concurrency is cooperative: there is only task switching in a await. This makes the code easier to reason about than with threads (there are no two pieces running at the same time), but it imposes a strict rule: never block the loop.
Multiple tasks at the same time
The asyncio.gather schedules multiple coroutines together and returns the results in the order you passed them. Three waits of 0.3, 0.1, and 0.2 seconds take about 0.3 in total, not 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']
When you want to handle each result as it arrives, 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 and timeout
Since Python 3.11, the TaskGroup is the recommended way to group tasks: if one fails, the others are canceled, and the block only ends when all finish. The asyncio.timeout imposes a deadline on a section:
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
When multiple tasks in the group fail at the same time, the error arrives as a ExceptionGroup, and the except* (also from 3.11) handles each type separately:
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']
Limit concurrency
Firing ten thousand requests at once crashes the server on the other side (and yours). The Semaphore limits how many tasks pass through the region at the same time:
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
The most expensive error: blocking the loop
A blocking call inside a coroutine (time.sleep, requests.get, a synchronous query) blocks all tasks while it runs. When there is no asynchronous alternative, send the call to a thread with 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 or asyncio
I choose
asynciowhen there are many simultaneous connections (thousands) and the libraries I need have asynchronous versions (httpx, aiohttp, async database drivers). For a few dozen blocking calls,ThreadPoolExecutorit is simpler and sufficient. Mixing both worlds is costly in complexity, so prefer one per service.
Exercise 1
Fetch several with a limit
Write buscar_todos(nomes, limite=2) that uses Semaphore and return a dictionary nome: resultado.