“Simplemente hazlo con async def” es el consejo que todo desarrollador de FastAPI escucha primero, y es la razón por la que tantos incidentes en producción empiezan con un endpoint lento que, silenciosamente, termina derribando a todos los demás. El impulso tiene sentido: async def parece la opción “rápida” y def parece la opción heredada. Pero ese enfoque invierte el modo de fallo real. async def no ofrece concurrencia gratis. Es una promesa al event loop de que esta función nunca se quedará bloqueando, y FastAPI no tiene forma de comprobar si cumpliste esa promesa hasta que ya es demasiado tarde.
La historia real es más específica que “usa async en todas partes”, y entenderla es la diferencia entre una API que se degrada con elegancia bajo carga y una en la que una sola llamada a requests.get() dentro de un handler async def congela a todos los usuarios concurrentes del servidor, no solo al que hizo esa solicitud. Esta publicación explica qué ocurre realmente cuando una corrutina se bloquea, por qué las rutas def se comportan de forma completamente distinta, cómo detectar la llamada bloqueante escondida en una librería que nunca auditaste y qué hacer realmente una vez que la encuentras.
Aprenderás:
- Qué es realmente el event loop de FastAPI y por qué solo puede hacer una cosa a la vez
- La diferencia real entre rutas
async defydef, y por qué FastAPI las trata de forma completamente distinta por debajo - Cómo el threadpool en el que se ejecutan las rutas
defprotege el event loop, y por qué esa misma red de seguridad no existe paraasync def - Las llamadas bloqueantes que se esconden dentro de handlers
async def: drivers de BD síncronos,requests,time.sleep, trabajo intensivo de CPU - Cómo diagnosticar de verdad un event loop bloqueado en un servicio en ejecución, en lugar de solo adivinar
- Las soluciones concretas:
run_in_executor, librerías nativas async y cuándo simplemente usardef
Tabla de contenidos
- Lo básico
- async def vs def: Lo que FastAPI realmente hace
- La red de seguridad del threadpool
- Llamadas bloqueantes escondidas dentro de async def
- Diagnosticar un event loop bloqueado
- Cómo solucionarlo
- Un ejemplo práctico
- Errores comunes
- Buenas prácticas en producción
Lo básico
Un event loop, un hilo, sin excepciones
FastAPI está construido sobre Starlette, que a su vez está construido sobre asyncio. En la configuración predeterminada, un único proceso worker ejecuta un único event loop en un único hilo, y cada corrutina async def de tu aplicación —cada request handler, cada dependencia, cada middleware— va turnándose para ejecutarse en ese mismo hilo. “Turnarse” solo funciona si cada corrutina devuelve periódicamente el control al loop, lo que ocurre en cada await. Entre un await y el siguiente, esa corrutina posee el hilo por completo. Nada más en el proceso se ejecuta —ni otras solicitudes, ni health checks, ni los propios internals del framework— hasta que termine o alcance otro await.
Este es todo el modelo mental que necesitas: una función async def que nunca hace await, o que llama a algo síncrono y lento sin esperarlo, no solo se ejecuta lentamente: detiene por completo todas las demás solicitudes en vuelo durante todo el tiempo que tarde.
Por qué esto aparece como lentitud “aleatoria”
El síntoma rara vez se ve como “este endpoint es lento”. Se ve como un endpoint completamente distinto, normalmente rápido, que ocasionalmente tarda tres segundos sin una razón visible, porque durante esos tres segundos estaba en cola detrás de la llamada bloqueante de otra persona en el mismo hilo. Eso es lo que hace que el bug sea difícil de encontrar: la solicitud lenta y la solicitud afectada suelen ser endpoints distintos, así que perfilar el endpoint afectado de forma aislada no muestra absolutamente nada malo.
async def vs def: Lo que FastAPI realmente hace
FastAPI trata ambas firmas de forma completamente distinta, y esa diferencia lo explica todo:
@app.get("/fast-if-truly-async")
async def get_data():
# Runs directly on the event loop thread.
# Every `await` here yields control back to the loop.
result = await some_async_db_call()
return result
@app.get("/runs-in-threadpool")
def get_data_sync():
# FastAPI automatically dispatches this to a worker
# thread from Starlette's threadpool — it never runs
# on the event loop thread at all.
result = some_blocking_db_call()
return result
Una ruta async def se ejecuta directamente en el event loop. FastAPI confía en que has escrito una función que nunca bloquea el hilo durante un periodo de tiempo significativo sin un await de por medio. En cambio, una ruta def se descarga automáticamente a un worker thread separado mediante run_in_threadpool: obtienes un comportamiento correcto y no bloqueante sin escribir una sola línea de código async, porque el trabajo bloqueante ocurre en otro lugar por completo.
Esto es exactamente lo contrario de lo que la mayoría de la gente imagina al comenzar con FastAPI: def no es la opción “lenta y heredada”. Para una porción de trabajo genuinamente síncrona —una llamada a una librería sin equivalente async, algo de I/O bloqueante pero liviano para la CPU—, un simple def suele ser la opción más segura, porque no puede bloquear accidentalmente el loop aunque te equivoques en los detalles internos.
La red de seguridad del threadpool
El threadpool de Starlette (respaldado por anyio) usa por defecto un pool limitado de workers —históricamente 40 hilos— en el que se ejecutan las rutas def y las dependencias def. Cada solicitud a una ruta def toma un hilo, realiza allí su trabajo bloqueante y devuelve el resultado al event loop cuando termina. El event loop en sí nunca se queda esperando esa llamada; queda libre para seguir atendiendo otras solicitudes de forma concurrente.
Esta es una red de seguridad real, pero no es ilimitada. Si tu servicio recibe mucho más de 40 solicitudes síncronas lentas concurrentes, las últimas quedarán en cola detrás de las primeras esperando un hilo libre: un modo de fallo distinto al de bloquear el loop, pero aun así un cuello de botella que conviene conocer. Puede ajustarse mediante el limitador de hilos de Starlette/anyio, pero la solución más duradera a escala real suele ser reducir cuánto trabajo síncrono y hambriento de hilos estás haciendo en primer lugar, no seguir aumentando el tamaño del pool.
El threadpool solo entra en juego para def. Nada intercepta una llamada bloqueante hecha dentro de async def: esa es exactamente la razón por la que existe esta clase de bug.
Ajustar el limitador del threadpool
Si de verdad necesitas superar el techo predeterminado de worker threads —por ejemplo, una ráfaga de subidas de archivos síncronas—, anyio expone un capacity limiter que puedes aumentar al inicio:
import anyio
from anyio import to_thread
@app.on_event("startup")
async def raise_threadpool_limit():
limiter = to_thread.current_default_thread_limiter()
limiter.total_tokens = 100
Trata esto como una medida temporal, no como una solución. Cada hilo extra añade consumo de memoria y sobrecarga por cambio de contexto, y no hace nada ante un event loop realmente bloqueado: solo ayuda cuando el cuello de botella es la disponibilidad de hilos para rutas def, que es un problema distinto del que trata principalmente esta publicación.
Por qué añadir más workers de Uvicorn no soluciona esto
Un impulso común, pero incorrecto, es escalar un incidente de loop bloqueado añadiendo más procesos worker de Uvicorn/Gunicorn. Cada proceso worker sí tiene su propio event loop, así que más workers aumentan la capacidad total, pero dentro de cualquier worker individual ocurre exactamente el mismo comportamiento bloqueante: una sola llamada lenta en async def sigue frenando todas las solicitudes actualmente asignadas al loop de ese worker. Más workers reducen el radio de impacto por incidente (solo 1/N de tu tráfico golpea al worker atascado en cada momento), pero no abordan la causa raíz y multiplican el costo de infraestructura para tapar un bug que una corrección de cinco líneas resolvería gratis. Considera los workers extra como un colchón de resiliencia, nunca como la solución real a una llamada bloqueante.
Llamadas bloqueantes escondidas dentro de async def
El patrón peligroso siempre tiene la misma forma: un handler async def que llama a algo síncrono sin envolverlo, de modo que la función “async” bloquea el loop exactamente tanto como lo haría una función def, pero sin el threadpool que la habría salvado.
import time
import requests # synchronous HTTP client
@app.get("/danger")
async def danger():
time.sleep(2) # blocks the entire event loop for 2s
resp = requests.get(EXTERNAL) # blocks for however long the network takes
return resp.json()
Cada solicitud en vuelo en cualquier parte del proceso se detiene durante la duración combinada de ambas llamadas. Los culpables habituales:
- Clientes HTTP síncronos —
requests, ohttpx.Client(nohttpx.AsyncClient) — dentro deasync def. - Drivers de base de datos síncronos —
psycopg2, el modo sync de SQLAlchemy — llamados directamente en lugar de usar un driver async. Esta es exactamente la trampa cubierta en profundidad en Async SQLAlchemy Sessions in FastAPI, Done Right: mezclar un motor sync en una base de código async reintroduce este mismo comportamiento bloqueante. time.sleep()en lugar deasyncio.sleep()— un error tipográfico fácil que no tiene ningún efecto hasta que aparece bajo carga concurrente.- Trabajo intensivo de CPU — redimensionado de imágenes, generación de PDF, transformaciones pesadas con
pandas, hashing criptográfico. Estos bloquean sin importar qué librería cliente uses, porque el coste es computación, no I/O: no existe una versión async de “la CPU está ocupada”, y ninguna cantidad deawaitlo arregla. - I/O de archivos en disco local —
open(),.read(),.write()son llamadas al sistema síncronas a menos que las canalices a través deaiofileso un threadpool.
Nada de esto lanza un error. Simplemente hace que todas las demás solicitudes del proceso esperen silenciosamente su turno.
Diagnosticar un event loop bloqueado
Adivinar qué endpoint es el culpable rara vez funciona: necesitas señales reales del proceso en ejecución.
1. Modo debug de asyncio. Ejecutar con PYTHONASYNCIODEBUG=1 (o asyncio.run(main(), debug=True)) hace que asyncio registre una advertencia cada vez que un callback tarda más de 100 ms en ejecutarse, que es exactamente el síntoma de un loop bloqueado.
PYTHONASYNCIODEBUG=1 uvicorn app.main:app
2. Server.log_slow_callbacks / instrumentación manual del loop. Puedes adjuntar una corrutina periódica de heartbeat que mida su propio retraso de planificación: si un heartbeat que debería activarse cada 100 ms se dispara tarde con regularidad, algo está monopolizando el loop:
import asyncio, time
async def loop_monitor():
while True:
start = time.monotonic()
await asyncio.sleep(0.1)
drift = time.monotonic() - start - 0.1
if drift > 0.05:
print(f"Event loop blocked for ~{drift:.2f}s")
3. Pruebas de carga con un conjunto mixto de endpoints. Golpea concurrentemente un endpoint conocido por ser rápido y otro sospechoso de ser lento con una herramienta como locust o hey. Si la latencia del endpoint rápido se degrada al mismo ritmo que la carga del lento, estás viendo contención del loop, no lentitud propia de un endpoint.
4. Trazas de APM con contexto de hilo/tarea. Herramientas como Datadog APM u OpenTelemetry pueden mostrarte si un span se está ejecutando en el hilo del event loop o en un worker del threadpool: un span async def lento, sin spans hijos, es una señal fuerte de que está haciendo trabajo bloqueante de forma síncrona.
5. py-spy dump contra el proceso en vivo. py-spy se conecta a un proceso Python en ejecución sin reiniciarlo e imprime la pila actual de cada hilo. En un servidor atascado, ejecuta py-spy dump --pid <uvicorn-worker-pid> y busca un único hilo detenido dentro de time.sleep, una llamada síncrona de socket o la extensión en C de un driver de base de datos: esa es tu llamada bloqueante, capturada en el acto, en producción, sin necesidad de cambios de código para reproducirla.
pip install py-spy
py-spy dump --pid $(pgrep -f "uvicorn app.main:app" | head -1)
Cómo solucionarlo
Una vez identificada la llamada bloqueante, hay tres opciones reales, en orden de preferencia:
1. Usa la versión nativa async de la librería. requests → httpx.AsyncClient; psycopg2 → asyncpg o el motor async de SQLAlchemy; time.sleep → asyncio.sleep. Casi siempre esta es la corrección adecuada: elimina la llamada bloqueante en lugar de rodearla.
import httpx
@app.get("/fixed")
async def fixed():
async with httpx.AsyncClient() as client:
resp = await client.get(EXTERNAL)
return resp.json()
2. Descárgala explícitamente a un hilo con run_in_threadpool o run_in_executor. Cuando no existe equivalente async —un SDK heredado, una librería respaldada por una extensión en C—, envía manualmente la llamada bloqueante a un worker thread en lugar de ejecutarla en el loop:
from starlette.concurrency import run_in_threadpool
@app.get("/legacy-sdk")
async def legacy_sdk():
result = await run_in_threadpool(legacy_blocking_call, arg1, arg2)
return result
3. Simplemente conviértela en una ruta def. Si un handler es fundamentalmente síncrono —llama a un SDK bloqueante y no hace nada más de forma concurrente—, no hay ninguna ventaja en forzarlo a async def. Deja que el threadpool de FastAPI se encargue; para eso está.
Para trabajo realmente intensivo de CPU (procesamiento de imágenes, computación pesada), ni los hilos ni async def ayudan de verdad, porque el GIL de Python implica que los hilos no te dan paralelismo para código intensivo de CPU: necesitas ProcessPoolExecutor, o mover el trabajo fuera del camino de la solicitud por completo hacia un worker en segundo plano, que es la decisión tratada en FastAPI BackgroundTasks vs Celery: Picking the Right One.
Un ejemplo práctico
Para ver el efecto directamente, compara dos versiones del mismo endpoint bajo carga concurrente. Ambas simulan una dependencia bloqueante de 1 segundo; ambas reciben 20 solicitudes concurrentes con httpx desde un script de prueba.
# blocking.py
@app.get("/blocking")
async def blocking():
time.sleep(1) # synchronous sleep inside async def
return {"ok": True}
# fixed.py
@app.get("/fixed")
async def fixed():
await asyncio.sleep(1) # yields control back to the loop
return {"ok": True}
Veinte solicitudes concurrentes a /blocking se completan en serie: aproximadamente 20 segundos en total, porque cada time.sleep(1) ocupa por completo el único hilo que está trabajando antes de que la siguiente solicitud pueda siquiera empezar a procesarse. Esas mismas 20 solicitudes concurrentes a /fixed se completan en aproximadamente 1 segundo total, porque asyncio.sleep cede el control inmediatamente y las veinte corrutinas quedan suspendidas y reanudadas juntas por el loop. Nada sobre la latencia anunciada del propio endpoint cambió; solo cambió si realmente cooperaba o no con todo lo demás que se estaba ejecutando en el proceso.
La brecha se amplía, no se reduce, a medida que aumenta la concurrencia. Con diez solicitudes concurrentes, /blocking aún podría parecer tolerable en una prueba manual rápida: unos diez segundos no alarman si nadie está mirando el reloj de cerca. A los niveles de tráfico que ve un servicio real en producción, ese mismo comportamiento de serialización lineal se convierte en un gráfico de latencia p95 que sube en línea recta con el volumen de solicitudes en lugar de aplanarse, como debería hacerlo un servicio async saludable. Esa forma —latencia que escala linealmente con la carga concurrente en lugar de mantenerse aproximadamente plana hasta alcanzar límites reales de recursos— es una de las huellas más claras de un event loop bloqueado en un dashboard, y suele ser visible mucho antes de que alguien diagnostique manualmente la causa raíz.
Errores comunes
Error: asumir que async def siempre es más rápido. Para una ruta con una sola llamada síncrona que en la práctica no bloquea, def y el threadpool son más simples e igual de seguros. Solución: usa def por defecto para trabajo genuinamente síncrono, y reserva async def para rutas de código que realmente hagan await sobre algo.
Error: mezclar una sesión sync de ORM dentro de una ruta async. Llamar a una sesión sync de SQLAlchemy desde dentro de async def bloquea el loop exactamente igual que cualquier otra llamada sync. Solución: usa de forma consistente un motor y una sesión async, como se explica en la publicación sobre sesiones de SQLAlchemy.
Error: no notar que el verdadero cuello de botella es trabajo intensivo de CPU. Cambiar un handler pesado en CPU a async def o envolverlo en run_in_threadpool no ayuda: el GIL sigue serializando el trabajo de CPU entre hilos. Solución: usa ProcessPoolExecutor para trabajo intensivo de CPU, o muévelo completamente fuera del ciclo request/response.
Error: depurar de forma aislada. Probar el endpoint sospechoso de ser lento por sí solo, sin carga concurrente, nunca reproduce el síntoma: todo el bug depende de la contención. Solución: reproduce siempre con solicitudes concurrentes golpeando varios endpoints a la vez.
Error: aumentar el tamaño del threadpool como primera respuesta. Esto enmascara la inanición de hilos en rutas def, pero no hace nada ante un event loop bloqueado causado por async def. Solución: diagnostica qué modo de fallo tienes realmente antes de recurrir a un cambio de configuración.
Buenas prácticas en producción
- Por defecto, usa
defpara nuevas integraciones síncronas, noasync def. Deja que el threadpool de FastAPI haga su trabajo en lugar de implementar a mano la misma protección. - Audita cada
async defen busca de una llamada que no esté esperada conawait. Un grep rápido derequests.,time.sleep(o imports de drivers síncronos dentro de handlers async detecta la mayoría de los incidentes reales antes de que lleguen a producción. - Añade monitoreo de loop lag en producción, no solo localmente. Una corrutina ligera de heartbeat registrando el retraso de planificación casi no cuesta nada y detecta regresiones al instante.
- Haz pruebas de carga con concurrencia, no con solicitudes individuales. La contención del loop es invisible bajo pruebas secuenciales por definición.
- Primero acierta con la estructura base de la aplicación. Una estructura que mantenga los route handlers ligeros —como se explica en FastAPI Project Structure That Survives Growth— hace mucho más fácil detectar una llamada bloqueante perdida, porque la lógica de negocio vive en un único lugar evidente en lugar de estar dispersa entre handlers.
Cierre
Un event loop bloqueado no es un misterioso techo de rendimiento: es una consecuencia específica y rastreable de una corrutina reteniendo el único hilo del que depende todo el proceso. async def vs def nunca trató de cuál es “moderno”; trata de si le estás entregando a FastAPI código que realmente coopera con el loop, o código que solo lo parece. Audita las llamadas bloqueantes antes de que las pruebas de carga las saquen a la luz, y cuando tengas dudas, deja que el threadpool se encargue: def no es la opción de respaldo; con frecuencia es la correcta.
¿De verdad has hecho pruebas de carga a tus rutas async def bajo concurrencia, o simplemente asumiste que la palabra clave estaba haciendo el trabajo por ti?
