La lección anterior terminó con una pregunta incómoda: los hilos solapan esperas, pero cada hilo cuesta memoria y abre la puerta a condiciones de carrera. ¿Y si hubiera que gestionar 500 consultas? asyncio propone otro modelo: un solo hilo que nunca espera parado. Piensa en el mejor camarero de la cafetería junto a Papyrus: toma nota en la mesa 1, y mientras la cocina prepara ese pedido no se queda mirando la puerta de la cocina — va a la mesa 2, sirve la 3, cobra la 4, y vuelve a la 1 justo cuando su plato sale. Un camarero, muchas mesas, cero tiempo muerto. Eso es la programación asíncrona: las tareas declaran dónde van a esperar (await) y un coordinador (el event loop) aprovecha cada espera para avanzar otra tarea.

Contenido

  1. La idea: un solo hilo que no espera parado
  2. async def y await: corrutinas
  3. El error fundacional: llamar a una corrutina no la ejecuta
  4. asyncio.run(): arrancar el mundo async
  5. asyncio.sleep vs time.sleep: el pecado capital
  6. asyncio.gather: N consultas a la vez, medidas
  7. Asyncio vs hilos: la tabla
  8. async with y async for, brevemente
  9. Honestidad: cuándo compensa y cuándo no
  10. Cierre del módulo 8

async def y await: corrutinas

Una función declarada con async def es una corrutina: una función que puede pausarse en sus esperas y ceder el turno. Si en 08-03 viste que un generador se pausa en cada yield, ya tienes la intuición exacta — las corrutinas se construyeron históricamente sobre esa misma maquinaria de pausar y reanudar:

import asyncio

async def consultar_precio(distribuidora: str, titulo: str) -> tuple[str, float]:
    print(f"→ consultando {titulo} en {distribuidora}...")
    await asyncio.sleep(2)          # "aquí voy a esperar 2 s: aprovecha el turno"
    precios = {"Distribuidora A": 19.90, "Distribuidora B": 21.50,
               "Distribuidora C": 20.10}
    print(f"← {distribuidora} responde")
    return distribuidora, precios[distribuidora]

await marca los puntos de espera: "me detengo aquí; event loop, atiende a otro y despiértame cuando esto esté listo". Como en 08-05, asyncio.sleep(2) simula la espera de red de una consulta real; sigue siendo la forma estándar de aprender esto sin servidores.

El error fundacional: llamarla no la ejecuta

Igual que leer_ventas(ruta) en 08-03 no leía nada (devolvía un generador pausado), llamar a una corrutina no ejecuta su cuerpo:

resultado = consultar_precio("Distribuidora A", "Fausto")
print(resultado)
# <coroutine object consultar_precio at 0x...>   ← ¡no se ha consultado nada!
# RuntimeWarning: coroutine 'consultar_precio' was never awaited

Es el error típico de asyncio: olvidar el await. La llamada crea el objeto corrutina; ejecutarla exige await (desde otra corrutina) o entregársela al event loop. Si ves RuntimeWarning: ... was never awaited en tu terminal, busca la llamada sin await — siempre hay una.

asyncio.run(): arrancar el mundo async

await solo puede escribirse dentro de un async def... entonces, ¿quién ejecuta la primera corrutina? asyncio.run(): crea el event loop (el camarero), le entrega la corrutina principal y no retorna hasta que todo termina:

async def main() -> None:
    dist, precio = await consultar_precio("Distribuidora A", "Fausto")
    print(f"{dist}: {precio} €")

asyncio.run(main())    # el ÚNICO punto de entrada; una vez por programa

La estructura estándar de un script async: corrutinas arriba, un main() asíncrono que las orquesta, y un único asyncio.run(main()) al final (bajo if __name__ == "__main__": si es un módulo, como siempre desde M3).

asyncio.sleep vs time.sleep: el pecado capital

