El event loop de asyncio: qué es y cómo funciona

El event loop es un bucle que corre indefinidamente preguntando: “¿hay algo listo para ejecutar?”. No es un scheduler de threads ni un pool de procesos — es un único hilo de ejecución que alterna entre tareas usando las llamadas de sistema del SO para saber cuándo una operación I/O está lista. Esa distinción es fundamental para entender por qué asyncio escala bien con miles de conexiones sin el overhead de threads.

Cuando una coroutine hace await sobre una operación I/O, no bloquea el proceso. Lo que ocurre es: la coroutine se suspende en ese punto exacto, el event loop la deja aparcada, y sigue ejecutando cualquier otra tarea que esté lista. Internamente, el loop usa select, epoll (Linux) o kqueue (macOS/BSD) para monitorear file descriptors — los descriptores numéricos que el SO usa para representar sockets, archivos, pipes. Cuando el SO notifica que un descriptor tiene datos disponibles, el loop reanuda la coroutine que estaba esperando ese resultado.

¿Por qué un solo event loop por thread? Porque el modelo entero asume que el estado compartido no necesita locks: solo hay una cosa ejecutándose en cada instante. Si creas dos loops en el mismo thread y los haces competir, rompes esa garantía. La regla es simple: un thread, un event loop.

¿Cuándo usas esto directamente? La mayoría del tiempo no tocas el loop explícitamente — asyncio.run() lo crea, lo lanza con tu coroutine principal, y lo cierra al terminar. Lo necesitas de forma explícita cuando integras asyncio con frameworks externos, cuando debuggeas, o cuando necesitas scheduling de bajo nivel.

Si ejecutas código bloqueante (una llamada requests.get(), time.sleep(), leer un archivo con open()) dentro de una coroutine sin envolverlo adecuadamente, bloqueas el loop completo — todas las demás coroutines se congelan hasta que esa operación termine. El loop no puede hacer magia si el hilo está ocupado esperando al SO de forma síncrona.

import asyncio
import time


async def fetch_data(label: str, delay: float) -> str:
    """Simula una operación I/O que tarda `delay` segundos."""
    print(f"[{label}] iniciando (t={time.monotonic():.2f})")
    await asyncio.sleep(delay)  # suspende esta coroutine, libera el loop
    result = f"{label}: datos listos tras {delay}s"
    print(f"[{label}] terminó (t={time.monotonic():.2f})")
    return result


async def main() -> None:
    start = time.monotonic()

    # Crear las tareas antes de awaitar permite que corran concurrentemente.
    # Si hiciéramos await fetch_data(...) una tras otra, serían secuenciales.
    task_a = asyncio.create_task(fetch_data("A", delay=2.0))
    task_b = asyncio.create_task(fetch_data("B", delay=1.0))
    task_c = asyncio.create_task(fetch_data("C", delay=1.5))

    # gather espera a que todas terminen y devuelve resultados en orden.
    results = await asyncio.gather(task_a, task_b, task_c)

    elapsed = time.monotonic() - start
    print(f"\nResultados: {results}")
    # El tiempo total es ~2s (el delay más largo), no 4.5s (suma de todos).
    print(f"Tiempo total: {elapsed:.2f}s  — no {2.0 + 1.0 + 1.5}s")


asyncio.run(main())

Lo que está pasando bajo el capó

Cuando asyncio.run(main()) arranca, crea un event loop fresco y le pasa el control a main. La primera línea interesante es asyncio.create_task(): esto no ejecuta fetch_data inmediatamente — registra la coroutine en el loop como una tarea pendiente. En ese momento el loop sabe que hay tres coroutines que quieren ejecutarse.

Cuando main llega a await asyncio.gather(...), se suspende a sí misma y cede el control. El loop ahora tiene libertad de avanzar con las tres tareas. Las tres arrancan rápidamente hasta su propio await asyncio.sleep(delay). Cada una de esas llamadas traduce el delay a un descriptor de tiempo que el loop monitorea con epoll/kqueue. Las tres quedan suspendidas simultáneamente — esto es la concurrencia sin paralelismo: un solo hilo, múltiples coroutines esperando.

A ~1 segundo, el loop detecta que el timer de B expiró, reanuda esa coroutine, imprime el mensaje y la marca como completada. A ~1.5s hace lo mismo con C, y a ~2s con A. Solo entonces gather recibe las tres respuestas, reanuda main, y el tiempo total refleja el delay más largo, no la suma.

asyncio.run() es la forma moderna (Python 3.7+) de gestionar este ciclo completo. Antes se hacía loop = asyncio.get_event_loop(); loop.run_until_complete(main()) manualmente, con más superficie para errores — especialmente en aplicaciones que abrían y cerraban loops a mano.

Errores que debes conocer

Error: Llamar código bloqueante directamente en una coroutine congela el loop entero, porque el hilo no puede atender nada más mientras espera.

import asyncio
import requests  # librería síncrona

# ❌ Wrong
async def get_url(url: str) -> str:
    response = requests.get(url)  # bloquea el hilo, el loop se congela
    return response.text

# ✅ Right
async def get_url(url: str) -> str:
    # Corre el código bloqueante en un thread pool separado
    loop = asyncio.get_running_loop()
    response = await loop.run_in_executor(None, requests.get, url)
    return response.text

run_in_executor delega la llamada síncrona a un ThreadPoolExecutor, liberando el event loop para seguir atendiendo otras coroutines mientras el thread espera.


Error: Usar asyncio.get_event_loop() en lugar de asyncio.get_running_loop() dentro de una coroutine activa puede devolver un loop diferente o generar un DeprecationWarning en Python 3.10+.

# ❌ Wrong
async def do_something() -> None:
    loop = asyncio.get_event_loop()  # puede no ser el loop actual
    loop.call_soon(some_callback)

# ✅ Right
async def do_something() -> None:
    loop = asyncio.get_running_loop()  # siempre el loop que te está ejecutando
    loop.call_soon(some_callback)

get_running_loop() lanza RuntimeError si no hay ningún loop corriendo — que es exactamente lo que quieres: falla rápido y claro en vez de crear silenciosamente un loop huérfano.


Error: Crear una coroutine sin hacer await ni create_task hace que nunca se ejecute, y Python solo avisa con un RuntimeWarning que es fácil de ignorar.

# ❌ Wrong
async def main() -> None:
    fetch_data("A", 1.0)  # crea el objeto coroutine, no lo ejecuta

# ✅ Right
async def main() -> None:
    await fetch_data("A", 1.0)           # ejecuta y espera
    # o bien:
    asyncio.create_task(fetch_data("A", 1.0))  # ejecuta concurrentemente

Una coroutine es un objeto generador perezoso hasta que algo la consume — await o create_task son las dos formas de decirle al loop que la ejecute.

148

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio