English · Español
01 — async def, el event loop, y por qué def lo bloquea todo¶
FastAPI corre sobre un event loop. Si tu handler es
def(sync), corre en un threadpool y solo necesitas no compartir state. Si esasync defy haces una llamada bloqueante (como tu modelo en CPU), te cargas el loop entero. La regla: ofloadea inference a un thread o usadef.
Los dos tipos de handler¶
FastAPI acepta ambos:
@app.post("/correct")
def correct_sync(req): ... # sync — corre en threadpool
@app.post("/correct")
async def correct_async(req): ... # async — corre en el event loop
¿Cuál es la diferencia?
Sync (def) — threadpool automático¶
Cuando defines un handler sync, FastAPI lo ejecuta en un thread del threadpool por defecto de anyio (tamaño por defecto ≈ 40). El event loop ofloadea cada request y queda libre para atender la siguiente inmediatamente.
Pro: Sin riesgo de bloquear el event loop. Contra: Acotado por el tamaño del threadpool + contención del GIL para trabajo CPU-bound.
Para nuestro Mini-GPT (CPU, ~80 ms por request), sync está bien. El throughput está acotado por min(threadpool_size / T_correct, gil_concurrency). Con 40 threads y el GIL liberado durante las llamadas NumPy (sí lo libera), puedes obtener un throughput razonable.
Async (async def) — corre en el event loop¶
Cuando defines un handler async, FastAPI lo ejecuta directamente sobre el event loop. Si en cualquier punto del handler haces una llamada bloqueante sin ceder, bloqueas el loop para todas las demás requests.
@app.post("/correct")
async def correct_async(req):
correction = agent.correct(req.sentence) # ❌ BLOQUEANTE — el modelo es NumPy CPU-bound
return ...
Este es el error más común con FastAPI. El código "parece async" pero en secreto bloquea el loop durante 80 ms por request. La concurrencia es efectivamente 1.
El arreglo: ofloadear a un thread¶
import anyio
@app.post("/correct")
async def correct_async(req):
correction = await anyio.to_thread.run_sync(agent.correct, req.sentence)
return ...
anyio.to_thread.run_sync (FastAPI también lo expone vía asyncio.to_thread) mueve la llamada a un worker thread, de modo que el event loop queda libre.
Regla práctica¶
- I/O bound (red, disco, DB): usa
async defconawait aiohttp.get(...),await redis.get(...), etc. Las librerías async nativas cooperan con el loop. - CPU bound (inferencia de modelo, procesado de imágenes): usa
defy deja que el threadpool de FastAPI haga el offloading. O usaasync def+to_threadexplícito.
Para nuestro agente, def es el valor por defecto correcto. El lab 01 hará load test a ambos y mostrará que — si evitas la trampa de async def + bloqueo — los dos se comportan parecido.
¿Y uvicorn --workers?¶
Ejecutar uvicorn app:app --workers 4 lanza 4 procesos separados, cada uno con su propio event loop y threadpool. Esto rodea el GIL: la inferencia del modelo puede correr en 4 núcleos de CPU en paralelo.
Caveat: Cada worker carga su propia copia de los pesos del modelo — 4× memoria. Para nuestro Mini-GPT de 103.680 parámetros esto es trivial (4 × 400 KB ≈ 1,6 MB), pero para un modelo 7B (~14 GB fp16), 4 workers necesitarían 56 GB solo para pesos. Por eso los schedulers de batching de los labs 02-03 son single-process — compartir el modelo significa compartir el acceso a los mismos pesos protegido por el GIL.
Para la Fase 33 usamos --workers 1 para que el scheduler pueda ver todas las requests in-flight en un proceso.
El detalle del GIL para nuestra carga¶
El GIL (Global Interpreter Lock) de CPython impide que dos threads ejecuten bytecode de Python simultáneamente. NumPy libera el GIL durante operaciones pesadas (matrix multiplies, llamadas BLAS). Así que nuestros forward passes intensivos en matmul pueden correr en paralelo entre threads.
Pero: la orquestación Python del bucle del agente (if/else, lookups en dict, json.loads) no libera el GIL. Así que añadir más threads da rendimientos decrecientes una vez que el overhead Python iguala al trabajo NumPy.
Para nuestro Mini-GPT (que tiene más overhead Python que trabajo NumPy, porque \(d = 64\) es minúsculo), el GIL sí importa. La concurrencia por threadpool se satura rápido. Esta es una de las motivaciones del batching: amortizar el overhead Python entre muchas requests.
Rendimiento: sync vs async en nuestra carga¶
Esperado del lab 01 en una CPU de 4 núcleos con --workers 1:
| Handler | 50 clientes concurrentes | latencia p50 | latencia p95 | Throughput |
|---|---|---|---|---|
def (threadpool) |
50 reqs | ~250 ms | ~600 ms | ~30 req/s |
async def + bloqueante |
50 reqs | ~2000 ms | ~3500 ms | ~12 req/s |
async def + to_thread |
50 reqs | ~270 ms | ~620 ms | ~30 req/s |
La fila del medio es la trampa. Las de arriba y abajo son equivalentes (descontando un pequeño overhead de to_thread).
Lo que este archivo NO cubre¶
- Paralelismo de modelo multi-proceso. Fase 35.
- Drivers async de DB, clientes async de cola. No se usan en esta fase.
uvloopy otras implementaciones de event loop. Speedup marginal; se omite.
Siguiente: 02-static-vs-continuous-batching.md