Kodokon kodokon.com

Asincronía con asyncio: corrutinas, await, gather

Comprende la concurrencia cooperativa de asyncio: corrutinas perezosas, puntos de suspensión, gather, TaskGroup y las trampas de las llamadas bloqueantes.

10 min · 3 preguntas

Abrir esta lección en Kodokon

asyncio implementa una concurrencia cooperativa en un único hilo: un bucle de eventos orquesta corrutinas que ceden el control voluntariamente en cada await. Un punto fundamental, a menudo mal entendido: llamar a una función async def no ejecuta nada. Construye un objeto corrutina inerte cuyo cuerpo solo avanza cuando se espera con await o se planifica como tarea en el bucle.

PYTHON
import asyncio

async def greet() -> str:
    return "hello"

coro = greet()
print(type(coro).__name__)

async def main() -> None:
    print(await coro)

asyncio.run(main())
Llamar a greet() crea un objeto corrutina; nada se ejecuta antes del await.

await marca un punto de suspensión: la corrutina se congela ahí, el bucle recupera el control y hace avanzar otra tarea lista. Entre dos await, tu código se ejecuta sin interrupción - no hacen falta cerrojos para la mayoría de las estructuras. Para lanzar varias corrutinas en paralelo (en el sentido de E/S), envuélvelas en asyncio.gather: tres esperas de un segundo terminan en un segundo en total, no en tres.

PYTHON
import asyncio
import time

async def fetch(name: str, delay: float) -> str:
    await asyncio.sleep(delay)
    return f"{name} done"

async def main() -> None:
    start = time.perf_counter()
    results = await asyncio.gather(
        fetch("a", 1.0),
        fetch("b", 1.0),
        fetch("c", 1.0),
    )
    elapsed = time.perf_counter() - start
    print(results)
    print(f"total: {elapsed:.1f} s")

asyncio.run(main())
Tres esperas concurrentes: alrededor de 1 segundo en total.

Por defecto, gather propaga la primera excepción lanzada pero deja que las demás tareas sigan ejecutándose en segundo plano - rara vez es lo que quieres. Con return_exceptions=True, las excepciones se devuelven como resultados, para clasificarlas después. Desde Python 3.11, prefiere asyncio.TaskGroup: al primer error, todas las tareas hermanas se cancelan limpiamente, y las posibles excepciones múltiples emergen agrupadas en un ExceptionGroup, que se captura con except*.

PYTHON
import asyncio
import time

def blocking_io() -> str:
    time.sleep(1)
    return "report written"

async def main() -> None:
    results = await asyncio.gather(
        asyncio.to_thread(blocking_io),
        asyncio.sleep(1, result="tick"),
    )
    print(results)

asyncio.run(main())
to_thread aísla el código bloqueante: el bucle sigue respondiendo.

Prueba de conocimientos

Comprueba que has retenido los puntos clave de esta lección.

  1. ¿Qué produce la llamada fetch('a', 1.0) escrita por sí sola, sin await ni create_task?
    • Un objeto corrutina inerte: no se ejecuta ni una sola línea del cuerpo de la función
    • La ejecución inmediata y síncrona de la corrutina
    • Una tarea planificada automáticamente en el bucle de eventos
    • Un SyntaxError, porque await es obligatorio
  2. Tres asyncio.sleep(1) lanzados juntos mediante asyncio.gather: ¿qué duración total?
    • Alrededor de 3 segundos, ya que un único hilo ejecuta el código
    • Imposible de predecir sin conocer el número de núcleos
    • Alrededor de 1 segundo: las esperas se solapan, el bucle pasa de una tarea a otra
  3. ¿Por qué está prohibido time.sleep(2) dentro de una corrutina?
    • Lanza una excepción en un contexto asíncrono
    • Bloquea el hilo del bucle de eventos: ninguna otra tarea avanza durante 2 segundos
    • Simplemente es menos preciso que asyncio.sleep
    • Consume el doble de memoria