Skip to content

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 def por async def sin offloading. Mide de nuevo: catástrofe. Aplica to_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.py del lab 00.
  • uv add httpx (o aiohttp) para el generador de carga.
  • Una lista de 100 prompts de corrección de verbos del corpus §A13.

Tareas

  1. 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
  1. Variante A — handler sync (baseline del lab 00). Mantén def correct(req): .... Arranca el servidor con uv run uvicorn miniserve.app:app --workers 1. Ejecuta el loadtest con concurrency=50, total=200.

  2. 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.

  1. 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.

  1. 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.

  1. Escribe una nota corta (5-10 líneas, en las notas del lab) explicando:
  2. Por qué la variante B es tanto peor que A y C.
  3. Por qué A y C son aproximadamente equivalentes.
  4. Cuándo preferirías C sobre A (pista: cuando el handler también haga await sobre 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.1 para 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=200 y concurrency=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 que async + to_thread, porque el tamaño del threadpool está acotado.

Siguiente: 02-static-batching.md