Aquí está la regla que separa el asyncio que funciona del que no. El event loop es un solo hilo: si una corrutina ejecuta algo bloqueante — time.sleep(2), un open().read() gigante, una petición HTTP síncrona — el camarero se queda congelado en esa mesa y todas las demás tareas se detienen con él.

async def consulta_MAL(distribuidora: str) -> None:
    time.sleep(2)             # ☠ bloquea el event loop ENTERO: nadie más avanza

async def consulta_BIEN(distribuidora: str) -> None:
    await asyncio.sleep(2)    # ✔ cede el turno: los demás avanzan mientras espero
time.sleep(2) await asyncio.sleep(2)
¿Quién espera? El hilo entero (el camarero, parado) Solo esa corrutina (esa mesa)
Las demás tareas Congeladas Avanzan
3 consultas "concurrentes" 6 s (¡secuencial disfrazado!) 2 s

Generalización importante: todo lo que uses dentro de asyncio debe ser async. De ahí que el ecosistema tenga bibliotecas paralelas: para HTTP real no sirve el clásico requests (síncrono, bloquea el loop) — se usa aiohttp, que aquí solo mencionamos; las peticiones reales llegan en el módulo 10.

asyncio.gather: N consultas a la vez

await de una en una sigue siendo secuencial (espera A, luego espera B...). Para lanzar varias corrutinas a la vez y recoger todos los resultados, asyncio.gather:

import time

async def main() -> None:
    distribuidoras = ["Distribuidora A", "Distribuidora B", "Distribuidora C"]

    # Versión secuencial: cada await espera a que el anterior termine
    inicio = time.perf_counter()
    for d in distribuidoras:
        await consultar_precio(d, "Fausto")
    print(f"secuencial: {time.perf_counter() - inicio:.1f} s")     # ~6.0 s

    # Versión gather: las tres esperas se solapan en el mismo hilo
    inicio = time.perf_counter()
    resultados = await asyncio.gather(
        *(consultar_precio(d, "Fausto") for d in distribuidoras)   # genexpr de 08-03
    )
    print(f"gather:     {time.perf_counter() - inicio:.1f} s")     # ~2.0 s

    dist, precio = min(resultados, key=lambda r: r[1])
    print(f"la más barata: {dist} a {precio} €")

asyncio.run(main())
secuencial: 6.0 s
gather:     2.0 s
la más barata: Distribuidora A a 19.9 €

gather recibe corrutinas (aquí desempaquetadas con el * de M3 desde una expresión generadora), las ejecuta concurrentemente y devuelve la lista de resultados en el orden de entrada — sin hilos, sin Lock, sin condiciones de carrera: entre await y await nadie te interrumpe, así que el contador de ventas de 08-05 aquí no se corrompería. Si una corrutina lanza, gather propaga la excepción (y con return_exceptions=True te las entrega como valores para tratarlas una a una, al estilo de los futuros de 08-05).

Asyncio vs hilos

Mismo tiempo (~2 s) en el ejemplo de las distribuidoras — entonces, ¿cuál usar?

Criterio Hilos (ThreadPoolExecutor) asyncio
Modelo Varios hilos; el SO decide los turnos Un hilo; las tareas ceden turno en await
Condiciones de carrera Posibles en cualquier línea → Lock Solo en los await (mucho más predecibles)
Escala razonable Decenas de tareas Miles de tareas (una conexión = una corrutina barata)
¿Funciona con bibliotecas normales (síncronas)? — su gran ventaja No: exige bibliotecas async (aiohttp, no requests)
¿Acelera CPU pura? No (GIL) No (¡un solo hilo!) — para CPU, procesos (08-05)
Estilo de código Funciones normales async/await por todas partes (es contagioso)

Regla práctica: pocas tareas de E/S con bibliotecas clásicas → hilos, que se integran sin reescribir nada. Muchísimas conexiones simultáneas, o un ecosistema ya async → asyncio. CPU → procesos, siempre.

async with y async for

