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
- La idea: un solo hilo que no espera parado
async defyawait: corrutinas- El error fundacional: llamar a una corrutina no la ejecuta
asyncio.run(): arrancar el mundo asyncasyncio.sleepvstime.sleep: el pecado capitalasyncio.gather: N consultas a la vez, medidas- Asyncio vs hilos: la tabla
async withyasync for, brevemente- Honestidad: cuándo compensa y cuándo no
- 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 awaitedEs 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 programaLa 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 esperotime.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())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)? | Sí — 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 unRuntimeWarning: 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 deasync def, toda espera se hace conawait.awaiten serie creyendo que es concurrente:await a(); await b()ejecuta a y después b. La concurrencia la creagather(oasyncio.TaskGroup, su alternativa moderna), no la palabraasync.- Llamar a
asyncio.run()dentro de una corrutina (o dos veces anidadas):RuntimeError. Un solorun(), en la frontera del programa — como elbasicConfigde 07-05, que también se configura una sola vez en la entrada. - Mezclar
requestsuopen()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
-
Escribe
async def descargar_portada(titulo: str) -> strque simule conawait asyncio.sleep(1)la descarga y devuelvaf"{titulo}.webp". Descarga las portadas de los cuatro libros del catálogo (La Odisea, Hamlet, El Quijote, Fausto) congathery comprueba midiendo contime.perf_counter()que tarda ~1 s y no ~4 s. -
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()) -
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
-
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.
-
Dos fallos:
time.sleep(2)bloquea el event loop (nadie avanza durante esos 2 s), y la comprensión conawait 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 -
(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
- Introducción a Python
- Configuración del Entorno de Desarrollo
- Sintaxis de Python y Tipos de Datos Básicos
- Variables y Constantes
- Entrada y Salida Básica
- Entornos Virtuales y Gestión de Paquetes
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for y while
- Herramientas de Control de Flujo
- Comprensiones de Listas
Módulo 3: Funciones y Módulos
- Definición de Funciones
- Argumentos de Función
- Funciones Lambda
- Módulos y Paquetes
- Visión General de la Biblioteca Estándar
Módulo 4: Estructuras de Datos
Módulo 5: Programación Orientada a Objetos
Módulo 6: Manejo de Archivos
- Lectura y Escritura de Archivos
- Trabajo con Archivos CSV
- Manejo de Datos JSON
- Operaciones con Archivos y Directorios
Módulo 7: Manejo de Errores y Excepciones
- Introducción a las Excepciones
- Manejo de Excepciones
- Lanzamiento de Excepciones
- Excepciones Personalizadas
- Buenas Prácticas y Registro de Errores con logging
Módulo 8: Temas Avanzados
- Anotaciones de Tipos (type hints)
- Decoradores
- Generadores
- Administradores de Contexto
- Concurrencia: Hilos y Procesos
- Asyncio para Programación Asíncrona
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con unittest
- Pruebas con pytest
- Desarrollo Guiado por Pruebas
- Técnicas de Depuración
- Uso de pdb para Depuración
Módulo 10: Desarrollo Web con Python
- Introducción al Desarrollo Web
- Fundamentos del Framework Flask
- Construcción de APIs REST con Flask
- Introducción a Django
- Construcción de Aplicaciones Web con Django
Módulo 11: Ciencia de Datos con Python
- Introducción a la Ciencia de Datos
- NumPy para Computación Numérica
- Pandas para Manipulación de Datos
- Matplotlib para Visualización de Datos
- Introducción al Aprendizaje Automático con scikit-learn
