Cuando tienes múltiples corrutinas que pueden ejecutarse de forma independiente, lanzarlas una tras otra con await es un error de diseño: estás tirando la concurrencia por la ventana. asyncio.gather, TaskGroup y wait_for son las tres herramientas que convierten código asíncrono secuencial en código verdaderamente concurrente.
La diferencia fundamental está en qué es una Task frente a una corrutina. Una corrutina es solo un objeto pausable; no hace nada hasta que alguien la avanza. Una asyncio.Task es esa corrutina envuelta por el event loop, ya programada para ejecutarse en cuanto el hilo de control cede con un await. Crear una Task es el acto de registrar trabajo concurrente; sin ese paso, todo es secuencial.
asyncio.gather toma corrutinas (o Tasks existentes) y las envuelve automáticamente en Tasks, ejecutándolas de forma concurrente. Retorna una lista con los resultados en el mismo orden en que recibió los argumentos, sin importar qué tarea terminó primero. Si alguna lanza una excepción y return_exceptions=False (el valor por defecto), la primera excepción cancela las demás y se propaga hacia arriba.
asyncio.TaskGroup es la API moderna introducida en Python 3.11. Sigue el principio de structured concurrency: las tareas creadas dentro del bloque async with tienen su ciclo de vida ligado a ese bloque. Si cualquier tarea falla, las demás se cancelan y las excepciones se agrupan en un ExceptionGroup. Esto evita el problema clásico de Tasks “huérfanas” que siguen corriendo después de que el código que las creó ya terminó.
asyncio.wait_for resuelve un problema distinto: poner un límite de tiempo a una operación. Si la corrutina no termina dentro del timeout, se cancela automáticamente y se lanza asyncio.TimeoutError. Sin esto, una red lenta o un servicio caído puede bloquear tu programa indefinidamente, y en código asíncrono ese bloqueo afecta a todas las corrutinas del event loop.
El riesgo más importante con gather es asumir que una excepción en una tarea deja las demás en un estado limpio. No necesariamente: las tareas que se cancelaron pueden haberse quedado a mitad de una operación con efectos secundarios. TaskGroup hace esto más seguro porque el modelo de cancelación es explícito y uniforme.
import asyncio
import httpx # pip install httpx
URLS = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://httpbin.org/delay/1",
]
async def fetch(client: httpx.AsyncClient, url: str) -> dict:
"""Descarga una URL con timeout individual de 5 segundos."""
# wait_for cancela la corrutina y lanza TimeoutError si supera 5 s
response = await asyncio.wait_for(
client.get(url),
timeout=5.0,
)
response.raise_for_status()
return {"url": url, "status": response.status_code}
async def fetch_all_gather(urls: list[str]) -> list[dict]:
"""gather: todos los resultados juntos al final, fallo rápido."""
async with httpx.AsyncClient() as client:
# gather envuelve cada corrutina en una Task inmediatamente;
# las tres peticiones viajan en paralelo dentro del event loop
results = await asyncio.gather(
*[fetch(client, url) for url in urls],
return_exceptions=False, # primera excepción la propaga; las demás se cancelan
)
return results
async def fetch_all_taskgroup(urls: list[str]) -> list[dict]:
"""TaskGroup (3.11+): structured concurrency, ExceptionGroup si algo falla."""
results: list[dict] = []
async with httpx.AsyncClient() as client:
async with asyncio.TaskGroup() as tg:
# create_task registra la Task y la vincula al ciclo de vida del bloque
tasks = [tg.create_task(fetch(client, url)) for url in urls]
# llegamos aquí solo si TODAS las tareas terminaron sin error
results = [t.result() for t in tasks]
return results
async def fetch_with_timeout_demo(url: str) -> None:
"""Demuestra wait_for con un timeout que expira a propósito."""
async with httpx.AsyncClient() as client:
try:
# delay/3 tarda ~3 s; el timeout de 2 s va a dispararse
result = await asyncio.wait_for(client.get(url), timeout=2.0)
print(result.status_code)
except asyncio.TimeoutError:
# La corrutina ya fue cancelada; el client limpia sus recursos
print("Timeout alcanzado — la petición fue cancelada")
async def main() -> None:
print("=== gather ===")
gathered = await fetch_all_gather(URLS)
for r in gathered:
print(r)
print("\n=== TaskGroup ===")
grouped = await fetch_all_taskgroup(URLS)
for r in grouped:
print(r)
print("\n=== wait_for con timeout que expira ===")
await fetch_with_timeout_demo("https://httpbin.org/delay/3")
if __name__ == "__main__":
asyncio.run(main())
Desglose del código
fetch con wait_for: envuelve client.get(url) —una corrutina— con un límite de 5 segundos. El timeout es por petición individual, lo que significa que una URL lenta no bloquea a las demás. Cuando wait_for cancela la corrutina internamente, httpx cierra la conexión; el TimeoutError sube hacia quien llamó a fetch.
fetch_all_gather: el unpacking *[fetch(...) for url in urls] pasa tres corrutinas a gather. En ese momento el event loop crea tres Tasks y las pone en cola. El await cede el control y el event loop las alterna cada vez que alguna hace await internamente (la espera de red, en este caso). El resultado es una lista ordenada según el orden de los argumentos, no el orden de finalización. Con return_exceptions=False, si fetch lanza una excepción en cualquier tarea, gather cancela las otras dos y relanza esa excepción.
fetch_all_taskgroup: el bloque async with asyncio.TaskGroup() es la frontera de vida de las tasks. tg.create_task(...) registra cada corrutina como una Task vinculada al grupo. Al salir del async with, Python espera a que todas terminen. Si dos tareas fallan simultáneamente, ambas excepciones se empaquetan en un ExceptionGroup —nunca pierdes información sobre qué salió mal. La lista tasks se lee con .result() solo después del bloque, cuando se garantiza que todas terminaron.
fetch_with_timeout_demo: ilustra el caso donde el timeout está pensado para dispararse. Nota que después del except asyncio.TimeoutError el client sigue siendo utilizable porque httpx maneja la limpieza interna. El async with httpx.AsyncClient() sigue siendo válido para hacer más peticiones si quisieras.
La elección entre gather y TaskGroup no es solo de sintaxis. TaskGroup es preferible cuando necesitas que el fallo de una tarea no deje trabajo “fantasma” corriendo, y cuando la semántica de ExceptionGroup te permite distinguir múltiples fallos. gather con return_exceptions=True sigue siendo útil cuando quieres que todas las tareas terminen pase lo que pase y luego inspeccionar los resultados tú mismo —por ejemplo, en un pipeline donde el fallo parcial es esperado y manejable.
Errores que debes conocer
Error: llamar asyncio.gather con corrutinas ya consumidas (el mismo objeto corrutina usado dos veces).
# ❌ Wrong coro = fetch(client, url) await asyncio.gather(coro, coro) # segundo uso: RuntimeError # ✅ Right await asyncio.gather(fetch(client, url), fetch(client, url))
Cada llamada a fetch(client, url) crea un objeto corrutina nuevo. Reutilizar el mismo objeto corrutina lanza RuntimeError: cannot reuse already awaited coroutine.
Error: leer .result() de Tasks de un TaskGroup dentro del bloque, antes de que terminen.
# ❌ Wrong
async with asyncio.TaskGroup() as tg:
t = tg.create_task(fetch(client, url))
result = t.result() # InvalidStateError: Task no ha terminado
# ✅ Right
async with asyncio.TaskGroup() as tg:
t = tg.create_task(fetch(client, url))
result = t.result() # fuera del bloque: garantizado que terminó
El bloque async with TaskGroup hace el await implícito al salir; dentro del bloque la Task puede estar en cualquier estado.
Error: no manejar ExceptionGroup cuando usas TaskGroup y esperas fallos.
# ❌ Wrong
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(failing_coro())
except ValueError as e: # nunca se ejecuta: TaskGroup lanza ExceptionGroup
print(e)
# ✅ Right
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(failing_coro())
except* ValueError as eg: # sintaxis except* para ExceptionGroup (Python 3.11+)
for exc in eg.exceptions:
print(exc)
TaskGroup siempre envuelve las excepciones en ExceptionGroup; la sintaxis except* (también nueva en 3.11) es la forma idiomática de desestructurarla por tipo.
N° 150