Existen versiones asíncronas de dos viejos amigos: async with para administradores de contexto cuya entrada/salida espera E/S (una conexión de red que tarda en abrirse — el protocolo es __aenter__/__aexit__, espejo de 08-04), y async for para iterar fuentes que producen valores con espera entre ellos. Te bastará con reconocerlos cuando los veas en documentación de bibliotecas async; no los necesitas hasta que trabajes con ellas.

Honestidad: cuándo compensa

Seamos claros, porque el async tiene mucho marketing: para Papyrus — un script que consulta 3 distribuidoras dos veces al día — asyncio no compensa. El ThreadPoolExecutor de 08-05 resuelve lo mismo, con menos conceptos nuevos y sin exigir bibliotecas especiales. Incluso la versión secuencial de 6 segundos es defendible: es la más simple y 6 segundos dos veces al día no duelen (la última fila de la tabla de decisión de 08-05 sigue vigente).

Donde asyncio brilla de verdad es donde los hilos no llegan: servidores con miles de conexiones simultáneas — mil clientes esperando respuesta son mil corrutinas baratas durmiendo en sus await, no mil hilos devorando memoria. Por eso los frameworks web modernos de Python son asíncronos por dentro. Cuando en el módulo 10 montemos servicios web para Papyrus, sabrás qué motor ruge debajo.

Errores Comunes y Consejos

  • Olvidar el await: obtienes un objeto corrutina y un RuntimeWarning: coroutine ... was never awaited. La función "no hace nada". Es el error #1; ahora ya sabes leer su síntoma.
  • time.sleep (o cualquier llamada bloqueante) dentro de una corrutina: congela el event loop entero y tu "concurrencia" pasa a ser secuencial disfrazado. Dentro de async def, toda espera se hace con await.
  • await en serie creyendo que es concurrente: await a(); await b() ejecuta a y después b. La concurrencia la crea gather (o asyncio.TaskGroup, su alternativa moderna), no la palabra async.
  • Llamar a asyncio.run() dentro de una corrutina (o dos veces anidadas): RuntimeError. Un solo run(), en la frontera del programa — como el basicConfig de 07-05, que también se configura una sola vez en la entrada.
  • Mezclar requests u open() pesados en corrutinas: funcionan, pero bloquean el loop. Ecosistema async o hilos; a medias, no.
  • Consejo: ante la duda entre hilos y asyncio, empieza por hilos. Migrar a asyncio después es asumible; forzar asyncio donde no toca es reescribirlo todo.

Ejercicios

  1. Escribe async def descargar_portada(titulo: str) -> str que simule con await asyncio.sleep(1) la descarga y devuelva f"{titulo}.webp". Descarga las portadas de los cuatro libros del catálogo (La Odisea, Hamlet, El Quijote, Fausto) con gather y comprueba midiendo con time.perf_counter() que tarda ~1 s y no ~4 s.

  2. Este código dice tardar 2 s y tarda 6. Explica por qué línea a línea y corrígelo (dos cambios).

    async def consulta(d):
        time.sleep(2)
        return d
    
    async def main():
        resultados = [await consulta(d) for d in ("A", "B", "C")]
    
    asyncio.run(main())
    
  3. Sin escribir código: para cada escenario de Papyrus, elige secuencial, hilos, procesos o asyncio, y justifica en una frase. (a) Generar las estadísticas anuales de ventas de 10 años, cálculo intensivo. (b) El cierre de caja diario, que tarda 0.04 s. (c) Un futuro servicio web que atienda a 2 000 librerías del barrio consultando stock a la vez.

