Futures, Awaitables y Errores Comunes en asyncio

Futures, Awaitables y Errores Comunes en asyncio

Tabla de Contenido

En los artículos anteriores aprendimos a crear coroutines con async/await y ejecutar tasks concurrentes. En este artículo vamos a cerrar el modelo mental: entenderemos qué son los futures, cómo se relacionan con tasks y coroutines a través de los awaitables, y veremos cómo medir el rendimiento de nuestro código asíncrono. Para cerrar, hablaremos de los dos errores más comunes al trabajar con asyncio.

Futures: Un Valor que Aún No Existe

Un future es un objeto de Python que representa un valor que esperamos obtener en algún momento, pero que todavía no tenemos. Cuando se crea, un future está en estado incompleto. Una vez que el valor llega, se establece con set_result y el future pasa a estar completado.

from asyncio import Future

my_future = Future()

print(f'¿Está completo? {my_future.done()}')  # False

my_future.set_result(42)

print(f'¿Está completo? {my_future.done()}')   # True
print(f'Resultado: {my_future.result()}')       # 42
stateDiagram-v2 [*] --> Incompleto: Future() Incompleto --> Completado: set_result(valor) Completado --> [*]: result() → valor note right of Incompleto done() → False result() → InvalidStateError end note note right of Completado done() → True result() → valor end note

Futures en la práctica

En la mayoría de los casos no crearás futures directamente — asyncio los gestiona internamente. Pero entender su mecánica es clave para comprender cómo funciona todo por dentro. Veamos un ejemplo donde un task establece el valor de un future después de un segundo:

from asyncio import Future
import asyncio


def hacer_peticion() -> Future:
    future = Future()
    asyncio.create_task(establecer_valor(future))  # 1
    return future


async def establecer_valor(future) -> None:
    await asyncio.sleep(1)                          # 2
    future.set_result(42)


async def main():
    future = hacer_peticion()
    print(f'¿Está completo? {future.done()}')       # False
    valor = await future                             # 3
    print(f'¿Está completo? {future.done()}')       # True
    print(valor)                                     # 42


asyncio.run(main())
  1. Crea un task que configurará el valor del future.
  2. Espera 1 segundo antes de establecer el resultado.
  3. await pausa main() hasta que el future reciba su valor.
sequenceDiagram participant M as main() participant F as Future participant T as task: establecer_valor() M->>F: hacer_peticion() → Future incompleto M->>T: create_task(establecer_valor(future)) M->>F: await future Note over M: ⏸ PAUSE main() T->>T: await asyncio.sleep(1) Note over T: ⏳ 1 segundo... T->>F: set_result(42) Note over F: ✅ Completado F-->>M: ▶ RESUME main() Note over M: valor = 42

La Jerarquía de los Awaitables

Tanto las coroutines como los tasks pueden usarse con await. ¿Qué tienen en común? Ambos son awaitables — objetos que implementan el método __await__, que es lo que Python necesita para poder usar la palabra clave await sobre ellos.

La relación entre estos conceptos forma una jerarquía clara:

flowchart TD A["Awaitable\n(__await__)"] --> B["Coroutine"] A --> C["Future"] C --> D["Task"] style A fill:#8b5cf6,stroke:#5b21b6,color:#fff style B fill:#3b82f6,stroke:#1e40af,color:#fff style C fill:#f59e0b,stroke:#b45309,color:#fff style D fill:#10b981,stroke:#065f46,color:#fff
  • Awaitable es la clase base que define el protocolo __await__. Todo lo que se pueda usar con await es un awaitable.
  • Coroutine es una función definida con async def. Hereda de Awaitable.
  • Future representa un valor pendiente. También hereda de Awaitable.
  • Task hereda de Future. Es la combinación de un future y una coroutine: encapsula una coroutine y la ejecuta en el event loop, mientras que el future subyacente almacena el resultado cuando la coroutine termina.

Note

Un Future es qué esperamos obtener. Un Task es cómo lo obtenemos (ejecutando una coroutine). Y Awaitable es el contrato que ambos cumplen para que await funcione.

Midiendo el Tiempo de Ejecución

Cuando trabajamos con código asíncrono, medir cuánto tarda cada operación es esencial para verificar que la concurrencia realmente funciona. La mejor forma de hacerlo es con un decorador que registre el tiempo de inicio y fin de cada coroutine:

