Cuándo tiene sentido asyncio
Python permite tres formas de hacer que un programa avance en paralelo:
- Threads: varios hilos compartiendo memoria. El GIL de CPython limita la concurrencia real de CPU pero funciona para esperas de IO.
- Processes: varios procesos independientes. Paralelismo real de CPU, pero comunicación más cara.
- Asyncio: un solo hilo, un event loop que alterna entre tareas mientras esperan recursos externos.
asyncio es la opción correcta cuando el programa pasa la mayoría del tiempo esperando (peticiones HTTP, consultas a base de datos, lectura de ficheros remotos, sockets). Un servidor asíncrono puede manejar miles de conexiones simultáneas con un solo hilo, porque mientras una espera respuesta del disco, el event loop dedica la CPU a otra.
asyncio es IO-bound: para tareas limitadas por CPU (cálculo pesado, procesado de imágenes, machine learning puro), sigue siendo necesario
multiprocessingo librerías C optimizadas. Mezclar ambos patrones sí es posible y habitual en servicios reales.
async def y await
Una función asíncrona se declara con async def. Al llamarla no se ejecuta su cuerpo: devuelve una corutina, un objeto que representa una ejecución pendiente.
import asyncio
async def saludar(nombre: str) -> str:
await asyncio.sleep(1)
return f"Hola {nombre}"
Para ejecutar realmente la corutina, usamos asyncio.run como punto de entrada y await para esperar otras corutinas:
async def main():
mensaje = await saludar("Ana")
print(mensaje)
asyncio.run(main())
asyncio.run arranca el event loop, ejecuta main, y lo cierra al terminar. Es la forma estándar de iniciar un programa asíncrono.
Concurrencia con asyncio.gather
Llamar a varias corutinas una detrás de otra con await las ejecuta en secuencia:
async def lenta(i):
await asyncio.sleep(1)
return i
async def main():
a = await lenta(1) # espera 1s
b = await lenta(2) # espera 1s
c = await lenta(3) # espera 1s
# total: 3 segundos
Para ejecutarlas en paralelo, se usa asyncio.gather:
async def main():
a, b, c = await asyncio.gather(
lenta(1),
lenta(2),
lenta(3),
)
# total: ~1 segundo
gather lanza las tres al mismo tiempo y espera a que terminen todas. El event loop alterna entre ellas mientras cada una "duerme", exactamente lo que se quiere para peticiones HTTP independientes o consultas a varias APIs.
asyncio.TaskGroup: concurrencia estructurada
En Python 3.11 se añadió TaskGroup con un modelo más seguro de concurrencia:
async def main():
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(lenta(1))
task2 = tg.create_task(lenta(2))
task3 = tg.create_task(lenta(3))
# aquí todas están ya terminadas; task.result() disponible
print(task1.result(), task2.result(), task3.result())
Ventajas sobre gather:
- Si una tarea lanza excepción, las demás se cancelan automáticamente.
- La sintaxis
async withdeja claro que el grupo es un ámbito cerrado: al salir, todo está terminado. - Se integra mejor con cancelación y timeouts.
En código nuevo, TaskGroup suele ser la forma preferida sobre gather.
Timeouts y cancelación
Una corutina que tarde demasiado puede cancelarse con asyncio.timeout (Python 3.11+):
async def main():
try:
async with asyncio.timeout(2.0):
resultado = await operacion_lenta()
except TimeoutError:
print("tardó demasiado")
El bloque async with asyncio.timeout(2.0) lanza TimeoutError si pasados 2 segundos las corutinas dentro no han terminado. Cancela todo lo que haya en curso automáticamente.
También se puede cancelar una tarea específica:
task = asyncio.create_task(operacion_lenta())
...
task.cancel()
try:
await task
except asyncio.CancelledError:
print("cancelada")
Async context managers con async with
Recursos que deben abrirse y cerrarse pueden implementarse como context managers asíncronos y usarse con async with:
async with cliente_http.get("https://api.ejemplo.com") as respuesta:
datos = await respuesta.json()
Por debajo, aiohttp, asyncpg, httpx (modo async) y muchas otras librerías exponen sus recursos como async context managers. Garantizan que la conexión se cierra aunque haya excepción.
Async iterators y async for
Para iteración asíncrona sobre fuentes de datos (streaming de API, lectura de socket, filas de una query), async for recorre un iterador asíncrono:
async for linea in stream:
procesar(linea)
Una función generadora asíncrona se declara con async def y yield:
async def generar_ids(n: int):
for i in range(n):
await asyncio.sleep(0.1)
yield i
async def main():
async for x in generar_ids(5):
print(x)
Este patrón es fundamental en pipelines de procesamiento de eventos.
Llamar código síncrono desde asyncio
Si dentro de código asíncrono necesitas ejecutar una función síncrona lenta (una librería que no soporta async, un cálculo CPU-bound), usa asyncio.to_thread para delegarla a un hilo:
async def main():
resultado = await asyncio.to_thread(funcion_lenta_sincrona, argumento)
Esto evita bloquear el event loop. El hilo corre en paralelo y al terminar devuelve el resultado. Para CPU pesado puro es mejor ProcessPoolExecutor vía loop.run_in_executor.
Librerías del ecosistema
asyncio es solo el fundamento. Las operaciones concretas se hacen con librerías del ecosistema que exponen APIs async:
- httpx: cliente HTTP async y sync con la misma API.
- aiohttp: cliente y servidor HTTP asíncronos.
- asyncpg (PostgreSQL), aiomysql (MySQL), motor (MongoDB): drivers de base de datos async.
- SQLAlchemy: en modo async desde 2.0.
- FastAPI: framework web asíncrono construido sobre Starlette.
- anyio: abstracción sobre asyncio y trio que permite escribir código portable entre event loops.
Ejemplo con httpx:
import httpx
async def descargar(urls: list[str]) -> list[dict]:
async with httpx.AsyncClient() as client:
respuestas = await asyncio.gather(
*(client.get(url) for url in urls)
)
return [r.json() for r in respuestas]
Cien URLs se descargan en paralelo con apenas unas líneas y sin abrir cien hilos.
Errores comunes
Mezclar sync y async sin cuidado: llamar funciones síncronas lentas dentro del event loop bloquea todo. Usa asyncio.to_thread o adopta el equivalente async.
Olvidar el await: resultado = funcion_async() no ejecuta la corutina, solo la crea. Python emite un warning si la corutina no se espera, pero el bug puede pasar desapercibido.
asyncio.run anidado: asyncio.run arranca un loop. Si ya estás dentro de uno, usa await directamente en lugar de llamar asyncio.run de nuevo.
Sobre-uso de asyncio: si el programa es CPU-bound o simple (un solo fichero, una sola petición), asyncio añade complejidad innecesaria. El código síncrono sigue siendo la opción por defecto.
Resumen
asyncio es la herramienta nativa de Python para concurrencia de IO. Con async def, await, asyncio.gather o TaskGroup, timeouts y context managers asíncronos cubres la mayoría de escenarios reales: llamadas a API, consultas a bases de datos, servicios web y pipelines de streaming. Combinado con librerías del ecosistema (httpx, asyncpg, FastAPI), permite construir aplicaciones modernas que escalan a miles de operaciones concurrentes sin la complejidad de los hilos.
Alan Sastre
Ingeniero de Software y formador, CEO en CertiDevs
Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Python es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.
Más tutoriales de Python
Explora más contenido relacionado con Python y continúa aprendiendo con nuestros tutoriales gratuitos.
Aprendizajes de esta lección
Comprender el modelo del event loop y cuándo conviene asyncio frente a hilos o procesos. Declarar funciones asíncronas con async def y esperar con await. Ejecutar corutinas concurrentes con asyncio.gather y asyncio.TaskGroup. Controlar timeouts y cancelaciones. Usar async with y async for.