Soluciones

  1. import asyncio, time
    
    async def descargar_portada(titulo: str) -> str:
        await asyncio.sleep(1)                    # descarga simulada
        return f"{titulo}.webp"
    
    async def main() -> None:
        titulos = ["La Odisea", "Hamlet", "El Quijote", "Fausto"]
        inicio = time.perf_counter()
        portadas = await asyncio.gather(*(descargar_portada(t) for t in titulos))
        print(portadas, f"{time.perf_counter() - inicio:.1f} s")   # [...] 1.0 s
    
    asyncio.run(main())
    

    Cuatro esperas de 1 s solapadas en un solo hilo: total ~1 s.

  2. Dos fallos: time.sleep(2) bloquea el event loop (nadie avanza durante esos 2 s), y la comprensión con await consulta(d) espera cada consulta antes de lanzar la siguiente — secuencial puro. Corrección:

    async def consulta(d):
        await asyncio.sleep(2)      # cambio 1: espera async que cede el turno
        return d
    
    async def main():
        resultados = await asyncio.gather(*(consulta(d) for d in ("A", "B", "C")))
        # cambio 2: gather lanza las tres a la vez → ~2 s
    
  3. (a) Procesos (ProcessPoolExecutor): es CPU pura y ni hilos ni asyncio la aceleran — solo el paralelismo real de varios GIL. (b) Secuencial: 0.04 s no es una espera que duela; añadir concurrencia sería pagar complejidad por nada. (c) Asyncio: miles de conexiones simultáneas de E/S es exactamente el escenario donde las corrutinas baratas ganan a los hilos — y el terreno de los frameworks del módulo 10.

Conclusión

asyncio completa el mapa de la concurrencia: un solo hilo, corrutinas que se pausan en await como los generadores lo hacían en yield, asyncio.run() como única puerta de entrada, la regla de oro de no bloquear jamás el event loop (asyncio.sleep, nunca time.sleep), y gather convirtiendo 6 segundos en 2 sin un solo Lock. Y la lección de madurez: para el script de Papyrus bastan los hilos — o nada —; el async brilla en servidores con miles de conexiones, cita a la que acudiremos en el módulo 10.

El módulo 8 cumplió, una a una, las promesas del cierre del módulo 7. Los contratos que aquel módulo defendía a mano ahora están escritos en las firmas — buscar_libro(catalogo: dict[str, Libro], titulo: str) -> Libro | None cuenta toda la verdad, y mypy la verifica sin ejecutar nada (08-01). La fontanería repetida salió de las funciones y se hizo envoltorio: @cronometrar mide, @registrar_llamada audita en papyrus.log y @reintentar(veces=3) insiste, todo sin tocar una línea de vender() (08-02). El cierre de caja dejó de cargar ventas.csv entero: un pipeline de generadores — leer → parsear → filtrar → sumar — procesa un año de ventas con la memoria de una sola fila (08-03). La promesa más antigua, la del with que arrastrábamos desde el módulo 6, quedó saldada: __enter__ prepara, __exit__ limpia siempre, y TransaccionCatalogo garantiza que una venta a medias nunca deje el catálogo corrupto (08-04). Y cuando las esperas dolieron, aprendiste a solaparlas con hilos, a paralelizar cálculo con procesos y a orquestar miles de esperas con un solo camarero — sabiendo, sobre todo, cuándo no hace falta ninguna de las tres (08-05, 08-06). Papyrus es ahora expresivo, eficiente y está preparado para crecer. Pero queda una pregunta que ningún type hint, decorador ni context manager responde: cuando mañana cambies una línea — un descuento nuevo, un campo más en el CSV — ¿cómo garantizas que todo esto SIGUE funcionando? Comprobarlo a mano cada vez no escala. Se escribe código que comprueba el código: pruebas. Ese es el módulo 9.

Curso de Programación en Python

Módulo 1: Introducción a Python

Módulo 2: Estructuras de Control

Módulo 3: Funciones y Módulos

Módulo 4: Estructuras de Datos

Módulo 5: Programación Orientada a Objetos

Módulo 6: Manejo de Archivos

Módulo 7: Manejo de Errores y Excepciones

Módulo 8: Temas Avanzados

Módulo 9: Pruebas y Depuración

Módulo 10: Desarrollo Web con Python

Módulo 11: Ciencia de Datos con Python

Módulo 12: Proyecto Final

© Copyright 2026. Todos los derechos reservados