async/await: coroutines versus funciones normales en Python

Cuando escribes async def fetch_data():, no estás definiendo una función que ejecuta código asíncrono; estás definiendo una coroutine function — un objeto callable que, al invocarse, devuelve un coroutine object sin ejecutar ni una sola línea de su cuerpo. Ese detalle cambia todo.

Comparémoslo con una función normal: llamar a fetch_data() ejecuta el cuerpo inmediatamente y devuelve el resultado. Llamar a una coroutine function devuelve un objeto que describe la computación pendiente. Para que esa computación ocurra, alguien tiene que conducirla — y ese alguien es el event loop.

await es la pieza que conecta los dos mundos. Dentro de una async def, await alguna_coroutine() hace dos cosas a la vez: suspende la coroutine actual y le cede el control al event loop para que pueda avanzar otras coroutines mientras espera. Cuando la coroutine esperada termina, el event loop reanuda la suspendida exactamente donde la dejó. Esto es cooperativo, no preemptivo: la suspensión solo ocurre en los puntos await explícitos.

La restricción de que await solo funciona dentro de async def no es arbitraria. El event loop necesita un punto de control para intercalar trabajo; fuera de una coroutine no existe ese contrato, así que Python lo prohíbe en tiempo de compilación.

De aquí nace lo que se llama infección asíncrona: si fetch_data es async, quien la llame tiene que ser async también — o tiene que ser el punto de entrada, donde usas asyncio.run(). asyncio.run() crea un event loop, lo ejecuta hasta que la coroutine dada termine, y lo destruye. Es la única compuerta legítima entre código síncrono y el mundo asíncrono.

import asyncio
import time


async def fetch_item(item_id: int, delay: float) -> str:
    # await sobre asyncio.sleep cede el control al event loop durante `delay`
    # segundos; time.sleep bloquearía el hilo entero — nunca lo uses aquí
    await asyncio.sleep(delay)
    return f"item-{item_id}"


async def fetch_all() -> list[str]:
    # gather lanza las tres coroutines concurrentemente y espera todas
    results = await asyncio.gather(
        fetch_item(1, 0.3),
        fetch_item(2, 0.1),
        fetch_item(3, 0.2),
    )
    return list(results)


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

    # asyncio.run es el único punto de entrada desde código síncrono
    items = asyncio.run(fetch_all())

    elapsed = time.perf_counter() - start
    print(f"Resultados: {items}")
    # si fuera secuencial tomaría 0.6 s; debería acercarse a 0.3 s
    print(f"Tiempo: {elapsed:.2f}s")


if __name__ == "__main__":
    main()

Lo que está pasando línea a línea

fetch_item es una coroutine function. Cuando asyncio.gather la invoca internamente, obtiene tres coroutine objects — no lanza tres hilos, no hace fork. El event loop los agenda como tareas cooperativas sobre un único hilo.

await asyncio.sleep(delay) es el punto de suspensión real. En ese momento el event loop dice “esta coroutine no necesita CPU ahora” y busca otra lista para avanzar. Por eso las tres terminan en ~0.3 s y no en ~0.6 s: el tiempo de espera se solapa.

fetch_all también es async por obligación: usa await, y await solo existe dentro de async def. Si intentaras hacer fetch_all una función normal con await adentro, Python lanzaría un SyntaxError antes de ejecutar nada.

main es síncrona deliberadamente. asyncio.run(fetch_all()) recibe el coroutine object que devuelve llamar a fetch_all() — fíjate en los paréntesis: fetch_all() crea el objeto, asyncio.run lo ejecuta. Si pasaras la función sin invocar (asyncio.run(fetch_all)), obtendrías un TypeError porque asyncio.run espera un awaitable, no un callable.

Errores que debes conocer

Error: llamar a una coroutine function sin await y asumir que se ejecutó.

# ❌ Wrong
async def process():
    fetch_item(1, 0.1)  # devuelve un coroutine object que nadie conduce
    print("done")

# ✅ Right
async def process():
    await fetch_item(1, 0.1)  # el event loop ejecuta la coroutine
    print("done")

Python emite un RuntimeWarning: coroutine 'fetch_item' was never awaited — es la señal de que creaste el objeto pero lo abandonaste sin ejecutarlo.


Error: usar time.sleep dentro de una coroutine en lugar de asyncio.sleep.

# ❌ Wrong
async def fetch_item_blocking(item_id: int, delay: float) -> str:
    time.sleep(delay)   # bloquea el hilo; ninguna otra coroutine avanza
    return f"item-{item_id}"

# ✅ Right
async def fetch_item_ok(item_id: int, delay: float) -> str:
    await asyncio.sleep(delay)  # suspende solo esta coroutine
    return f"item-{item_id}"

time.sleep bloquea el hilo completo, destruyendo cualquier ventaja de concurrencia. La versión con asyncio.sleep suspende solo la coroutine actual y deja libre el event loop.


Error: llamar a asyncio.run() desde dentro de una coroutine (el loop ya existe).

# ❌ Wrong
async def outer():
    # asyncio.run crea un nuevo event loop, pero ya hay uno corriendo
    result = asyncio.run(fetch_item(1, 0.1))  # RuntimeError

# ✅ Right
async def outer():
    result = await fetch_item(1, 0.1)  # simplemente awaita

asyncio.run está diseñado como punto de entrada único desde código síncrono; dentro de una coroutine ya tienes un event loop activo y await es el mecanismo correcto.

149

Dejar un comentario

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

Scroll al inicio