🔎 Buscar

🐍 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.

Wiki / Apuntes📖 Contenido

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).
  • await le 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.sleep es el “placeholder” del I/O real. Sustitúyelo por await httpx.AsyncClient().get(...) o await 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 de async def congela todas las coroutines del loop. Si no hay versión async de tu librería, usa asyncio.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), usa def y deja que FastAPI haga el thread pool. Lo peor es mezclar: síncrono dentro de async 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, ProcessPoolExecutor o multiprocessing.

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

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.