Coroutines y async/await: Tu Primer Código Asíncrono en Python

Coroutines y async/await: Tu Primer Código Asíncrono en Python

Tabla de Contenido

En los artículos anteriores construimos la base teórica: concurrencia vs paralelismo , procesos, hilos, el GIL y el event loop . Ahora es momento de escribir código. En este artículo vamos a aprender a crear coroutines, usar las palabras clave async y await, y entender exactamente qué pasa cuando pausamos y reanudamos la ejecución.

¿Qué es una Coroutine?

Una coroutine es como una función normal de Python, pero con un superpoder: puede pausar su ejecución cuando encuentra una operación que podría tardar (una petición de red, una lectura de disco, una espera). Mientras la coroutine está pausada, el event loop puede ejecutar otras tareas. Cuando la operación termina, la coroutine se “despierta” y continúa desde donde se detuvo.

Esta capacidad de pausar y reanudar es lo que le da concurrencia a nuestra aplicación.

Para trabajar con coroutines necesitamos dos palabras clave:

  • async def → define una coroutine.
  • await → pausa la coroutine hasta que la operación esperada termine.

Creando Coroutines

Crear una coroutine es casi idéntico a crear una función normal. La única diferencia es usar async def en lugar de def:

async def mi_coroutine() -> None:
    print('¡Hola mundo!')

Info

Llamar a una coroutine no la ejecuta. Solo produce un objeto coroutine. Para ejecutarla, necesitas un event loop.

# ❌ Esto NO ejecuta la coroutine
mi_coroutine()  # Retorna un objeto coroutine, no imprime nada

# ✅ Esto SÍ la ejecuta
import asyncio
asyncio.run(mi_coroutine())  # Imprime: ¡Hola mundo!

Ejecutando Coroutines con asyncio.run

asyncio.run es el punto de entrada principal de cualquier aplicación asyncio. Veamos un ejemplo concreto:

import asyncio


async def sumar_uno(numero: int) -> int:
    return numero + 1


resultado = asyncio.run(sumar_uno(1))
print(resultado)  # 2

asyncio.run hace varias cosas por nosotros:

  1. Crea un event loop nuevo.
  2. Ejecuta la coroutine que le pasamos hasta que se completa.
  3. Retorna el resultado de la coroutine.
  4. Limpia cualquier tarea pendiente y cierra el event loop.
flowchart LR A["asyncio.run(coroutine)"] --> B["Crea el\nEvent Loop"] B --> C["Ejecuta la\ncoroutine"] C --> D["Retorna\nel resultado"] D --> E["Cierra el\nEvent Loop"] style A fill:#3b82f6,stroke:#1e40af,color:#fff style C fill:#10b981,stroke:#065f46,color:#fff style E fill:#64748b,stroke:#334155,color:#fff

Info

asyncio.run está diseñado para ejecutarse una sola vez como entrada principal de tu aplicación. Esa única coroutine debería encargarse de lanzar todas las demás tareas.

Pausando la Ejecución con await

El verdadero poder de asyncio está en await. Esta palabra clave hace dos cosas:

  1. Ejecuta la coroutine que le sigue (a diferencia de llamarla directamente, que solo crea el objeto).
  2. Pausa la coroutine contenedora hasta que la coroutine esperada retorne un resultado.

Cuando la coroutine esperada termina, la coroutine contenedora se “despierta” y puede usar el resultado.

import asyncio


async def obtener_saludo() -> str:
    return '¡Hola desde una coroutine!'


async def main() -> None:
    saludo = await obtener_saludo()  # Pausa main() hasta obtener el resultado
    print(saludo)


asyncio.run(main())

Simulando Operaciones Largas con asyncio.sleep

Para simular una operación de I/O de larga duración (como una petición HTTP o una consulta a base de datos), podemos usar asyncio.sleep. Esta función pausa la coroutine por el número de segundos indicado, cediendo el control al event loop.

Veamos un ejemplo más completo con una función auxiliar delay:

import asyncio


async def delay(seconds: int) -> str:
    print(f'Esperando {seconds} segundo(s)...')
    await asyncio.sleep(seconds)
    print(f'¡Espera de {seconds} segundo(s) completada!')
    return f'Esperé {seconds} segundo(s)'

La Trampa del await Secuencial

Ahora veamos un error muy común al empezar con asyncio. Considera este código:

import asyncio


async def sumar_uno(numero: int) -> int:
    return numero + 1


