🐍 Async en Python
Event loop, coroutines con async y await, asyncio.gather, threading vs asyncio vs multiprocessing y APIs async reales con FastAPI y httpx.
Async en Python
Python es síncrono por diseño: cada línea bloquea hasta terminar. Eso es un problema para el backend, donde el 90 % del tiempo una request está esperando a una base de datos, una API externa o un file system. Async permite aprovechar ese tiempo de espera para atender otras requests. Este artículo te enseña el modelo mental, no solo la sintaxis.
El problema: I/O bloqueante
Cuando tu código hace I/O (red, disco, red de base de datos), el proceso se queda bloqueado esperando la respuesta del sistema operativo:
T1: GET /api/weather → HTTP a servicio externo (200 ms de espera)
T2: GET /api/weather → HTTP a servicio externo (200 ms de espera)
T3: ... → mientras, el CPU no hace NADA
Con un server síncrono, cada request “ocupa” un thread del SO (~1 MB de pila + overhead). Llegas a pocos miles de conexiones concurrentes antes de agotar recursos. Y el peor caso: un servicio lento que tarda 30 s hace que todas las demás esperas se acumulen.
threading vs asyncio vs multiprocessing
La tabla que siempre conviene tener a mano:
| Enfoque | Unidad | Cuándo ayuda | Cuándo NO |
|---|---|---|---|
| Threading | threads del SO (GIL) | I/O con librerías bloqueantes que ya no puedes cambiar | CPU-bound (el GIL limita a 1 núcleo) |
| Asyncio | coroutines en un solo thread | I/O intensivo y controlable con async/await |
CPU-bound puro, librerías sin soporte async |
| Multiprocessing | procesos separados | CPU-bound (rompe el GIL con procesos reales) | mucha comunicación entre procesos, overhead de fork |
⚠️ El GIL (Global Interpreter Lock) hace que solo un thread ejecute bytecode Python a la vez. Los threads sí ayudan al I/O porque la espera del OS libera el GIL, pero nunca obtienes paralelismo real de CPU con threads.
¿Es CPU-bound?
┌───────────────┐
│ sí │
┌───────────────┤ ├───────────────┐
│ └───────┬───────┘ │
│ │ │
│ multiprocessing │
│ (varios núcleos) │
│ │
¿Puedes escribir código async? │
┌───────────────┐ │
│ sí │ │
│ asyncio │ ¿no (lib bloqueante)? │
└───────┬───────┘ │
│ threading │
asyncio: el event loop
asyncio da la ilusión de concurrencia en un solo thread mediante un event loop: una cola de tareas que se van ejecutando y “cediendo” (await) cuando esperan I/O.
import asyncio
import time
async def tarea(nombre, segundos):
print(f"{nombre}: empiezo")
await asyncio.sleep(segundos) # cede el control al loop
print(f"{nombre}: termino")
async def main():
t0 = time.perf_counter()
# lanzamos las tres "a la vez"
await asyncio.gather(
tarea("A", 2),
tarea("B", 2),
tarea("C", 2),
)
print(f"total: {time.perf_counter() - t0:.1f} s")
asyncio.run(main())
A: empiezo
B: empiezo
C: empiezo
A: termino
B: termino
C: termino
total: 2.0 s # no 6 s: se solaparon
Coroutines, tasks y el papel de await
- Una coroutine es una función
async def. Llamarla no ejecuta nada: devuelve un objeto coroutine que hay que agendar. - Un Task envuelve una coroutine para que el loop la ejecute en paralelo a otras (
asyncio.create_task). awaitle dice al loop: puedo esperar, atiende a los demás.
async def main():
# create_task: lanza en segundo plano, no la esperas aún
t1 = asyncio.create_task(descargar("a.jpg"))
t2 = asyncio.create_task(descargar("b.jpg"))
# gather: espera todas y recoge resultados
resultados = await asyncio.gather(t1, t2)
💡
asyncio.sleepes el “placeholder” del I/O real. Sustitúyelo porawait httpx.AsyncClient().get(...)oawait cursor.execute(...)(drivers async) y el modelo es idéntico.
requests vs httpx: async de verdad
requests es síncrono y bloquea el event loop si lo llamas dentro de una coroutine: mientras esperas una respuesta, nadie más avanza. La opción async es httpx:
import asyncio
import httpx
async def obtener(url):
async with httpx.AsyncClient() as client:
r = await client.get(url, timeout=10)
return r.json()
async def main():
urls = ["https://api.github.com/repos/python/cpython",
"https://api.github.com/repos/pallets/flask"]
resultados = await asyncio.gather(*(obtener(u) for u in urls))
for r in resultados:
print(r["full_name"], "★", r["stargazers_count"])
asyncio.run(main())
⚠️ Mezclar síncrono y async es el error nº1: un
requests.get()dentro deasync defcongela todas las coroutines del loop. Si no hay versión async de tu librería, usaasyncio.to_thread(...)para mandarlo a un thread pool.
import asyncio, requests
async def main():
# librería síncrona que no puedes cambiar → fuera del loop
data = await asyncio.to_thread(requests.get, "https://api.example.com/x")
async vs sync en FastAPI
FastAPI distingue def (síncrona: la corre en un thread pool) de async def (la corre en el event loop). Es la diferencia clave para el rendimiento:
| Endpoint | Ejecución | Cuándo elegir |
|---|---|---|
def |
Thread pool (varios threads) | I/O con librerías bloqueantes (SQLAlchemy síncrono, requests) |
async def |
Event loop (un thread) | I/O async (httpx, drivers async, DB async) |
from fastapi import FastAPI, HTTPException
import httpx
app = FastAPI()
# MAL: requests dentro de async def bloquea el loop
@app.get("/mal")
async def mal():
import requests
r = requests.get("https://api.example.com") # congela todo
return r.json()
# BIEN: httpx async cede el loop
@app.get("/bien")
async def bien():
async with httpx.AsyncClient() as client:
r = await client.get("https://api.example.com")
return r.json()
💡 Regla práctica: si tu endpoint usa solo librerías async (asyncpg, httpx, aiomysql), usa
async def. Si usas ORM síncrono (Django ORM, SQLAlchemy sin async), usadefy deja que FastAPI haga el thread pool. Lo peor es mezclar: síncrono dentro deasync def.
I/O intensivo vs CPU intensivo
- I/O intensivo (HTTP, DB, archivos): se resuelve con asyncio. Un server async sirve miles de conexiones con un puñado de threads.
- CPU intensivo (procesado de imágenes, hashing, cálculos): asyncio no ayuda — una operación CPU-bound no cede el loop hasta terminar. Aquí necesitas procesos:
import asyncio
from concurrent.futures import ProcessPoolExecutor
def heavy(n): # CPU-bound, sin async
return sum(i * i for i in range(n))
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor(max_workers=4) as pool:
resultados = await loop.run_in_executor(pool, heavy, 10_000_000)
print(resultados)
⚠️ Asyncio no es paralelismo: es concurrencia por multiplexado de esperas. Si haces un bucle pesado sin
await, bloqueas el loop y toda la API. Para CPU-bound,ProcessPoolExecutoromultiprocessing.
API async de ejemplo: FastAPI + httpx + gather
Un servicio que agrega resultados de varias APIs externas con un solo endpoint:
import asyncio
import httpx
from fastapi import FastAPI
app = FastAPI()
SERVICES = {
"usuarios": "https://api.example.com/users",
"pedidos": "https://api.example.com/orders",
"analytics": "https://api.example.com/analytics",
}
async def fetch(client: httpx.AsyncClient, nombre: str, url: str) -> dict:
r = await client.get(url, timeout=15)
r.raise_for_status()
return {"servicio": nombre, "status": r.status_code, "datos": r.json()}
@app.get("/dashboard")
async def dashboard():
async with httpx.AsyncClient() as client:
resultados = await asyncio.gather(
*(fetch(client, n, u) for n, u in SERVICES.items()),
return_exceptions=True, # un fallo no tira todo
)
# filtra errores y devuelve lo que sí respondió
ok = [r for r in resultados if not isinstance(r, Exception)]
fallidos = [str(r) for r in resultados if isinstance(r, Exception)]
return {"ok": ok, "fallidos": fallidos, "total": len(SERVICES)}
Con asyncio.gather las tres llamadas se disparan simultáneamente: el tiempo total es el de la más lenta (~300 ms), no la suma (~900 ms).
Timeout y cancelación
Nunca dejes una espera sin límite:
async def obtener_con_timeout():
try:
async with asyncio.timeout(5):
async with httpx.AsyncClient() as client:
r = await client.get("https://api.example.com")
return r.json()
except TimeoutError:
return {"error": "timeout"}
Cheatsheet
| Quieres… | Usas… |
|---|---|
| Correr el event loop | asyncio.run(main()) |
| Lanzar varias a la vez | asyncio.gather(*tasks) |
| Lanzar en segundo plano | asyncio.create_task(...) |
| Llamar código síncrono desde async | await asyncio.to_thread(fn, ...) |
| HTTP async | httpx.AsyncClient (nunca requests en coroutines) |
| CPU-bound | ProcessPoolExecutor |
| Límite de tiempo | async with asyncio.timeout(n): |
| No tumbar todo por un error | gather(..., return_exceptions=True) |
| Endpoint con I/O async | async def en FastAPI |
| Endpoint con ORM síncrono | def en FastAPI (thread pool automático) |
Para profundizar
- Official asyncio docs: la referencia definitiva del event loop.
- asyncio in the real world (Python.org) y la guía de concurrencia.
- httpx — Async Support: cliente HTTP async de referencia.
- FastAPI — Concurrency and async/await: la página oficial que explica
defvsasync def. - Real Python — Async IO in Python: tutorial profundo con ejemplos.
- Ruta completa: Backend con Python.