English · Español
Lab 01 — Sync vs async: la trampa del handler bloqueante¶
🇪🇸 Carga el servicio de lab 00 con 50 clientes concurrentes. Mide latencias. Cambia
defporasync defsin offloading. Mide de nuevo: catástrofe. Aplicato_thread. Re-mide: arreglado.
Objetivo¶
Demostrar la trampa async def + llamada bloqueante descrita en theory/01-async-and-the-event-loop.md. Hacer load-test de tres variantes de handler y producir una CDF de latencia comparativa.
Setup¶
src/miniserve/app.pydel lab 00.uv add httpx(oaiohttp) para el generador de carga.- Una lista de 100 prompts de corrección de verbos del corpus §A13.
Tareas¶
- Escribe el generador de carga en
scripts/loadtest.py:
import asyncio, time, httpx
async def one_request(client, payload, results):
t0 = time.perf_counter()
r = await client.post("/correct", json=payload)
results.append((time.perf_counter() - t0, r.status_code))
async def run_load(concurrency: int, total: int, payloads: list[dict]):
results = []
async with httpx.AsyncClient(base_url="http://127.0.0.1:8000", timeout=30) as c:
sem = asyncio.Semaphore(concurrency)
async def bounded(p):
async with sem:
await one_request(c, p, results)
await asyncio.gather(*(bounded(payloads[i % len(payloads)]) for i in range(total)))
return results
-
Variante A — handler sync (baseline del lab 00). Mantén
def correct(req): .... Arranca el servidor conuv run uvicorn miniserve.app:app --workers 1. Ejecuta el loadtest conconcurrency=50, total=200. -
Variante B — handler async con llamada bloqueante. Cambia a:
@app.post("/correct")
async def correct(req: CorrectRequest) -> CorrectResponse:
result = agent.correct(req.sentence, learner_id=req.learner_id) # blocking!
return ...
Reinicia el servidor. Re-ejecuta el loadtest. Espera catástrofe: la p95 debería ser ~5-10× peor.
- Variante C — handler async con
to_thread. Cambia a:
import anyio
@app.post("/correct")
async def correct(req: CorrectRequest) -> CorrectResponse:
result = await anyio.to_thread.run_sync(
agent.correct, req.sentence, req.learner_id
)
return ...
Reinicia el servidor. Re-ejecuta el loadtest. Espera recuperación — similar a la variante A.
- Dibuja una CDF de latencia con las tres variantes en los mismos ejes (
scripts/plot_cdf.py). Eje x: latencia (ms), escala logarítmica; eje y: fracción acumulada.
Anota p50, p95, p99 en cada curva.
- Escribe una nota corta (5-10 líneas, en las notas del lab) explicando:
- Por qué la variante B es tanto peor que A y C.
- Por qué A y C son aproximadamente equivalentes.
- Cuándo preferirías C sobre A (pista: cuando el handler también haga
awaitsobre I/O async por otras razones — por ejemplo, logging a un sink remoto, llamar a una base de datos).
Mediciones¶
Guarda en experiments/<date>-phase-33-lab-01/:
latencies_sync.json,latencies_async_blocking.json,latencies_async_tothread.json— arrays de (latencia, status_code).latency_cdf.png— la CDF comparativa.summary.md— tus observaciones escritas.manifest.json— seeds, versiones, concurrencia, total de peticiones.
Aceptación¶
- Las tres variantes consiguen ≥ 99% HTTP 200 bajo el test de carga (sin timeouts).
- La p95 de la variante B es al menos 3× peor que las variantes A y C.
- Las variantes A y C tienen p95 dentro del 20% una de la otra.
- La CDF muestra claramente tres curvas distintas.
Trampas¶
- Olvidar
--workers 1. Con varios workers de uvicorn, el problema del handler bloqueante queda parcialmente enmascarado (cada worker tiene su propio event loop). Estamos estudiando el comportamiento por proceso; fija workers a 1. - Cold start contaminando la medición. Envía 10 peticiones de warmup antes de grabar. La primera petición siempre es lenta (imports diferidos, JIT, cache misses).
- Overhead de red. Ejecuta el loadtest en
127.0.0.1para evitar jitter de red. Estamos midiendo el comportamiento del servidor, no TCP. - Timeout por defecto de httpx = 5s — demasiado corto. Súbelo a 30s, si no la variante B reportará errores de timeout que parecen errores del servidor.
- No suficientes muestras. Con
total=200yconcurrency=50, cada batch es de ~4 de profundidad. Para que la p99 sea estable, sube total a 500+.
Stretch¶
- Repite el experimento con
--workers 4. ¿Cómo cambia la imagen? - Añade un
time.sleep(0.05)dentro del handler (para simular un retraso de I/O adicional) y vuelve a ejecutar. La variante sync con threadpool debería degradarse más queasync + to_thread, porque el tamaño del threadpool está acotado.
Siguiente: 02-static-batching.md