Python Asynchronous Programming: asyncio, event loop
asyncio es concurrencia cooperativa de un solo hilo: un event loop corre muchas coroutines que ceden el control voluntariamente en cada await, y mientras una espera I/O el loop hace avanzar a otra. No hay paralelismo ni preempción — hay interleaving determinista gobernado por los puntos de await. Dominar async es entender exactamente dónde tu código cede el control, qué pasa cuando no lo cede (bloqueas el loop), y cuándo el problema real no era I/O-bound sino CPU-bound (y entonces async es la herramienta equivocada).
Teoría
Qué es async y cuándo usarlo (I/O-bound)
Una tarea es I/O-bound cuando pasa la mayoría de su tiempo esperando algo externo: una respuesta HTTP, una query a la base de datos, una lectura de disco, un mensaje de una cola. Durante esa espera el CPU está ocioso. Async explota exactamente ese hueco: mientras una coroutine espera la respuesta de la red, el event loop corre otra. El resultado es que miles de operaciones I/O “en vuelo” se manejan con un solo hilo y un solo proceso, sin el overhead de memoria de miles de threads.
Casos típicos donde async brilla:
- Enviar N emails / notificaciones push en paralelo (cada uno es una llamada de red).
- Un endpoint que necesita agregar datos de 3 servicios upstream (fan-out de HTTP calls).
- Recalcular/refrescar caches consultando varias fuentes.
- Web scraping o polling de muchos endpoints.
- Servidores con muchas conexiones concurrentes de larga vida (WebSockets, SSE).
Cuándo no usar async: trabajo CPU-bound (hashing, compresión, parseo pesado, cómputo numérico). Ahí no hay espera que aprovechar; el trabajo es el CPU ocupado, y una coroutine que calcula sin await bloquea el loop entero. Eso va a multiprocessing o a un ProcessPoolExecutor.
async vs parallel (concurrencia ≠ paralelismo)
Son ejes distintos y confundirlos es el error conceptual #1 en entrevistas.
- Concurrencia: estructurar el programa como tareas independientes que progresan intercaladas. Es sobre lidiar con muchas cosas a la vez.
- Paralelismo: ejecutar múltiples cómputos literalmente al mismo tiempo en varios cores. Es sobre hacer muchas cosas a la vez.
asyncio da concurrencia sin paralelismo: un solo hilo, un solo core, el GIL ni compite. Nunca hay dos líneas de bytecode Python corriendo simultáneamente. Lo que ganas es que el tiempo total tiende al de la operación más lenta, no a la suma, porque las esperas se solapan.
import asyncio, time
async def fetch(name: str, delay: float) -> str:
await asyncio.sleep(delay) # simula I/O: aquí se cede el control
return name
async def sequential() -> None:
t0 = time.perf_counter()
a = await fetch("a", 1) # espera 1s...
b = await fetch("b", 1) # ...LUEGO espera otro 1s
print("sequential", time.perf_counter() - t0) # ~2.0s
async def concurrent() -> None:
t0 = time.perf_counter()
a, b = await asyncio.gather(fetch("a", 1), fetch("b", 1))
print("concurrent", time.perf_counter() - t0) # ~1.0s
sequential no es “lento por ser async” — es lento porque await uno-por-uno serializa las esperas. La concurrencia solo aparece cuando lanzas varias coroutines antes de esperar sus resultados (gather, create_task, TaskGroup).
El event loop: cómo funciona
El event loop es un bucle infinito que administra la ejecución cooperativa. En cada iteración (“tick”):
- Ejecuta los callbacks listos (los que
call_soonencoló) y las tasks que ya pueden avanzar, corriendo cada coroutine hasta su siguienteawait. - Cuando una coroutine hace
awaitsobre algo que aún no está listo (I/O pendiente, timer), suspende esa coroutine y registra una continuación: “cuando este socket sea legible / cuando venza este timer, reanúdame”. - Si no queda nada listo, el loop duerme en un syscall de multiplexado de I/O (
select/poll/epollsegún el OS), que despierta cuando algún file descriptor está listo o vence el timer más próximo. Esto es clave: el loop no hace busy-wait, cede el CPU al kernel. - Al despertar, marca como listas las coroutines cuyo I/O completó y vuelve al paso 1.
La propiedad esencial es que es cooperativo, no preemptivo: el loop solo recupera el control en los await. No hay un timer que interrumpa una coroutine a la fuerza (como sí hace el OS scheduler con threads). Corolario directo: cualquier código síncrono que no ceda —un time.sleep, un loop de cómputo, un driver de DB bloqueante— congela TODAS las demás coroutines hasta que retorne.
import asyncio
async def main() -> None:
print("start")
await asyncio.sleep(0) # yield al loop sin dormir: da un tick a otras tasks
print("resumed")
asyncio.run(main()) # crea el loop, corre main() hasta completar, cierra el loop
asyncio.run() es el entrypoint de alto nivel: crea un event loop nuevo, corre la coroutine hasta completarla, cancela tasks pendientes y cierra el loop. No debes anidar asyncio.run() dentro de otro (ya hay un loop corriendo) — desde dentro de una coroutine se usa await o create_task.
async / await y coroutines
async def define una coroutine function. Llamarla no ejecuta nada: devuelve un objeto coroutine “frío” que solo avanza cuando el loop lo mueve (vía await, create_task o gather).
async def greet() -> str:
return "hi"
c = greet() # NO imprime, NO corre: c es un coroutine object
# print(c) -> <coroutine object greet at 0x...>
# olvidarlo aquí -> RuntimeWarning: coroutine 'greet' was never awaited
result = await greet() # ahora sí corre y devuelve "hi" (dentro de otra coroutine)
await X significa “suspende esta coroutine hasta que X produzca su resultado, cediendo el control al loop mientras tanto”. X debe ser un awaitable: una coroutine, una Task, un Future, o un objeto con __await__. await es el único punto donde una coroutine puede ceder — entre dos await el código es efectivamente atómico respecto al loop (no hay context switch en medio), lo que elimina buena parte de las race conditions clásicas de threads.
Coroutines, Tasks y Futures
Tres niveles de abstracción, fáciles de confundir:
- Coroutine: el objeto que retorna llamar una
async def. Es un plan de ejecución perezoso; por sí solo no está agendado en el loop. - Future: un awaitable de bajo nivel que representa un resultado que aún no existe. Tiene estado (pending → done) y puedes registrar callbacks o
await-earlo. Rara vez lo creas a mano; es el mecanismo con el que librerías conectan I/O de bajo nivel conawait. - Task: una subclase de Future que envuelve una coroutine y la agenda en el loop para correr de inmediato. Crear una Task es lo que convierte “un plan” en “trabajo en vuelo”.
import asyncio
async def work(n: int) -> int:
await asyncio.sleep(0.1)
return n * 2
async def main() -> None:
# create_task AGENDA la coroutine ya: empieza a correr en el próximo tick,
# sin necesidad de await inmediato.
t1 = asyncio.create_task(work(10))
t2 = asyncio.create_task(work(20))
# ...aquí ambas ya están progresando concurrentemente...
r1 = await t1 # espero su resultado
r2 = await t2
print(r1, r2) # 20 40
asyncio.run(main())
Diferencia práctica: await coro corre la coroutine ahora y en línea (no hay concurrencia con lo que sigue). create_task(coro) la lanza en background y sigues; concurrencia real aparece cuando hay varias tasks vivas antes de esperarlas.
Trap de fire-and-forget: el loop guarda solo una referencia débil a las tasks. Si creas una task y no guardas la referencia, el garbage collector puede recolectarla a mitad de camino y la task “desaparece” silenciosamente. Guarda las tasks en un set y remuévelas en un done_callback:
_bg: set[asyncio.Task] = set()
def spawn(coro) -> None:
t = asyncio.create_task(coro)
_bg.add(t)
t.add_done_callback(_bg.discard)
Obtener y controlar el loop (API de bajo nivel)
Dentro de una coroutine, la forma correcta de obtener el loop es asyncio.get_running_loop() (falla explícito si no hay loop corriendo). Evita asyncio.get_event_loop() en código nuevo: su semántica sin loop activo está deprecada.
Programar callbacks (no coroutines, funciones normales) en el loop:
loop.call_soon(cb, *args): encolacbpara el próximo tick, sin delay.loop.call_later(delay, cb, *args): encolacbpara dentro dedelaysegundos.loop.call_at(when, cb, *args): en un instante absoluto del reloj del loop (loop.time()).
import asyncio
def ping(msg: str) -> None:
print("callback:", msg)
async def main() -> None:
loop = asyncio.get_running_loop()
loop.call_soon(ping, "now")
loop.call_later(0.5, ping, "in 0.5s")
await asyncio.sleep(1) # dejar tiempo a que corran los callbacks
asyncio.run(main())
Correr/parar el loop manualmente (útil para entender qué hace asyncio.run por dentro, o para embeber asyncio en frameworks que gestionan su propio loop):
import asyncio
async def main() -> None:
await asyncio.sleep(0.1)
return "done"
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(main()) # corre hasta que main() termina
finally:
loop.close()
loop.stop() detiene un loop.run_forever() tras el tick actual; se usa con loops de larga vida manejados a mano. En aplicaciones normales no toques esto — asyncio.run() (o el runner de tu framework: uvicorn, etc.) lo maneja por ti.
Librerías async: el ecosistema aio*
asyncio solo aporta el motor; para no bloquear el loop necesitas drivers async nativos en cada borde de I/O. Usar un cliente síncrono (requests, psycopg2 clásico, time.sleep) dentro de una coroutine bloquea el loop y anula toda la ventaja.
- HTTP:
aiohttpohttpx(async mode). - PostgreSQL:
asyncpg, o SQLAlchemy 2.0 async. - Redis:
redis.asyncio. - Kafka:
aiokafka.
import aiohttp, asyncio
async def fetch_json(session: aiohttp.ClientSession, url: str) -> dict:
async with session.get(url) as resp: # async context manager (ver abajo)
resp.raise_for_status()
return await resp.json() # await: el body llega por red
async def main() -> None:
async with aiohttp.ClientSession() as session:
data = await fetch_json(session, "https://example.com/api")
print(data)
asyncio.run(main())
Chaining/fan-out: gather
asyncio.gather(*aws) corre múltiples awaitables concurrentemente y devuelve sus resultados en el orden de los argumentos (no en orden de finalización). Es la herramienta de fan-out por excelencia.
import asyncio
async def get_user(uid): ...
async def get_orders(uid): ...
async def get_prefs(uid): ...
async def profile(uid: int) -> dict:
user, orders, prefs = await asyncio.gather(
get_user(uid), get_orders(uid), get_prefs(uid),
)
return {"user": user, "orders": orders, "prefs": prefs}
Semántica de errores de gather, que es donde está la trampa senior:
- Con
return_exceptions=False(default): la primera excepción se propaga inmediatamente alawait gather(...). Pero las otras coroutines NO se cancelan — siguen corriendo en background, potencialmente hasta completar (o hasta que el loop muera), y sus excepciones pueden quedar sin recoger. Puedes terminar con trabajo huérfano y warnings de “exception was never retrieved”. - Con
return_exceptions=True: ninguna excepción se propaga; cada resultado es o el valor o el objeto de excepción, y tú inspeccionas la lista.
Por eso en Python 3.11+ se prefiere asyncio.TaskGroup para fan-out con structured concurrency: si una task falla, el grupo cancela todas las demás y re-lanza al salir del async with. No hay trabajo huérfano ni fallo parcial silencioso.
import asyncio
async def profile(uid: int) -> dict:
async with asyncio.TaskGroup() as tg: # 3.11+
t_user = tg.create_task(get_user(uid))
t_orders = tg.create_task(get_orders(uid))
t_prefs = tg.create_task(get_prefs(uid))
# al salir del 'async with' todas terminaron; si alguna falló,
# el resto fue cancelado y aquí se re-lanza (como ExceptionGroup).
return {"user": t_user.result(), "orders": t_orders.result(), "prefs": t_prefs.result()}
Regla práctica: gather cuando el fallo parcial es aceptable (o con return_exceptions=True y manejo explícito); TaskGroup cuando “todo o nada” y no quieres tasks huérfanas.
Timeouts y cancellation
En sistemas de producción toda espera de I/O debe tener timeout. Sin él, un upstream colgado deja la coroutine —y el recurso que sostiene— pendiente para siempre.
import asyncio
async def main() -> None:
try:
# wait_for: si excede el timeout, CANCELA la coroutine interna y
# lanza TimeoutError.
result = await asyncio.wait_for(slow_call(), timeout=2.0)
except asyncio.TimeoutError:
result = fallback()
# 3.11+: asyncio.timeout() como context manager, envuelve un bloque entero.
try:
async with asyncio.timeout(2.0):
a = await step_one()
b = await step_two() # el timeout cubre TODO el bloque
except asyncio.TimeoutError:
...
Cancellation es el mecanismo por el que asyncio detiene una task en curso. task.cancel() programa que, en el próximo await de esa task, se lance asyncio.CancelledError dentro de la coroutine. Puntos clave, muy preguntados:
- Desde Python 3.8,
CancelledErrorhereda deBaseException, no deException. Por eso unexcept Exception(correctamente) no la captura: no quieres tragarte una cancelación por accidente. - Si capturas
CancelledErrorpara hacer cleanup, debes re-lanzarla. Tragarla convierte tu task en no-cancelable (“uncancellable task”), lo que rompe timeouts y shutdown ordenado. - La cancelación es cooperativa: solo surte efecto en un
await. Una coroutine bloqueada en cómputo síncrono no se puede cancelar hasta que ceda.
import asyncio
async def worker() -> None:
try:
while True:
await do_chunk()
except asyncio.CancelledError:
await flush_partial() # cleanup permitido...
raise # ...pero SIEMPRE re-lanzar
finally:
await release_resources() # cleanup garantizado
Para proteger una sección de la cancelación (raro, pero existe): asyncio.shield(coro). Para esperar a un set de tasks con política: asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED).
Async generators y async for
Un async generator es una async def que usa yield y además puede await entre yields. Sirve para producir un stream cuyos elementos llegan de forma asíncrona (paginación de una API, lectura de una cola, streaming de filas de la DB) sin materializar todo en memoria.
import asyncio
async def paginate(client, url: str):
next_url = url
while next_url:
page = await client.get_json(next_url) # await dentro del generator
for item in page["items"]:
yield item # emite item a item
next_url = page.get("next")
async def main(client) -> None:
async for item in paginate(client, "/api/v1/things"): # consumo con async for
process(item)
async for es la contraparte de consumo: en cada iteración hace await sobre __anext__() del async iterator, cediendo al loop mientras el próximo elemento se produce. Existe también la async comprehension: [x async for x in agen()].
Async context managers y async with
Un async context manager implementa __aenter__/__aexit__ (versiones awaitables de __enter__/__exit__) y se usa con async with. Necesario cuando la adquisición o liberación del recurso implican I/O: abrir/cerrar una sesión HTTP, tomar una conexión de un pool, adquirir un asyncio.Lock, iniciar/commit-ear una transacción.
import asyncio
from contextlib import asynccontextmanager
@asynccontextmanager
async def db_transaction(pool):
conn = await pool.acquire() # I/O al adquirir
tx = conn.transaction()
await tx.start()
try:
yield conn
except Exception:
await tx.rollback() # I/O al liberar
raise
else:
await tx.commit()
finally:
await pool.release(conn)
async def main(pool) -> None:
async with db_transaction(pool) as conn:
await conn.execute("INSERT ...")
Async Queue: patrón producer/consumer
asyncio.Queue coordina tasks dentro del mismo loop sin locks manuales (recuerda: single-thread, no hay race entre awaits). Es la base del patrón worker-pool async: productores encolan trabajo, un número acotado de consumidores lo drena, y el backpressure sale gratis si le pones maxsize.
import asyncio
async def producer(q: asyncio.Queue, items: list[int]) -> None:
for it in items:
await q.put(it) # bloquea (cede) si la cola está llena -> backpressure
async def consumer(q: asyncio.Queue, name: str) -> None:
while True:
item = await q.get()
try:
await handle(item)
finally:
q.task_done() # marca el item como procesado
async def main() -> None:
q: asyncio.Queue = asyncio.Queue(maxsize=100)
workers = [asyncio.create_task(consumer(q, f"w{i}")) for i in range(5)]
await producer(q, list(range(1000)))
await q.join() # espera a que todos los items se procesen
for w in workers: # los consumidores corren en while True: cancelarlos
w.cancel()
await asyncio.gather(*workers, return_exceptions=True)
Cuándo async, cuándo threads, cuándo processes
El árbol de decisión senior parte de la naturaleza del trabajo:
| Situación | Herramienta | Por qué |
|---|---|---|
| I/O-bound + drivers async disponibles | asyncio | máxima concurrencia, un hilo, mínimo overhead |
| I/O-bound pero la librería es síncrona/bloqueante | ThreadPoolExecutor (o run_in_executor) | el GIL se libera durante el syscall de I/O, así que threads sí escalan aquí |
| CPU-bound (cómputo puro) | ProcessPoolExecutor / multiprocessing | cada proceso tiene su propio GIL → paralelismo real en varios cores |
concurrent.futures da una API uniforme (Executor) para las dos últimas: ThreadPoolExecutor y ProcessPoolExecutor comparten interfaz (submit, map), así que cambiar de threads a procesos es cambiar una línea. Y asyncio se integra con ellos vía run_in_executor, que es el puente para no bloquear el loop con código legacy:
import asyncio, functools
from concurrent.futures import ProcessPoolExecutor
def heavy_cpu(data: bytes) -> str: # función SÍNCRONA, CPU-bound
return expensive_hash(data)
async def handler(data: bytes) -> str:
loop = asyncio.get_running_loop()
# offload al pool de PROCESOS: no bloquea el loop, corre en otro core.
return await loop.run_in_executor(
_process_pool, functools.partial(heavy_cpu, data)
)
_process_pool = ProcessPoolExecutor(max_workers=4)
Con None como primer argumento, run_in_executor usa el ThreadPoolExecutor por defecto del loop — perfecto para envolver una librería síncrona de I/O (un requests.get, un driver de DB legacy) sin bloquear. Para CPU-bound, pásale explícitamente un ProcessPoolExecutor.
Custom event loops: uvloop
El event loop por defecto de CPython está escrito en Python. uvloop es una implementación alternativa basada en libuv (el motor de Node.js), escrita en Cython, que suele dar 2–4x más throughput en cargas de red. Es un drop-in: no cambias tu código async, solo la policy del loop.
import asyncio
import uvloop
# Forma clásica (instala uvloop como loop por defecto):
uvloop.install() # antes de asyncio.run(...)
asyncio.run(main())
# Python 3.12+ / uvloop reciente, con runner explícito:
# uvloop.run(main())
En producción, servidores como uvicorn ya seleccionan uvloop automáticamente si está instalado (--loop uvloop). No está disponible en Windows (libuv/uvloop apuntan a plataformas Unix); ahí se cae al loop estándar o a ProactorEventLoop. La lección conceptual: el event loop es pluggable vía asyncio.AbstractEventLoop / event loop policy, y uvloop demuestra que la semántica async del lenguaje es independiente de la implementación del motor.
Errores comunes (los que se ven en code review)
- Bloquear el loop con código síncrono.
time.sleep(),requests.get(), un driver de DB sync, o un loop de cómputo pesado dentro de una coroutine congelan todas las demás tasks. Fix: driver async, orun_in_executor. - Usar drivers síncronos “porque funcionan”. Corren, pero serializan todo: pierdes la concurrencia y ni te das cuenta hasta que la latencia p99 se dispara bajo carga.
- Coroutine nunca esperada. Llamar
foo()(async) sinawait/create_taskno ejecuta nada y tiraRuntimeWarning: coroutine was never awaited. Bug silencioso frecuente. awaiten serie donde querías concurrencia.await a(); await b()es secuencial. Para concurrencia:gather/TaskGroup/tasks.- Tragar
CancelledError. Unexcept Exceptionestá bien (no la atrapa), pero unexcept BaseExceptiono unexcept asyncio.CancelledErrorsin re-lanzar rompe timeouts y shutdown. - Fire-and-forget sin guardar la task. Puede ser recolectada por el GC a mitad de ejecución. Guarda referencia.
gatheresperando cancelación automática. Si una falla, las otras no se cancelan solas — usaTaskGroupo cancela a mano.asyncio.run()anidado. No se puede llamar dentro de un loop ya corriendo (típico en notebooks/frameworks). Desde dentro usaawait.
Ejercicios
Ejercicio 1 — Endpoint I/O-bound con fan-out concurrente
Tienes un endpoint que arma el dashboard de un usuario consultando 3 servicios upstream (fetch_profile, fetch_stats, fetch_notifications), cada uno ~200 ms. La versión actual los espera en serie (~600 ms). Reescríbela para que corra en ~200 ms, y haz que si cualquiera falla se cancele el resto y se propague el error (nada de dashboards a medias).
Solución
import asyncio
async def build_dashboard(uid: int) -> dict:
async with asyncio.TaskGroup() as tg: # 3.11+: structured concurrency
t_profile = tg.create_task(fetch_profile(uid))
t_stats = tg.create_task(fetch_stats(uid))
t_notifs = tg.create_task(fetch_notifications(uid))
# Al salir del 'async with' las 3 terminaron. Si una lanzó, el grupo canceló
# las otras y re-lanza aquí (ExceptionGroup). Nunca hay resultado parcial.
return {
"profile": t_profile.result(),
"stats": t_stats.result(),
"notifs": t_notifs.result(),
}
# Variante pre-3.11 con gather. OJO: return_exceptions=False NO cancela las otras
# si una falla. Para "todo o nada" real hay que cancelarlas a mano:
async def build_dashboard_gather(uid: int) -> dict:
tasks = [
asyncio.create_task(fetch_profile(uid)),
asyncio.create_task(fetch_stats(uid)),
asyncio.create_task(fetch_notifications(uid)),
]
try:
profile, stats, notifs = await asyncio.gather(*tasks)
except Exception:
for t in tasks:
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True) # drenar cancelaciones
raise
return {"profile": profile, "stats": stats, "notifs": notifs}
Clave: el tiempo total pasa de suma (~600 ms) al máximo (~200 ms) porque las 3 esperas de red se solapan. TaskGroup da el “todo o nada” sin código extra; con gather hay que cancelar manualmente.
Ejercicio 2 — Timeout + cancelación con cleanup correcto
Escribe charge_with_timeout(gateway, order_id, timeout=3.0) que llame a un gateway de pago async. Si excede el timeout, debe cancelar la llamada, hacer rollback de la reserva y lanzar un error de dominio. La coroutine del gateway debe hacer su propio cleanup al ser cancelada sin volverse no-cancelable.
Solución
import asyncio
class PaymentTimeout(Exception): ...
async def call_gateway(gateway, order_id: str) -> dict:
try:
return await gateway.charge(order_id)
except asyncio.CancelledError:
await gateway.abort(order_id) # cleanup del recurso remoto...
raise # ...pero SIEMPRE re-lanzar CancelledError
async def charge_with_timeout(gateway, order_id: str, timeout: float = 3.0) -> dict:
try:
async with asyncio.timeout(timeout): # 3.11+
return await call_gateway(gateway, order_id)
except asyncio.TimeoutError as err:
await rollback_reservation(order_id)
raise PaymentTimeout(f"gateway timed out for {order_id}") from err
Puntos evaluados: (1) asyncio.timeout cancela la coroutine interna al vencer; (2) call_gateway captura CancelledError solo para hacer cleanup y re-lanza (si no, la task queda no-cancelable y el timeout no surtiría efecto); (3) el error de timeout se traduce a excepción de dominio con from para preservar el root cause. Nota: except Exception NO habría atrapado CancelledError (hereda de BaseException), que es justo lo que queremos.
Ejercicio 3 — Detectar dónde se bloquea el loop
Este handler “async” tiene p99 de latencia horrible bajo carga y CPU al 100% en un solo core. Encuentra las tres cosas que bloquean el event loop y arréglalas.
import time, requests, hashlib
import asyncio
async def handle(url: str, payload: bytes) -> dict:
time.sleep(0.1) # (1)
resp = requests.get(url) # (2)
digest = hashlib.pbkdf2_hmac("sha256", payload, b"salt", 1_000_000) # (3)
return {"status": resp.status_code, "digest": digest.hex()}
Solución
Las tres líneas son síncronas y bloquean el único hilo del loop, congelando todas las demás coroutines mientras corren:
time.sleep(0.1)→ bloqueo puro. Fix:await asyncio.sleep(0.1)(o eliminarlo).requests.get(url)→ cliente HTTP síncrono. Fix: un driver async (aiohttp/httpxasync).hashlib.pbkdf2_hmac(..., 1_000_000)→ CPU-bound puro (1M iteraciones). No hay I/O que aprovechar:awaitno ayuda. Fix: offload a unProcessPoolExecutor.
import asyncio, functools, hashlib
import aiohttp
from concurrent.futures import ProcessPoolExecutor
_cpu_pool = ProcessPoolExecutor(max_workers=4)
def _derive(payload: bytes) -> bytes: # síncrona, CPU-bound
return hashlib.pbkdf2_hmac("sha256", payload, b"salt", 1_000_000)
async def handle(session: aiohttp.ClientSession, url: str, payload: bytes) -> dict:
await asyncio.sleep(0.1) # (1) no bloquea
loop = asyncio.get_running_loop()
async with session.get(url) as resp: # (2) I/O async real
status = resp.status
digest = await loop.run_in_executor( # (3) CPU-bound a otro proceso
_cpu_pool, functools.partial(_derive, payload)
)
return {"status": status, "digest": digest.hex()}
El insight: (2) es I/O-bound → driver async; (3) es CPU-bound → proceso aparte. Confundir ambos (meter el hash en un ThreadPool) no ayudaría, porque el GIL serializa el cómputo Python entre threads.
Ejercicio 4 — Worker pool con asyncio.Queue y backpressure
Implementa un crawler que procese una lista de URLs con como máximo 10 requests concurrentes (no 10.000 a la vez), reintentando fallos una vez, y que termine limpio cuando todas se procesaron.
Solución
import asyncio
async def worker(q: asyncio.Queue, session, results: list) -> None:
while True:
url, attempts = await q.get()
try:
results.append(await fetch(session, url))
except Exception:
if attempts < 1:
await q.put((url, attempts + 1)) # reintento: re-encola
# si ya reintentó, se descarta (o loguear)
finally:
q.task_done() # marca ESTE item, siempre
async def crawl(urls: list[str], concurrency: int = 10) -> list:
q: asyncio.Queue = asyncio.Queue()
results: list = []
for u in urls:
q.put_nowait((u, 0))
async with make_session() as session:
workers = [
asyncio.create_task(worker(q, session, results))
for _ in range(concurrency)
]
await q.join() # espera a que se drene la cola
for w in workers: # while True -> cancelar
w.cancel()
await asyncio.gather(*workers, return_exceptions=True)
return results
La concurrencia queda acotada por el número de workers (10), no por el número de URLs — eso es el control de concurrencia/backpressure. q.join() desbloquea cuando cada get() tuvo su task_done() correspondiente (incluidos los reintentos, porque re-encolar suma un item que también deberá marcarse). Alternativa más simple para acotar concurrencia sin cola: un asyncio.Semaphore(10) alrededor de cada fetch, con asyncio.gather sobre todas las URLs.
Preguntas tipo entrevista (EN)
Q1 — What’s the difference between concurrency and parallelism, and which one does asyncio give you?
- ❌ Wrong / trap: “asyncio runs tasks in parallel across CPU cores, so it’s faster.” Why it fails: asyncio is single-threaded and single-core; it never runs two Python bytecodes at once. It provides no CPU parallelism at all — claiming it does reveals a fundamental misunderstanding.
- ✅ Correct: Concurrency is structuring a program as independently-progressing tasks that interleave; parallelism is literally executing computations simultaneously on multiple cores. asyncio gives concurrency without parallelism: one thread, cooperative scheduling at
awaitpoints. - ⭐ Optimal: Because it’s concurrency-not-parallelism, asyncio only helps I/O-bound work, where total time collapses toward the slowest operation instead of the sum, since waits overlap. For CPU-bound work there’s no wait to exploit and a computing coroutine blocks the whole loop — that needs
multiprocessing/ProcessPoolExecutorfor real parallelism, each process with its own GIL.
Q2 — What exactly happens when you await a coroutine? And what does calling an async def without awaiting do?
- ❌ Wrong / trap: “
awaitruns the function and calling it also runs it, await just gets the result.” Why it fails: calling anasync defruns nothing — it returns a cold coroutine object. Missing that is the source of “coroutine was never awaited” bugs. - ✅ Correct: Calling
async defreturns a coroutine object that isn’t scheduled.await corodrives it to completion, suspending the current coroutine and yielding control to the event loop at each internal await until the result is ready. - ⭐ Optimal:
await cororuns it inline (no concurrency with the following code). To get concurrency you must schedule work before awaiting —asyncio.create_task,gather, or aTaskGroup. Forgetting to await produces aRuntimeWarningand silently no-ops; forgetting to keep a reference to a fire-and-forget task lets the GC collect it mid-flight.
Q3 — You call time.sleep(5) inside an async request handler. What happens, and how do you fix it?
- ❌ Wrong / trap: “It just delays that one request.” Why it fails: asyncio is cooperative and single-threaded, so a synchronous
time.sleepblocks the entire loop — every other concurrent request stalls for 5 seconds. - ✅ Correct: It freezes the whole event loop for 5s; all other coroutines are blocked because nothing yields control. Fix a pure delay with
await asyncio.sleep(5). - ⭐ Optimal: The general rule is that any blocking call —
time.sleep,requests, a sync DB driver, heavy CPU loops — starves the loop. For a synchronous I/O library, offload withawait loop.run_in_executor(None, blocking_fn)(default ThreadPool; the GIL is released during the syscall). For CPU-bound work, use aProcessPoolExecutor. Best of all, use a native async driver so you never block in the first place.
Q4 — asyncio.gather vs asyncio.TaskGroup: when and why?
- ❌ Wrong / trap: “They’re the same, TaskGroup is just newer syntax.” Why it fails: they differ crucially in failure semantics — this is a correctness issue, not style.
- ✅ Correct:
gatherruns awaitables concurrently and returns results in argument order. With defaultreturn_exceptions=False, the first exception propagates immediately, but the other coroutines are not cancelled and keep running.TaskGroup(3.11+) is structured concurrency: if any task fails, it cancels the rest and re-raises after all finish. - ⭐ Optimal: Use
gatherwhen partial failure is acceptable, orgather(..., return_exceptions=True)to collect per-item results/exceptions explicitly. UseTaskGroupfor all-or-nothing fan-out where orphaned background tasks and silent partial failures are unacceptable — it raises anExceptionGroupand guarantees no task outlives the block. In new codeTaskGroupis the safer default.
Q5 — Explain cancellation. Why shouldn’t you catch CancelledError with except Exception, and what must you do if you catch it?
- ❌ Wrong / trap: “Wrap the body in
try/except Exceptionto handle cancellation and keep going.” Why it fails: since Python 3.8CancelledErrorinherits fromBaseException, notException, soexcept Exceptionwon’t catch it (by design) — and if you did catch and swallow it, you’d make the task uncancellable, breaking timeouts and graceful shutdown. - ✅ Correct:
task.cancel()schedules aCancelledErrorto be raised at the task’s nextawait. Cancellation is cooperative — it only takes effect at await points, so a coroutine stuck in synchronous computation can’t be cancelled until it yields. - ⭐ Optimal: If you catch
CancelledErrorto run cleanup, you must re-raise it so cancellation propagates; usefinallyfor guaranteed resource release.except Exceptioncorrectly leaves it alone. To protect a critical section from cancellation useasyncio.shield, and rememberasyncio.wait_for/asyncio.timeoutimplement timeouts precisely by cancelling the inner coroutine — a swallowedCancelledErrorsilently defeats them.
Q6 — Coroutine vs Task vs Future — what’s the difference?
- ❌ Wrong / trap: “They’re three names for the same async thing.” Why it fails: they sit at different levels; conflating them hides when work actually gets scheduled.
- ✅ Correct: A coroutine is the lazy object returned by an
async def, not scheduled by itself. A Future is a low-level awaitable representing an eventual result with pending/done state. A Task is a Future subclass that wraps a coroutine and schedules it on the loop to run immediately. - ⭐ Optimal: Creating a Task (
create_task) is what turns “a plan” into “in-flight work” — that’s the seam where concurrency begins. Futures are rarely created by hand; libraries use them to bridge low-level I/O callbacks into theawaitworld. Because a Task is a Future, you can attachadd_done_callback,cancel()it, orawaitit — and you should hold a reference so the GC doesn’t drop a fire-and-forget task.
Q7 — When would you reach for run_in_executor, and how do you choose ThreadPool vs ProcessPool?
- ❌ Wrong / trap: “Always throw blocking work in a ThreadPoolExecutor.” Why it fails: threads don’t parallelize CPU-bound Python because of the GIL — CPU work in a ThreadPool still serializes and still contends for the interpreter.
- ✅ Correct:
run_in_executoroffloads blocking synchronous code off the event loop. Use the default ThreadPool (None) for synchronous I/O libraries — the GIL is released during the blocking syscall, so threads scale there. Use aProcessPoolExecutorfor CPU-bound work so it runs on another core with its own GIL. - ⭐ Optimal: The decision follows the workload: I/O-bound + async driver → plain asyncio; I/O-bound + sync library → ThreadPool via
run_in_executor; CPU-bound → ProcessPool.concurrent.futuresgives both executors a uniformExecutorAPI, so switching is one line, andrun_in_executoris the bridge that keeps the loop responsive while legacy/CPU code runs elsewhere. Note process pools add serialization (pickling) overhead, so they pay off only when the compute clearly dominates that cost.
Referencias
- Python docs —
asyncio: https://docs.python.org/3/library/asyncio.html - Coroutines and Tasks: https://docs.python.org/3/library/asyncio-task.html
asyncio.gather/TaskGroup: https://docs.python.org/3/library/asyncio-task.html#asyncio.gather- Event loop API: https://docs.python.org/3/library/asyncio-eventloop.html
- Timeouts (
asyncio.timeout,wait_for): https://docs.python.org/3/library/asyncio-task.html#timeouts - Async generators /
async for(PEP 525): https://peps.python.org/pep-0525/ - Async context managers /
async with(PEP 492): https://peps.python.org/pep-0492/ asyncio.Queue: https://docs.python.org/3/library/asyncio-queue.htmlconcurrent.futures(Executors): https://docs.python.org/3/library/concurrent.futures.html- uvloop: https://github.com/MagicStack/uvloop
- aiohttp: https://docs.aiohttp.org/