import functools
import time
from typing import Callable, Any


def async_timed():
    def wrapper(func: Callable) -> Callable:
        @functools.wraps(func)
        async def wrapped(*args, **kwargs) -> Any:
            print(f'starting {func} with args {args} {kwargs}')
            start = time.time()
            try:
                return await func(*args, **kwargs)
            finally:
                end = time.time()
                total = end - start
                print(f'finished {func} in {total:.4f} second(s)')

        return wrapped

    return wrapper

Uso en la práctica

import asyncio


@async_timed()
async def delay(delay_seconds: int) -> int:
    print(f'sleeping for {delay_seconds} second(s)')
    await asyncio.sleep(delay_seconds)
    print(f'finished sleeping for {delay_seconds} second(s)')
    return delay_seconds


@async_timed()
async def main():
    task_one = asyncio.create_task(delay(2))
    task_two = asyncio.create_task(delay(3))

    await task_one
    await task_two


asyncio.run(main())
starting <function main ...> with args () {}
starting <function delay ...> with args (2,) {}
starting <function delay ...> with args (3,) {}
finished <function delay ...> in 2.0032 second(s)
finished <function delay ...> in 3.0003 second(s)
finished <function main ...> in 3.0004 second(s)

El output confirma lo que esperamos: ambos tasks corren concurrentemente. delay(2) termina a los ~2 segundos, delay(3) a los ~3, y main() tarda ~3 segundos en total (no 5).

gantt title Ejecución concurrente verificada con @async_timed dateFormat X axisFormat %s section delay(2) sleep(2) :a1, 0, 2 section delay(3) sleep(3) :b1, 0, 3 section main() Tiempo total :c1, 0, 3

Errores Comunes en asyncio

Hay dos trampas frecuentes que pueden arruinar el rendimiento de una aplicación async. Ambas tienen la misma causa: bloquear el event loop.

1. Ejecutar código CPU-bound en coroutines

Cuando tenemos funciones computacionalmente costosas (cálculos matemáticos, procesamiento de datos grandes), puede parecer buena idea envolverlas en tasks para ejecutarlas concurrentemente. Pero recuerda: asyncio usa un solo hilo. El GIL garantiza que solo una instrucción de bytecode se ejecute a la vez, así que el código CPU-bound bloqueará el event loop sin importar cuántos tasks crees.

flowchart LR A["Código CPU-bound\nen una coroutine"] -->|"Bloquea el\nevent loop 🛑"| B["Otras tareas\nno pueden ejecutarse"] C["Solución"] --> D["multiprocessing\no run_in_executor()"] style A fill:#ef4444,stroke:#991b1b,color:#fff style B fill:#ef4444,stroke:#991b1b,color:#fff style D fill:#10b981,stroke:#065f46,color:#fff

2. Usar APIs bloqueantes

El mismo problema ocurre al usar librerías de I/O que no son asíncronas. Librerías como requests o funciones como time.sleep bloquean el hilo principal. Cuando las llamas dentro de una coroutine, bloqueas el event loop completo y ninguna otra coroutine puede ejecutarse.

  • ❌ Bloqueante
  • ✅ No bloqueante

No uses estas funciones dentro de coroutines

  • requests.get() → bloquea el event loop
  • time.sleep() → bloquea el event loop
  • Cualquier función de I/O síncrona

Usa las alternativas asíncronas

  • aiohttp → reemplazo async de requests
  • asyncio.sleep() → cede control al event loop
  • asyncpg → cliente async de PostgreSQL
  • aiofiles → lectura/escritura async de archivos

Warning

Regla general: cualquier función que realice I/O y no sea una coroutine, o que ejecute operaciones CPU costosas, debe considerarse bloqueante. Para CPU-bound, usa loop.run_in_executor() con un ProcessPoolExecutor.

En conclusión

  • Un Future representa un valor que aún no existe — se completa con set_result.
  • Un Task es un future + una coroutine: ejecuta la coroutine y almacena su resultado en el future subyacente.
  • Todo lo que se pueda usar con await es un Awaitable — el contrato que unifica coroutines, futures y tasks.
  • El decorador @async_timed permite verificar que la concurrencia funciona midiendo tiempos reales.
  • Los dos errores más comunes son ejecutar código CPU-bound y usar APIs bloqueantes dentro de coroutines — ambos bloquean el event loop.

Referencias