async def mensaje_hola_mundo() -> str:
    await asyncio.sleep(1)
    return '¡Hola Mundo!'


async def main() -> None:
    mensaje = await mensaje_hola_mundo()  # Pausa 1 segundo
    uno_mas_uno = await sumar_uno(1)      # Ejecuta después de la pausa
    print(uno_mas_uno)
    print(mensaje)


asyncio.run(main())

Cuando ejecutamos este código, pasa 1 segundo antes de que se impriman ambos resultados. Pero sumar_uno(1) es instantánea — ¿por qué espera?

Warning

await pausa toda la coroutine contenedora. No se ejecuta ningún otro código dentro de main() hasta que la expresión await retorne un valor. Como mensaje_hola_mundo() tarda 1 segundo, main() queda pausada ese segundo completo antes de siquiera llegar a sumar_uno(1).

El siguiente diagrama muestra exactamente qué pasa en cada momento:

sequenceDiagram participant EL as Event Loop participant M as main() participant HW as mensaje_hola_mundo() participant AO as sumar_uno(1) EL->>M: ▶ RUN main() Note over M: await mensaje_hola_mundo() M->>HW: ▶ RUN mensaje_hola_mundo() HW->>HW: await sleep(1) Note over M: ⏸ PAUSE main() Note over HW: ⏸ PAUSE mensaje_hola_mundo() Note over EL: ⏳ Pasa 1 segundo... EL->>HW: ▶ RESUME mensaje_hola_mundo() Note over HW: return '¡Hola Mundo!' HW-->>M: ▶ RESUME main() Note over M: mensaje = '¡Hola Mundo!' Note over M: await sumar_uno(1) M->>AO: ▶ RUN sumar_uno(1) Note over AO: return 1 + 1 AO-->>M: ▶ RESUME main() Note over M: uno_mas_uno = 2 Note over M: print() resultados

El problema visualizado

El flujo de ejecución es completamente secuencial, a pesar de usar async/await:

gantt title Ejecución secuencial — Sin concurrencia real dateFormat X axisFormat %s section main() await mensaje_hola_mundo() :a1, 0, 10 await sumar_uno(1) :a2, 10, 11 print() resultados :a3, 11, 12

Lo que queremos es que sumar_uno(1) se ejecute mientras mensaje_hola_mundo() espera su segundo de delay. Pero con await secuencial eso no es posible — cada await bloquea la coroutine hasta completarse.

¿Cómo logramos concurrencia real?

Para ejecutar múltiples coroutines de forma concurrente necesitamos herramientas como asyncio.gather o asyncio.create_task. Un adelanto rápido:

async def main() -> None:
    # ✅ Ambas coroutines corren concurrentemente
    mensaje, uno_mas_uno = await asyncio.gather(
        mensaje_hola_mundo(),
        sumar_uno(1)
    )
    print(uno_mas_uno)
    print(mensaje)

Con gather, el event loop puede ejecutar sumar_uno(1) mientras mensaje_hola_mundo() está pausada esperando su sleep. El tiempo total se mantiene en ~1 segundo en lugar de sumar ambos tiempos.

gantt title Ejecución concurrente — Con asyncio.gather dateFormat X axisFormat %s section mensaje_hola_mundo() await sleep(1) :a1, 0, 10 section sumar_uno(1) return 1 + 1 :a2, 0, 1 section main() print() resultados :a3, 10, 11

En conclusión

flowchart TD A["async def"] -->|"Define"| B["Coroutine"] B -->|"Se ejecuta con"| C["asyncio.run()"] B -->|"Se pausa con"| D["await"] D -->|"Cede control al"| E["Event Loop"] E -->|"Cuando I/O completa"| F["Reanuda la coroutine"] C -->|"Crea y gestiona"| E style A fill:#8b5cf6,stroke:#5b21b6,color:#fff style B fill:#3b82f6,stroke:#1e40af,color:#fff style D fill:#f59e0b,stroke:#b45309,color:#fff style E fill:#10b981,stroke:#065f46,color:#fff
  • async def define una coroutine — una función que puede pausarse.
  • await pausa la coroutine actual hasta que la operación esperada termine.
  • asyncio.run crea el event loop y ejecuta la coroutine principal.
  • asyncio.sleep simula operaciones de I/O de larga duración.
  • Usar await de forma secuencial no da concurrencia — para eso necesitamos asyncio.gather o asyncio.create_task.

Referencias