Cuando arrancas varios Thread en Python, el sistema operativo es el árbitro: puede interrumpir un hilo en cualquier instrucción, guardar su estado y darle la CPU a otro. Ese modelo se llama concurrencia preemptiva —el código no tiene voz ni voto sobre cuándo pierde el control. asyncio funciona al revés: solo hay un hilo y cada pieza de código retiene la CPU hasta que explícitamente escribe await. Ese momento de ceder es el único punto donde otra coroutine puede ejecutarse. Eso es concurrencia cooperativa.
La diferencia no es de velocidad bruta sino de quién controla los cambios de contexto. En threading el scheduler del SO puede interrumpirte en medio de un += (por eso existe el GIL, que mitiga algunos problemas pero no los elimina todos). En asyncio el cambio de contexto solo ocurre en los puntos await, lo que hace que el estado de tus estructuras de datos sea predecible entre esos puntos: nadie más puede modificarlas mientras no hayas cedido el control.
Esto no es paralelismo. Con asyncio tienes un solo thread ejecutando una sola coroutine en cada instante. La ganancia es otra: cuando esa coroutine hace await en una operación I/O (leer un socket, esperar una respuesta HTTP), el event loop aprovecha esa espera para avanzar otra coroutine que ya tiene datos listos. Para mantener miles de conexiones simultáneas eso es enormemente más eficiente que crear miles de hilos, pero no calcula nada más rápido que un solo thread secuencial.
El riesgo simétrico: si una coroutine hace trabajo CPU-intenso o llama a cualquier función bloqueante sin await, bloquea el event loop entero. Todos los demás esperan. Con threading el SO habría interrumpido al hilo culpable; en asyncio nadie viene a rescatarte.
import asyncio
import time
import httpx # pip install httpx
URLS = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/1",
]
async def fetch(client: httpx.AsyncClient, url: str, idx: int) -> tuple[int, float]:
t0 = time.perf_counter()
response = await client.get(url, timeout=10) # cede el control mientras espera la red
elapsed = time.perf_counter() - t0
print(f" [{idx}] status={response.status_code} tiempo={elapsed:.2f}s")
return idx, elapsed
async def main() -> None:
print("=== Concurrente (cooperativo) ===")
t0 = time.perf_counter()
async with httpx.AsyncClient() as client:
# gather crea todas las coroutines y las entrega al event loop juntas.
# El event loop las avanza de forma intercalada cada vez que una hace await.
results = await asyncio.gather(
*[fetch(client, url, i) for i, url in enumerate(URLS)]
)
total = time.perf_counter() - t0
print(f"Total concurrente: {total:.2f}s (esperado ~1s, no ~{len(URLS)}s)\n")
print("=== Secuencial (para comparar) ===")
t0 = time.perf_counter()
async with httpx.AsyncClient() as client:
for i, url in enumerate(URLS):
# Sin gather: cada await espera a que termine antes de empezar el siguiente
await fetch(client, url, i)
total = time.perf_counter() - t0
print(f"Total secuencial: {total:.2f}s (esperado ~{len(URLS)}s)")
if __name__ == "__main__":
asyncio.run(main())
Lo que pasa adentro
asyncio.gather toma las cinco coroutines y las registra en el event loop antes de que ninguna empiece a ejecutarse. Cuando la primera hace await client.get(...), el event loop ve que esa coroutine está esperando la red y le pregunta a la siguiente: “¿tienes algo que hacer?”. La segunda también lanza su request y hace await. Y así sucesivamente. Los cinco requests viajan a la red casi simultáneamente. Cuando el primer servidor responde, el event loop reactiva la coroutine correspondiente —que continúa exactamente donde dejó el await— mientras las otras siguen esperando sus propias respuestas.
El resultado es que cinco operaciones de ~1 segundo tardan ~1 segundo en total, no ~5. No porque se ejecuten en paralelo en distintos núcleos, sino porque el tiempo de espera de red se solapa: el único hilo está haciendo otra cosa mientras el SO gestiona los sockets.
Fíjate en la versión secuencial: mismo código, pero los await están en un bucle sin gather. Cada request espera a que el anterior termine porque nunca hay más de una coroutine activa al mismo tiempo. El event loop no puede avanzar algo que nadie le ha entregado todavía.
La ausencia de condiciones de carrera clásicas en asyncio viene de aquí: entre un await y el siguiente, tu código es efectivamente monohilo. Puedes leer y modificar un diccionario compartido sin locks, siempre que no hagas await en medio de la operación. En cuanto cedes el control, otra coroutine podría modificar ese estado —pero al menos tú sabes exactamente dónde pueden ocurrir esas interleaving, porque son los únicos puntos con await en el código.
Errores que debes conocer
Error: Llamar a una función bloqueante de la librería estándar sin envolverla, lo que congela el event loop entero porque no hay await que permita ceder el control.
import asyncio
import time
# ❌ Wrong
async def process():
time.sleep(2) # bloquea el hilo completo; ninguna otra coroutine avanza
return "done"
# ✅ Right
async def process():
await asyncio.sleep(2) # cede el control; el event loop puede avanzar otras coroutines
return "done"
Para I/O real (archivos, base de datos, HTTP) usa librerías async nativas (aiofiles, asyncpg, httpx) o delega a un executor con asyncio.run_in_executor.
Error: Crear una coroutine sin await ni asyncio.gather, creyendo que ya está en ejecución cuando en realidad nunca corre.
import asyncio
async def work(n: int) -> None:
await asyncio.sleep(0.1)
print(n)
# ❌ Wrong
async def main():
work(1) # RuntimeWarning: coroutine 'work' was never awaited
work(2)
# ✅ Right
async def main():
await asyncio.gather(work(1), work(2))
asyncio.run(main())
Llamar work(1) solo construye el objeto coroutine; no lo ejecuta. El event loop solo ejecuta lo que recibe explícitamente vía await, gather, create_task u otras primitivas de scheduling.
N° 147