Bloque I · Módulo 1 · Unidad 1.3
Asincronía mínima para comprender FastAPI
Cómo distinguir espera, concurrencia y paralelismo, interpretar corutinas y await, y evitar bloquear el event loop al elegir entre def y async def.
Antes de empezar#
Una aplicación web pasa una parte considerable de su vida esperando: llegan bytes desde un cliente, una consulta de base de datos prepara resultados, otra API responde o el sistema operativo entrega datos. La CPU no necesita calcular activamente durante toda esa espera. Si el programa sabe ceder el control, puede atender otro trabajo mientras el resultado externo no está disponible.
Esta idea explica por qué FastAPI admite funciones escritas con async def y expresiones await. Sin embargo, las palabras asíncrono, concurrente y rápido no significan lo mismo. Añadir async a una función no transforma una operación bloqueante, no acelera un cálculo intensivo y tampoco hace que todas las líneas se ejecuten a la vez.
Conocimientos previos. Debes comprender el ciclo petición-respuesta de la Unidad 1.1 y manejar funciones y excepciones básicas de Python. No necesitas conocer asyncio, threads, procesos ni el funcionamiento interno de FastAPI.
Mapa conceptual. La unidad sigue este recorrido:
- Separaremos trabajo y espera, porque no consumen los recursos del mismo modo.
- Distinguiremos concurrencia —varias tareas progresan durante un intervalo— de paralelismo —varias ejecuciones ocurren simultáneamente—.
- Clasificaremos cargas I/O-bound y CPU-bound.
- Relacionaremos funciones de corutina, objetos de corutina,
await, Tasks y event loop. - Observaremos cómo una llamada bloqueante impide que otras tareas progresen en ese event loop.
- Cerraremos con un criterio inicial para elegir entre
defyasync defen FastAPI.
1. Espera, trabajo y ejecución#
Trabajo útil frente a espera#
Una tarea alterna normalmente dos clases de intervalos:
- Durante el trabajo útil, la CPU interpreta instrucciones, valida datos, recorre una estructura o calcula un resultado.
- Durante la espera, el programa no puede continuar esa tarea hasta recibir un acontecimiento externo: datos de red, una respuesta de base de datos, un temporizador o el resultado de otro componente.
Imagina una operación que necesita 5 milisegundos para preparar una consulta, espera 80 milisegundos a la base de datos y emplea 5 milisegundos en formar la respuesta. Su duración total ronda 90 milisegundos, pero la CPU solo ejecuta trabajo propio durante una fracción pequeña. La pregunta de la concurrencia es: ¿puede aprovecharse el intervalo de espera para hacer progresar otra tarea?
Bloqueante no significa necesariamente lento. Una suma pequeña bloquea durante un instante porque se ejecuta de principio a fin, pero su coste es despreciable. El problema aparece cuando el bloqueo dura lo suficiente o se repite con tanta frecuencia que retrasa el resto del sistema.
Síncrono y asíncrono describen coordinación#
En un flujo síncrono, quien inicia una operación continúa vinculado a ella hasta obtener el resultado. Si la operación espera, ese flujo también espera. Después comienza el paso siguiente.
En un flujo asíncrono, una operación que todavía no puede terminar puede suspenderse de forma explícita. El sistema conserva la información necesaria para reanudarla y utiliza el intervalo para atender otro trabajo. Cuando el resultado está disponible, la tarea suspendida puede continuar.
La diferencia no es «ordenado frente a desordenado». Una función asíncrona conserva un orden local comprensible: la línea posterior a await necesita el resultado anterior. Lo que cambia es que la espera puede ser visible para el planificador y permitir que otra tarea progrese.
| Pregunta | Flujo síncrono | Flujo asíncrono cooperativo |
|---|---|---|
| ¿Qué ocurre al iniciar la espera? | El flujo permanece ocupado hasta obtener el resultado. | La tarea puede suspenderse y ceder el control. |
| ¿Puede progresar otra tarea en el mismo event loop? | No mientras la llamada lo bloquee. | Sí, si la operación esperada es realmente no bloqueante. |
| ¿Cambia el tiempo de la dependencia externa? | No. | No necesariamente; se aprovecha mejor la espera. |
| ¿Se ejecutan automáticamente varias tareas? | No. | No: deben estar planificadas y alcanzar puntos donde cedan el control. |
2. Concurrencia y paralelismo#
Concurrencia como progreso intercalado#
Existe concurrencia cuando varias tareas permanecen activas durante un mismo intervalo y sus progresos pueden intercalarse. Una tarea avanza, encuentra una espera y cede el control; otra utiliza ese intervalo; después la primera se reanuda.
No es necesario que dos instrucciones se ejecuten en el mismo instante. Un único trabajador puede alternar entre varias tareas si cada una deja libre el puesto durante sus esperas. Desde fuera, ambas progresan de forma solapada.
Paralelismo como ejecución simultánea#
Existe paralelismo cuando más de una ejecución realiza trabajo al mismo tiempo, por ejemplo en distintos núcleos o procesos. Resulta especialmente relevante cuando hay mucho cálculo que puede dividirse.
Concurrencia y paralelismo son dimensiones distintas:
- Puede haber concurrencia sin paralelismo: un event loop intercala tareas en un solo thread.
- Puede haber paralelismo sin un modelo asíncrono: varios procesos ejecutan cálculos síncronos a la vez.
- Un sistema de producción puede combinar ambos: varios procesos, cada uno atendiendo I/O concurrente.
| Dimensión | Concurrencia | Paralelismo |
|---|---|---|
| Pregunta principal | ¿Cómo progresan varias tareas durante el mismo intervalo? | ¿Cuántas ejecuciones realizan trabajo simultáneo? |
| Recurso característico | Puntos de suspensión y un planificador. | Varios núcleos, procesos o máquinas. |
| Beneficio típico | Aprovechar esperas de I/O. | Repartir trabajo de cálculo. |
| No garantiza | Simultaneidad física ni menor latencia individual. | Buen uso de esperas ni ausencia de coordinación. |
En la ejecución secuencial, la tarea B comienza cuando la tarea A termina y las dos esperas se acumulan. En la ejecución concurrente, cada tarea cede el control durante su espera y sus intervalos se solapan, por lo que ambas terminan antes.
La línea temporal no representa una duración universal. Solo muestra el modelo: en la versión secuencial, la segunda tarea no empieza hasta que termina la primera; en la concurrente, la espera de A permite preparar B. Las dos dependencias externas pueden permanecer igual de lentas, pero sus intervalos se solapan.
3. I/O-bound y CPU-bound#
Cargas limitadas por I/O#
Una carga es I/O-bound cuando la mayor parte del tiempo transcurre esperando entrada o salida. Ejemplos habituales en una API:
- recibir la petición de un cliente;
- esperar una consulta a la base de datos;
- consultar otra API por red;
- leer o escribir mediante una interfaz de archivo;
- enviar la respuesta al cliente.
La CPU participa antes y después de la espera, pero la duración está dominada por un componente externo. Este perfil hace valiosa la concurrencia: mientras una operación espera, otra puede utilizar el proceso.
No toda biblioteca que hace I/O ofrece una interfaz asíncrona. Una llamada de red puede ser I/O-bound y, al mismo tiempo, bloquear el thread que la ejecuta. La naturaleza de la carga y la interfaz de la biblioteca son preguntas diferentes.
Cargas limitadas por CPU#
Una carga es CPU-bound cuando la mayor parte del tiempo se consume ejecutando cálculos. Algunos ejemplos:
- transformar una imagen grande;
- comprimir o cifrar un volumen importante de datos;
- procesar vídeo;
- ejecutar un cálculo numérico costoso;
- analizar un conjunto de datos grande en Python.
Durante ese trabajo no existe una espera externa que await pueda aprovechar. La CPU debe completar las instrucciones. Marcar la función como asíncrona no crea más capacidad de cálculo.
| Operación | Perfil dominante | Pregunta útil | Error que debe evitarse |
|---|---|---|---|
| Consulta a una API externa | I/O-bound | ¿La biblioteca ofrece un método awaitable? | Suponer que toda llamada de red es automáticamente no bloqueante. |
| Consulta a base de datos | I/O-bound | ¿El driver y la sesión son sync o async? | Mezclar una sesión síncrona con una ruta async sin reconocer el bloqueo. |
| Validar unos pocos campos | CPU, pero breve | ¿El coste es material? | Crear infraestructura compleja para trabajo insignificante. |
| Procesar una imagen grande | CPU-bound | ¿Debe salir del proceso web o repartirse? | Esperar que await acelere el cálculo. |
| Leer un archivo con una API síncrona | I/O-bound y bloqueante | ¿Dónde debe aislarse esa llamada? | Confundir «espera de disco» con «interfaz async». |
4. Corutinas, event loop y await#
Función de corutina y objeto de corutina#
En Python, async def define una función de corutina. Llamarla no ejecuta inmediatamente todo su cuerpo como ocurriría con una función ordinaria; produce un objeto de corutina que representa esa ejecución pendiente.
async def obtener_ficha(cliente, ficha_id):
respuesta = await cliente.get(f"/fichas/{ficha_id}")
return respuesta.json()
operacion_pendiente = obtener_ficha(cliente, 42)
En el fragmento, operacion_pendiente no contiene todavía la ficha. Contiene un objeto de corutina. Para que el cuerpo progrese, la corutina debe ser esperada o planificada por el entorno asíncrono.
La documentación de Python distingue expresamente:
- coroutine function: la función declarada con
async def; - coroutine object: el objeto obtenido al llamar a esa función.
Awaitable y expresión await#
Un awaitable es un objeto que puede utilizarse en una expresión await. Las corutinas son awaitables; Tasks y Futures también lo son, aunque sus detalles se posponen.
Dentro de async def, la expresión:
respuesta = await cliente.get("/fichas/42")
comunica dos cosas:
- La función necesita el resultado de esa operación antes de continuar por la línea siguiente.
- Si la operación debe esperar y sabe cooperar con el event loop, la tarea actual puede suspenderse y dejar avanzar a otras tareas.
await no significa «ignora el resultado» ni «ejecuta esto en segundo plano». La función actual continúa necesitando el valor o la finalización de la operación. Lo que puede cambiar es el uso del tiempo de espera.
Event loop y planificación cooperativa#
El event loop coordina tareas asíncronas y acontecimientos de I/O. Mantiene trabajo listo, ejecuta una tarea y, cuando esta espera de forma cooperativa, puede seleccionar otra. Más tarde reanuda la primera cuando su resultado está disponible.
El modelo es cooperativo porque una tarea en ejecución conserva el control hasta que termina, alcanza un punto de suspensión válido o falla. El event loop no convierte cualquier línea lenta en una oportunidad para alternar.
Una secuencia con dos await no implica por sí sola que ambas operaciones hayan empezado a la vez:
perfil = await obtener_perfil(cliente)
pedidos = await obtener_pedidos(cliente, perfil["id"])
La segunda depende del resultado de la primera y comienza después. Incluso si fueran independientes, escribir dos await consecutivos mantiene una espera secuencial salvo que se planifiquen de otra manera. La sintaxis async permite concurrencia, pero no inventa independencia ni planifica automáticamente todas las expresiones.
await solo aparece dentro de async def#
Python permite await dentro de una función declarada con async def. La función ordinaria def no participa directamente en ese protocolo y no puede contener una expresión await.
Esta regla sintáctica aporta una señal visible: al leer una función de corutina, sabemos que puede encontrar puntos de suspensión. No garantiza que todas sus operaciones sean no bloqueantes. Una llamada síncrona ordinaria sigue siendo síncrona aunque esté escrita dentro de async def.
5. Bloqueo del event loop#
Una llamada bloqueante no se vuelve async#
Observa este fragmento conceptual:
async def consultar_recurso():
resultado = cliente_sincrono.get("/recurso")
return resultado
La función exterior es asíncrona, pero cliente_sincrono.get(...) no contiene await ni ofrece al event loop una oportunidad de atender otra tarea. Si esa llamada espera diez segundos, la tarea conserva el thread del event loop durante esos diez segundos.
El problema no es que falte la palabra await delante de cualquier llamada. Solo puede esperarse un objeto awaitable. Añadir await cliente_sincrono.get(...) sería incorrecto si la biblioteca no proporciona esa interfaz.
El cálculo prolongado también bloquea#
Una función puede no realizar I/O y aun así monopolizar el event loop:
async def generar_informe(datos):
return calculo_costoso(datos)
Mientras calculo_costoso utiliza la CPU, no existe await que ceda el control. El event loop no puede intercalar otras tareas en ese thread. La solución no consiste en insertar await arbitrarios, sino en decidir dónde debe ejecutarse esa carga. Procesos, workers y colas se estudiarán más adelante.
Dos esperas que parecen iguales#
Una espera temporal permite observar la diferencia sin depender de una red:
# Bloquea el thread que la ejecuta.
time.sleep(2)
# Suspende la tarea y permite que el event loop atienda otras.
await asyncio.sleep(2)
Ambas líneas retrasan la continuación de la tarea aproximadamente dos segundos, pero coordinan el tiempo de forma diferente. La primera ocupa el thread; la segunda registra una espera cooperativa.
Este contraste sirve como modelo, no como recomendación de introducir temporizadores en endpoints. En una aplicación real, la diferencia relevante aparece al utilizar clientes HTTP, drivers, archivos y otras dependencias.
6. Primer criterio para FastAPI#
FastAPI admite operaciones de ruta declaradas con def y con async def. La elección debe seguir la interfaz de las operaciones internas, no una preferencia estética.
| Situación | Forma inicial | Razón | Qué se estudiará después |
|---|---|---|---|
La biblioteca indica que debes usar await. | async def | La operación ofrece una interfaz asíncrona y necesita un contexto que pueda esperarla. | Clientes HTTP async, sesiones async y manejo de errores. |
| La biblioteca realiza I/O pero solo ofrece llamadas bloqueantes. | def como primera frontera segura. | FastAPI puede aislar la operación de ruta síncrona fuera del event loop. | Thread pool, límites y aislamiento explícito. |
| La operación combina varias dependencias async. | async def | Puede esperar cada dependencia sin bloquear el loop. | Concurrencia estructurada, timeouts y cancelación. |
| La operación ejecuta cálculo pesado. | No se resuelve eligiendo solo una de las dos. | La carga necesita CPU; async no crea paralelismo. | Procesos, workers, colas y medición. |
| No conoces la interfaz de la biblioteca. | No adivinar; consultar su documentación. | El nombre o el dominio de la llamada no revelan si es awaitable. | Pruebas y observación del comportamiento. |
La documentación oficial de FastAPI propone una regla simple: si una biblioteca indica que se llame con await, utiliza async def; si no admite await y realiza comunicación bloqueante, utiliza def. Ambas formas pueden convivir en la aplicación.
Mezclar ambas formas es una decisión local#
Una aplicación no necesita adoptar def o async def como regla universal. Puede tener:
- una ruta async que utiliza un cliente HTTP asíncrono;
- una ruta sync que llama a una biblioteca bloqueante;
- una ruta que solo ejecuta una operación breve y no material;
- trabajo intensivo enviado a otra frontera de ejecución.
La unidad de decisión es la operación y sus dependencias. Elegir de forma local evita envolver toda la arquitectura en una abstracción prematura.
Lo que este criterio todavía no resuelve#
La regla inicial no responde por sí sola:
- cuántas operaciones independientes conviene ejecutar concurrentemente;
- qué timeout debe utilizar cada dependencia;
- cómo cancelar trabajo cuando el cliente abandona;
- cómo limitar concurrencia para proteger una base de datos;
- cuándo un thread pool se satura;
- si una sesión de base de datos debe ser sync o async;
- cuántos procesos o workers necesita el despliegue;
- dónde medir antes de optimizar.
Estas preguntas exigen observar dependencias, carga y operación real. Adelantarlas aquí convertiría una distinción básica en recetas sin contexto.
7. Ejemplo mínimo: leer una línea temporal#
Supongamos dos peticiones independientes:
- A necesita 1 unidad de trabajo, 3 de espera y 1 de trabajo final.
- B necesita la misma secuencia.
En ejecución secuencial, B empieza cuando A ha consumido sus 5 unidades. El total conceptual es 10. Durante 6 de esas unidades el programa espera una dependencia externa.
En ejecución concurrente, A inicia su espera después de la primera unidad. B puede preparar su operación durante ese intervalo y empezar su propia espera. El total conceptual se aproxima a 6 unidades, aunque cada petición continúa necesitando 5 desde su propio comienzo.
| Observación | Conclusión correcta | Conclusión incorrecta |
|---|---|---|
| Las esperas se solapan. | El sistema aprovecha intervalos que antes quedaban ociosos. | La dependencia externa se volvió más rápida. |
| Una tarea cede durante I/O. | Otra tarea lista puede progresar. | Las dos instrucciones se ejecutan siempre en paralelo. |
| El total baja de 10 a unas 6 unidades. | Mejora el rendimiento conjunto de este escenario. | Toda operación async tarda siempre un 40 % menos. |
| A y B son independientes. | El solapamiento no rompe una dependencia de datos. | También podría adelantarse B si necesitara el resultado de A. |
8. Ejemplo integrado: clasificar dependencias de un endpoint#
Imaginemos una futura operación de FastAPI que recibe una petición, consulta un catálogo externo, valida una respuesta pequeña y genera una miniatura. Todavía no la implementaremos; clasificaremos sus piezas.
| Pieza | Comportamiento conocido | Clasificación | Decisión inicial |
|---|---|---|---|
| Cliente del catálogo | Su documentación muestra await client.get(…). | I/O-bound y async. | La ruta necesita async def para esperarlo. |
| Validación de cinco campos | Trabajo local breve. | CPU, pero no material. | Se ejecuta directamente; no justifica otra infraestructura. |
| Generador de miniaturas | Procesa millones de píxeles y tarda varios segundos. | CPU-bound. | No debe resolverse añadiendo await; requiere otra frontera. |
| SDK heredado de auditoría | Realiza una petición de red y no ofrece API async. | I/O-bound y bloqueante. | No debe llamarse directamente dentro del event loop. |
El análisis descubre un conflicto: una única función async podría esperar correctamente al catálogo, pero bloquearía con el SDK heredado y con el procesamiento de imagen. No existe una palabra clave que vuelva homogéneas esas dependencias.
Una decisión proporcionada sería separar responsabilidades:
- La operación HTTP puede esperar al cliente async del catálogo.
- La validación breve puede ejecutarse inmediatamente.
- El SDK bloqueante debe aislarse o sustituirse; la técnica concreta se decidirá al estudiar thread pools.
- La miniatura debe salir de la ruta crítica o ejecutarse en una frontera apta para CPU; las colas se estudiarán en el Bloque VII.
Este ejemplo recupera la Unidad 1.1: mientras una petición espera, el proceso servidor puede recibir otras. También anticipa que una respuesta HTTP tiene un tiempo de vida limitado; enviar trabajo pesado fuera del request puede cambiar el contrato y requerir estados como «aceptado» o recursos de seguimiento, conceptos que se estudiarán más adelante.
9. Errores conceptuales frecuentes#
| Idea incorrecta | Modelo más preciso |
|---|---|
«async def siempre es más rápido.» | Solo permite un modelo de coordinación; el resultado depende de las operaciones internas y de la carga. |
«await ejecuta la operación en paralelo.» | Espera un awaitable y puede suspender la tarea; no crea por sí solo paralelismo. |
| «Llamar a una función async devuelve su resultado.» | Devuelve un objeto de corutina que debe ejecutarse mediante el entorno asíncrono. |
| «Toda llamada de red es no bloqueante.» | La carga es I/O-bound, pero la biblioteca puede ofrecer una interfaz sync bloqueante. |
«Puedo poner await delante de cualquier función lenta.» | Solo se puede esperar un awaitable; una función síncrona no cambia por añadir una palabra. |
«Dos await consecutivos empiezan a la vez.» | Normalmente conservan la secuencia; la concurrencia requiere planificación e independencia. |
| «La asincronía acelera una transformación de imagen.» | El cálculo CPU-bound necesita capacidad de CPU o aislamiento, no tiempo de espera reutilizable. |
| «Si existe un event loop, ninguna tarea puede bloquear.» | La planificación es cooperativa; una llamada bloqueante o un cálculo largo conserva el control. |
| «Toda la aplicación debe elegir entre sync o async.» | FastAPI admite mezclar operaciones según las dependencias y el propósito de cada frontera. |
10. Síntesis final#
Las aplicaciones web combinan trabajo breve con esperas de red, base de datos, archivos y otros componentes. La asincronía permite representar esas esperas como puntos de suspensión para que un event loop pueda atender otras tareas. No reduce automáticamente la latencia externa; mejora la capacidad de aprovechar el intervalo.
Concurrencia significa que varias tareas progresan durante un mismo periodo; paralelismo significa que varias ejecuciones trabajan simultáneamente. La primera suele ser valiosa para I/O-bound; el segundo resulta especialmente relevante para CPU-bound. Un sistema puede utilizar ambas estrategias.
En Python, async def define una función de corutina. Llamarla produce un objeto de corutina. await obtiene el resultado de un awaitable y puede suspender cooperativamente la tarea. El event loop solo alterna cuando la ejecución termina o cede; una llamada bloqueante y un cálculo prolongado pueden impedir que otras tareas progresen.
El primer criterio de FastAPI parte de las dependencias: biblioteca awaitable → async def y await; biblioteca bloqueante → def como frontera inicial o aislamiento explícito posterior; cálculo pesado → otra estrategia que no se resuelve con async. Mezclar rutas sync y async es válido cuando cada elección tiene una razón verificable.
Vocabulario esencial: ejecución síncrona, ejecución asíncrona, bloqueo, operación no bloqueante, concurrencia, paralelismo, I/O-bound, CPU-bound, función de corutina, objeto de corutina, awaitable, await, Task, event loop, planificación cooperativa, latencia y throughput.
Después de estudiar esta unidad deberías poder:
- Dibujar una línea temporal que diferencie trabajo y espera.
- Explicar concurrencia sin confundirla con ejecución simultánea.
- Clasificar una operación sencilla como I/O-bound o CPU-bound.
- Distinguir una función de corutina del objeto que devuelve al llamarla.
- Explicar por qué
awaites un punto potencial de suspensión. - Reconocer I/O bloqueante y cálculo largo dentro de una función async.
- Elegir inicialmente entre
defyasync defa partir de la documentación de una dependencia. - Rechazar la afirmación «
async defsiempre es más rápido».
Fuentes#
Estos apuntes sintetizan las fuentes siguientes y limitan deliberadamente los detalles de asyncio al modelo necesario para leer FastAPI.
- FastAPI — Concurrency and async / await
Secciones In a hurry?, Asynchronous Code, async and await, Coroutines y Very Technical Details — Path operation functions: espera de I/O, concurrencia, paralelismo, corutinas y criterio inicial entre def y async def.
- Python 3 — Coroutines and Tasks
Secciones Coroutines, Tasks y Task Object: diferencia entre función y objeto de corutina, necesidad de ejecutar o esperar la corutina y planificación cooperativa. Fuente complementaria necesaria para precisar la semántica de Python.
- FastAPI Best Practices — Async Routes
Encabezados Async Routes, I/O Intensive Tasks y CPU Intensive Tasks: ejemplos de bloqueo dentro de rutas async y límites para cargas CPU-bound. Se usa como advertencia práctica; la documentación oficial de FastAPI prevalece.