1.16 · Asincronía con asyncio¶
Objetivos. Al terminar este capítulo podrás: diferenciar concurrencia de
paralelismo; definir coroutines con async def; esperar operaciones con
await; ejecutar varias tareas con asyncio.gather; y evitar bloquear el bucle
de eventos.
Evidencia de logro. Escribirás un cliente simulado que consulta varias fuentes concurrentemente, conserva el orden de los resultados y trata un tiempo agotado sin depender de una red real.
Contexto y motivación¶
Una aplicación de IA suele esperar a servicios HTTP, discos o colas. Mientras una operación espera, el programa puede atender otra tarea. La asincronía organiza esa espera en un único hilo mediante un bucle de eventos.
- Concurrencia: varias tareas avanzan intercaladas durante sus esperas.
- Paralelismo: varias operaciones se ejecutan realmente al mismo tiempo, normalmente en varios núcleos o procesos.
asyncio es especialmente útil para muchas operaciones de entrada/salida. No
hace que un cálculo pesado de Python sea más rápido por sí solo.
Vocabulario¶
| Término | Significado |
|---|---|
| Coroutine | Función async def que puede pausarse y reanudarse |
await |
Esperar una operación asíncrona sin bloquear el bucle |
| Tarea | Coroutine programada para ejecutarse en el bucle |
| Bucle de eventos | Motor que coordina las tareas asíncronas |
| Concurrencia | Progreso intercalado de varias tareas |
| Timeout | Límite de tiempo de espera |
| Bloqueo | Operación que impide avanzar a las demás tareas |
Prerrequisitos¶
1.5 · Bucles, 1.8 · Funciones, 1.11 · Excepciones y logging y 1.15 · Librerías externas (esta última, solo necesaria para la ampliación del cliente HTTP).
Núcleo y ampliaciones
El núcleo de las tres horas cubre los apartados 1 a 4: coroutine, gather,
timeouts y el peligro de bloquear el bucle. Los apartados de ampliación
(crear/cancelar tareas y cliente HTTP asíncrono) son opcionales y no se
piden en la práctica evaluable.
1. Una coroutine no se ejecuta al definirla¶
async def crea una función que devuelve una coroutine. Para ejecutarla desde
un script se usa asyncio.run:
import asyncio
async def saludar():
await asyncio.sleep(0)
return "Hola desde una coroutine"
print(asyncio.run(saludar()))
Salida esperada:
await asyncio.sleep(0) cede el control al bucle. En una operación real sería
la espera de una respuesta HTTP, una cola o un fichero asíncrono.
No llames una coroutine como una función normal
saludar() por sí sola devuelve un objeto coroutine y no ejecuta su cuerpo.
En un script usa asyncio.run(saludar()). En un entorno que ya tiene un
bucle de eventos, como algunos servidores, sigue la API del framework y no
anides otro asyncio.run.
En un cuaderno marimo
Los ejemplos de este capítulo están escritos para un script. marimo admite
await directamente en una celda, así que allí puedes escribir
await saludar() o await main() en lugar de asyncio.run(...).
2. Esperar varias operaciones¶
Si esperas una operación después de otra, las esperas se suman. Con
asyncio.gather pueden avanzar juntas. Compruébalo con tres esperas simuladas
de 0,1 segundos cada una:
import asyncio
import time
async def esperar(segundos):
await asyncio.sleep(segundos) # simula esperar a un servidor
return segundos
async def una_tras_otra():
for _ in range(3):
await esperar(0.1)
async def a_la_vez():
await asyncio.gather(esperar(0.1), esperar(0.1), esperar(0.1))
inicio = time.perf_counter()
asyncio.run(una_tras_otra())
print("Una tras otra:", round(time.perf_counter() - inicio, 1), "s")
inicio = time.perf_counter()
asyncio.run(a_la_vez())
print("A la vez:", round(time.perf_counter() - inicio, 1), "s")
Salida esperada:
time.perf_counter() es un cronómetro: la diferencia entre dos lecturas es el
tiempo transcurrido, que redondeamos a una décima porque varía unos
milisegundos entre ejecuciones. Una tras otra, las tres esperas se suman (0,3 s);
con gather, mientras una espera, las otras también esperan, y el total es
aproximadamente el de la más lenta (0,1 s). Esa es toda la ventaja de la
asincronía: aprovechar las esperas.
Para ver el orden en que avanzan, este ejemplo imprime al empezar y al terminar
cada consulta (con sleep(0), que solo cede el turno sin esperar):
import asyncio
async def consultar(nombre):
print("inicio", nombre)
await asyncio.sleep(0)
print("fin", nombre)
return f"{nombre}: disponible"
async def consultar_todas():
return await asyncio.gather(
consultar("catálogo"),
consultar("memoria"),
consultar("modelo"),
)
print(asyncio.run(consultar_todas()))
Salida esperada:
inicio catálogo
inicio memoria
inicio modelo
fin catálogo
fin memoria
fin modelo
['catálogo: disponible', 'memoria: disponible', 'modelo: disponible']
Las tareas avanzan hasta await, ceden el control y continúan después. Aunque
el orden de impresión puede depender de dónde cedan las coroutines, gather
devuelve los resultados en el orden de las operaciones que recibió.
Ampliación: crear y cancelar tareas¶
create_task programa una coroutine para que avance mientras hacemos otra cosa:
import asyncio
async def trabajo(nombre):
try:
await asyncio.sleep(0)
return f"terminado: {nombre}"
except asyncio.CancelledError:
print("cancelado:", nombre)
raise
async def main():
tarea = asyncio.create_task(trabajo("consulta"))
await asyncio.sleep(0)
print(tarea.cancel())
try:
await tarea
except asyncio.CancelledError:
print("cancelación confirmada")
asyncio.run(main())
Salida esperada:
Cancelar no es lo mismo que ignorar el resultado: la coroutine recibe
CancelledError y debe liberar recursos en un finally si los ha adquirido.
3. Timeouts y excepciones¶
Una espera sin límite puede dejar una aplicación ocupada indefinidamente. En
Python 3.11 o posterior, asyncio.timeout expresa el límite:
import asyncio
async def esperar_servidor():
await asyncio.sleep(0.02)
return "respuesta"
async def main():
try:
async with asyncio.timeout(0.001):
return await esperar_servidor()
except TimeoutError:
return "tiempo agotado"
print(asyncio.run(main()))
Salida esperada:
El timeout cancela la espera dentro del contexto. La capa que conoce el caso de uso decide si devuelve una respuesta alternativa, reintenta o informa del error. No conviertas todo timeout en una respuesta válida sin registrar la pérdida de información.
4. El error más común: bloquear el bucle¶
Este código bloquea todas las demás tareas durante la espera:
Usa una operación asíncrona (await asyncio.sleep(2)) si la librería la ofrece.
Si solo existe una función síncrona y no puedes cambiarla, puedes moverla a un
hilo:
import asyncio
import time
def lectura_síncrona():
time.sleep(0)
return "lectura terminada"
async def main():
resultado = await asyncio.to_thread(lectura_síncrona)
return resultado
print(asyncio.run(main()))
Salida esperada:
to_thread evita bloquear el bucle durante una función de entrada/salida
síncrona. No es una solución automática para cálculos intensivos: para CPU
pesada se estudian procesos, vectorización u otras estrategias.
Ampliación: cliente HTTP asíncrono¶
En una aplicación real, httpx2.AsyncClient ofrece métodos asíncronos. Para
practicar sin Internet levantamos un servidor HTTP local:
import asyncio
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from threading import Thread
import httpx2
class Manejador(BaseHTTPRequestHandler):
def do_GET(self):
contenido = b'{"estado":"ok"}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(contenido)))
self.end_headers()
self.wfile.write(contenido)
def log_message(self, formato, *argumentos):
pass
async def consultar():
servidor = ThreadingHTTPServer(("127.0.0.1", 0), Manejador)
hilo = Thread(target=servidor.serve_forever, daemon=True)
hilo.start()
try:
async with httpx2.AsyncClient() as cliente:
respuesta = await cliente.get(
f"http://127.0.0.1:{servidor.server_port}/estado",
timeout=5.0,
)
respuesta.raise_for_status()
return respuesta.json()
finally:
servidor.shutdown()
servidor.server_close()
print(asyncio.run(consultar()))
Salida esperada:
El cliente se abre y se cierra con async with. En un servicio web de larga
vida suele crearse un cliente compartido y cerrarse durante el apagado de la
aplicación; no abras uno nuevo para cada línea de un bucle sin motivo.
Aplicación práctica: consultar fuentes de un asistente¶
import asyncio
async def fuente(nombre, disponible=True):
await asyncio.sleep(0)
if not disponible:
raise ConnectionError(f"{nombre} no responde")
return {"fuente": nombre, "estado": "ok"}
async def consultar_fuentes():
resultados = await asyncio.gather(
fuente("notas"),
fuente("calendario", disponible=False),
return_exceptions=True,
)
respuestas = []
for resultado in resultados:
if isinstance(resultado, Exception):
respuestas.append({"estado": "error", "detalle": str(resultado)})
else:
respuestas.append(resultado)
return respuestas
print(asyncio.run(consultar_fuentes()))
Salida esperada:
return_exceptions=True permite conservar los resultados válidos y tratar cada
fallo por separado. Úsalo solo cuando el caso de uso permita una respuesta
parcial; si todas las fuentes son obligatorias, deja que la excepción suba y
manéjala en la frontera adecuada.
Errores frecuentes¶
- Olvidar
awaity trabajar con la coroutine sin ejecutar. - Usar
asyncio.rundentro de un bucle que ya está activo. - Confundir concurrencia con ejecutar cálculos en paralelo.
- Usar
time.sleep, una petición síncrona o un bucle pesado dentro de una coroutine y bloquear a todas las demás. - Crear clientes HTTP repetidamente sin cerrarlos.
- Capturar
Exceptiony convertir un fallo total en éxito sin informar. - Usar
return_exceptions=Truesin revisar cada resultado. - Medir velocidad con
sleep(0)y sacar conclusiones sobre un sistema real.
Práctica de transferencia¶
- Convierte una función que espere con
time.sleepa una coroutine. - Ejecuta tres consultas simuladas con
gathery conserva los resultados en el mismo orden que las fuentes. - Añade un timeout y decide qué respuesta es correcta si una fuente tarda.
- Provoca una excepción en una de las consultas y compara
gathercon y sinreturn_exceptions=True. - Explica por qué una función que calcula millones de operaciones no se acelera
automáticamente por ser
async.
Producto evaluable¶
Crea 12_asyncio.py como script o cuaderno marimo. Debe:
- Consultar al menos tres fuentes simuladas con
async defygather. - Mostrar resultados en un orden estable y distinguir éxito, error y timeout.
- Cerrar los clientes o recursos asíncronos con
async withsi usas la ampliación del cliente HTTP. - Incluir una celda Markdown que explique por qué el caso es concurrente y no necesariamente paralelo.
- Comparar una operación asíncrona con una función síncrona que se ejecuta con
asyncio.to_thread.
Criterio de aceptación: la práctica se repite sin red ni claves, no deja coroutines sin esperar y trata explícitamente al menos un fallo.
Formato de entrega: script o cuaderno marimo ejecutado, con salida esperada, interpretación y conclusión.
Resumen y referencia rápida¶
| Necesitas | Patrón |
|---|---|
| Definir coroutine | async def funcion(): |
| Esperar | resultado = await funcion() |
| Arrancar desde script | asyncio.run(main()) |
| Varias tareas | await asyncio.gather(a(), b()) |
| Programar una tarea (ampliación) | asyncio.create_task(coroutine) |
| Limitar espera | asyncio.timeout(segundos) |
| Adaptar función síncrona | await asyncio.to_thread(funcion) |
| Cliente HTTP (ampliación) | async with httpx2.AsyncClient(...) |
Siguiente: 1.17 · Type hints y testing con pytest.