El Modelo Mental del Bucle de Eventos
La concurrencia de Asyncio confunde a las personas que ya conocen los hilos, porque desde fuera parece paralelismo pero funciona bajo un principio completamente diferente.
Todas las demás páginas de esta sección (coroutines, tasks, gather, structured concurrency, sync/async bridging) son en realidad un solo mecanismo visto desde un ángulo diferente: un único programador ejecutando una pieza de código Python a la vez, cambiando a otra pieza exactamente cuando la actual dice que no tiene nada útil que hacer en este momento.
Entender ese programador, no memorizar la superficie de la API, es lo que hace que asyncio.gather frente a asyncio.TaskGroup frente a asyncio.to_thread parezcan una idea coherente en lugar de tres herramientas no relacionadas que hay que consultar.
Resumen
- Asyncio logra la concurrencia en un solo hilo del sistema operativo ejecutando exactamente el código de una corrutina a la vez y cambiando a otra cuando la actual está esperando algo externo, no ejecutando código en paralelo.
- Por Qué Importa: Malinterpretar este modelo lleva a dos errores opuestos: esperar un paralelismo de CPU que asyncio nunca proporciona, o escribir una función de aspecto síncrono que bloquea silenciosamente a todas las demás corrutinas que comparten el bucle.
- Conceptos Clave: bucle de eventos, corrutina, awaitable, Task, programación cooperativa, concurrencia estructurada.
- Cuándo Usar: Trabajo limitado por E/S con muchas esperas concurrentes (llamadas de red, consultas a bases de datos, operaciones de archivos) donde el costo es principalmente la espera, no la computación.
- Limitaciones / Compensaciones: Asyncio no paraleliza el trabajo limitado por CPU, requiere que cada biblioteca en la cadena de llamadas coopere (una sola llamada bloqueante detiene todo el bucle) y hace que el flujo de control sea más difícil de razonar que el código síncrono lineal.
- Temas Relacionados: threading, multiprocessing, structured concurrency, el Global Interpreter Lock (GIL).
Fundamentos
Una corrutina es lo que obtienes al llamar a una función async def, no el resultado de ejecutarla, sino un objeto de función pausado en su primera línea, esperando a ser impulsado hacia adelante.
Nada dentro de una corrutina se ejecuta realmente hasta que algo la espera o la programa como una Task.
El bucle de eventos es la pieza que hace el impulso: mantiene una cola de corrutinas y callbacks listas para ejecutarse, ejecuta una hasta que encuentra un await en algo que aún no ha terminado, y luego pasa al siguiente elemento listo en la cola.
await es el punto exacto de traspaso: significa "No tengo nada útil que hacer hasta que esto termine, así que deja que otra cosa se ejecute mientras tanto", que es todo el mecanismo sobre el que descansa la programación cooperativa.
Una analogía útil es un solo chef dirigiendo varias ollas en una sola estufa: el chef revuelve la olla A, luego mientras la olla A hierve sin supervisión, el chef pasa a la olla B, luego a la olla C, siempre haciendo exactamente una cosa en cualquier instante, pero nunca quedándose inactivo esperando a que una sola olla termine.
Eso es fundamentalmente diferente de contratar tres chefs (hilos) que pueden revolver genuinamente tres ollas al mismo tiempo, o alquilar tres cocinas (procesos) que ni siquiera comparten una estufa.
Asyncio es el modelo de un solo chef: eficiente porque el chef nunca está inactivo mientras otra cosa podría progresar, pero aún limitado por el hecho de que hay exactamente un chef haciendo exactamente una cosa a la vez.
Mecánicas e Interacciones
La cola de listos del bucle es el mecanismo que convierte "muchas esperas pendientes" en "alto rendimiento en un solo hilo": una corrutina esperando una lectura de socket no ocupa el bucle mientras la red está en silencio, registra interés y se aparta, y el bucle gasta ese tiempo ejecutando otras corrutinas cuyos datos ya han llegado.
Una Task es lo que envuelve una corrutina para que el bucle pueda rastrearla y reanudarla de forma independiente: asyncio.create_task programa una corrutina para que se ejecute en el bucle sin que el llamador la espere inmediatamente, que es lo que hace posible la ejecución concurrente de múltiples corrutinas en primer lugar.
Aquí es también donde las dos formas de ejecutar varias corrutinas juntas divergen realmente, y es una fuente común de sorpresa: asyncio.gather no cancela los otros awaitables cuando uno de ellos lanza una excepción; por defecto, propaga inmediatamente esa primera excepción mientras que los demás siguen ejecutándose en segundo plano, sin supervisión, a menos que el llamador haga algo al respecto.
asyncio.TaskGroup (3.11+) existe específicamente para cerrar esa brecha: vincula la vida de cada tarea hija al bloque async with que la contiene, y si un hijo lanza una excepción, el grupo cancela activamente a sus hermanos antes de que el bloque salga, una diferencia de comportamiento real, no solo una sintaxis más agradable sobre la misma garantía.
"Bloquear el bucle" es el fallo de modelo mental más importante en el código asyncio: cualquier llamada síncrona que no ceda (un bucle de CPU intensivo, una lectura de archivo bloqueante, un controlador de base de datos síncrono) ocupa al chef por completo, por lo que todas las demás corrutinas programadas en ese bucle se detienen durante exactamente el tiempo que dura esa llamada, no solo la corrutina que la hizo.
import asyncio
async def blocks_everyone() -> None:
total = 0
for i in range(50_000_000): # CPU pura, nunca cede
total += i
# todas las demás corrutinas en este bucle esperaron todo el tiempo
async def cooperates() -> None:
await asyncio.to_thread(lambda: sum(range(50_000_000)))
# el trabajo de CPU se ejecutó en un hilo de trabajo; el bucle siguió sirviendo a otrosEl puente síncrono/asíncrono (asyncio.to_thread, loop.run_in_executor, asyncio.run) es el mecanismo para cruzar entre "código que el bucle está dirigiendo" y "código que no puede cooperar", en ambas direcciones: descargar una llamada bloqueante a un pool de hilos para que el bucle se mantenga libre, o entrar al bucle desde código síncrono ordinario a través de asyncio.run.
Consideraciones Avanzadas y Aplicaciones
Una sutileza que confunde a ingenieros experimentados que pasan de hilos a asyncio: hay exactamente un bucle de eventos por hilo. asyncio.run crea uno, lo ejecuta hasta su finalización y lo cierra, y no puedes anidar una segunda llamada a asyncio.run dentro de un bucle en ejecución; los servidores en su lugar mantienen un bucle de larga duración para la vida del proceso en lugar de crear uno por solicitud.
La equidad de la programación del bucle también depende completamente de que cada corrutina ceda en intervalos razonables; una base de código async que se comporta bien es realmente una base de código donde cada operación adyacente a E/S es nativamente async o explícitamente puenteada a un hilo, porque el bucle no puede prevenir una corrutina que se niega a esperar.
Existen implementaciones alternativas de bucles de eventos para el mismo contrato de programación con diferentes características de rendimiento: uvloop, por ejemplo, reemplaza el bucle predeterminado de Python puro con uno respaldado por libuv para E/S de menor latencia, sin cambiar nada sobre el modelo de corrutina/Task/await descrito aquí.
El Global Interpreter Lock (GIL) todavía importa dentro de un solo bucle asyncio: incluso en la compilación experimental de CPython con hilos libres introducida en 3.13 y continuada en 3.14, el contrato de asyncio de "el bytecode de una corrutina se ejecuta a la vez en este bucle" no cambia; los hilos libres afectan al paralelismo multicores real de hilos del sistema operativo separados, no al modelo de programación cooperativa de un solo bucle en sí.
La observabilidad importa más en asyncio que en el código síncrono precisamente porque los fallos son fallos de programación, no solo fallos de lógica: el modo de depuración de asyncio (PYTHONASYNCIODEBUG=1 o asyncio.run(main(), debug=True)) expone callbacks lentos y corrutinas nunca esperadas que de otro modo fallarían silenciosamente.
| Enfoque | Fortaleza | Debilidad | Mejor Ajuste |
|---|---|---|---|
Corrutinas nativas async/await | Baratas de programar, miles de esperas concurrentes en un hilo | Inútiles para trabajo limitado por CPU; cada dependencia debe cooperar | E/S de alto fan-out: APIs, llamadas a DB, recuperaciones de red |
threading | Concurrencia real a nivel de SO para llamadas bloqueantes; modelo mental simple por llamada | GIL todavía serializa el bytecode de Python; cuesta más memoria por trabajador | Puente de una o dos bibliotecas bloqueantes en una aplicación async |
multiprocessing | Ejecución real de CPU en paralelo a través de núcleos | Costo de inicio de proceso pesado, sin memoria compartida, sobrecarga de serialización | Trabajo limitado por CPU: análisis, compresión, lotes numéricos |
asyncio.to_thread / run_in_executor | Mantiene el bucle receptivo mientras una llamada bloqueante se ejecuta en otro lugar | Añade un salto de pool de hilos; todavía no es paralelismo de CPU real | Llamadas bloqueantes aisladas dentro de una base de código mayormente async |
Conceptos Erróneos Comunes
- "asyncio ejecuta mi código en paralelo." Ejecuta el código de una corrutina a la vez en un solo hilo; la concurrencia proviene de cambiar en los puntos
awaitmientras otra cosa espera, no de la ejecución simultánea. - "gather cancela las otras tareas cuando una falla." Por defecto no lo hace; los awaitables restantes siguen ejecutándose a menos que los manejes explícitamente;
TaskGroupes la herramienta que realmente cancela a los hermanos en caso de fallo. - "Crear una Task y seguir adelante está bien, terminará por sí sola." Una tarea huérfana sin referencia y sin nada que la espere puede ser recolectada por el recolector de basura silenciosamente a mitad de camino o simplemente nunca se le revisan las excepciones, lo cual es exactamente la fuga que cierra la concurrencia estructurada (
TaskGroup). - "await asyncio.sleep(0) en realidad no hace nada." Es un punto de cesión genuino: devuelve el control al bucle por una iteración aunque no pase tiempo real, lo cual a veces se usa deliberadamente para permitir que otras corrutinas progresen.
- "Hacer una función async la hace automáticamente más rápida." Solo ayuda cuando la función pasa tiempo real esperando E/S; envolver trabajo limitado por CPU en
async defsin descargarlo a un hilo o proceso no cambia cuánto tiempo tarda y puede empeorar todo el bucle al bloquearlo.
Preguntas Frecuentes
¿Qué es exactamente el bucle de eventos?
El programador que mantiene una cola de corrutinas y callbacks listas para ejecutarse, ejecuta una a la vez y cambia a la siguiente cuando la actual espera algo que aún no ha terminado.
¿Es asyncio realmente paralelo?
No. Es concurrente en un solo hilo: el código de una corrutina se ejecuta en cualquier instante, y el progreso en otras ocurre intercalando en los puntos await, no ejecutándose simultáneamente.
¿Cuál es la diferencia entre una corrutina, una Task y un Future?
Una corrutina es un objeto de función pausado que no hace nada hasta que se espera o se programa. Una Task envuelve una corrutina para que el bucle pueda ejecutarla y rastrearla independientemente del llamador. Un Future es el primitivo de nivel inferior que representa un resultado eventual sobre el cual se construyen las Tasks.
¿Cómo devuelve await realmente el control al bucle?
Cuando una corrutina llega a un await en algo que aún no está listo, se suspende en ese punto y devuelve el control al bucle, que luego ejecuta cualquier otra corrutina lista o callback que esté a continuación en su cola.
¿Por qué solo puedo tener un bucle de eventos por hilo?
El bucle posee la programación del hilo mientras se ejecuta; un segundo bucle en el mismo hilo no tendría forma de intercalarse con el primero, por lo que asyncio impone un bucle activo por hilo y asyncio.run no se anidará dentro de un bucle ya en ejecución.
¿Cuál es la diferencia real entre gather y TaskGroup?
gather ejecuta awaitables concurrentemente y, por defecto, deja que los demás sigan ejecutándose en segundo plano si uno lanza una excepción. TaskGroup vincula la vida de cada hijo a su bloque async with y cancela activamente a los hijos restantes cuando uno falla, proporcionando garantías de concurrencia estructurada más sólidas.
¿Qué significa "bloquear el bucle de eventos"?
Significa ejecutar una operación síncrona dentro de una corrutina que nunca cede (un bucle limitado por CPU, una llamada bloqueante a archivo o base de datos), lo que ocupa el bucle por completo y detiene todas las demás corrutinas programadas en él durante toda la duración de esa llamada.
¿Cuándo debería usar asyncio.to_thread en lugar de simplemente hacer algo async?
Cuando la operación es proporcionada por una biblioteca que no tiene una versión async (un controlador de base de datos bloqueante, un cálculo limitado por CPU, una API síncrona heredada), to_thread la saca del hilo del bucle para que el bucle siga sirviendo a otras corrutinas mientras se ejecuta.
¿Todavía importa el GIL en el código asyncio?
Sí; dentro de un solo bucle, el bytecode de una corrutina se ejecuta a la vez independientemente del estado del GIL, porque el modelo cooperativo de asyncio ya serializa la ejecución; incluso en la compilación CPython con hilos libres (experimental desde 3.13, continuando en 3.14), ese contrato de programación para un solo bucle no cambia.
¿Qué es uvloop y por qué lo usaría?
Es una implementación de bucle de eventos de reemplazo directo respaldada por libuv, que ofrece menor latencia y mayor rendimiento para cargas de trabajo con muchas E/S que el bucle predeterminado de Python puro, sin cambiar el modelo de programación corrutina/Task/await.
¿Cómo depuro problemas de programación en lugar de errores de lógica?
Habilita el modo de depuración de asyncio (PYTHONASYNCIODEBUG=1 o asyncio.run(main(), debug=True)), que expone callbacks lentos, corrutinas nunca esperadas y otros problemas a nivel de programación que un traceback normal no mostraría.
¿Debería reescribir toda mi base de código a asyncio para obtener rendimiento?
Solo si el cuello de botella es el tiempo dedicado a esperar E/S con alta concurrencia; el trabajo limitado por CPU no gana nada solo con asyncio y necesita multiprocessing o una descarga a hilo/proceso independientemente de si el código circundante es async.
¿Por qué una sola llamada bloqueante no esperada en cualquier parte de la cadena de llamadas importa tanto?
Porque el bucle no puede prevenir una corrutina a mitad de ejecución de la misma manera que un programador del sistema operativo previene un hilo: solo recupera el control en un await, por lo que una función que nunca cede bloquea todo el bucle durante toda su duración, no solo su propia unidad de trabajo lógica.
Relacionados
- Fundamentos de Asyncio - ejemplos prácticos de corrutinas, gather y timeouts
- Tasks y Gather - la API Task sobre la que se construye este modelo de programación
- Concurrencia Estructurada - las garantías de cancelación de
TaskGroupen profundidad - Puente Síncrono/Asíncrono -
to_threadyrun_in_executoren la práctica - Depuración de Código Async - diagnóstico de bloqueos y tareas huérfanas
Versiones de Stack: Esta página fue escrita para Python 3.14.0 (estable 3.14, mantenimiento 3.13), FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, y uv 0.6+.