Guzmán D. Darío Senior Python Developer Español Hire me
Taller autoguiado

De bloqueante a tiempo real: un pipeline de video multi-cámara con asyncio

Paso 08 de 23
Paso 8 de 23

La trampa de la CPU

Hasta aquí asyncio parece magia: pones async delante de todo y las cosas van más rápido. En este paso vas a descubrir dónde deja de funcionar, y es un descubrimiento que te va a ahorrar días de depuración en el futuro.

Un latido para vigilar el bucle

Necesitamos una forma de saber si el event loop está sano. La técnica es sencilla: una tarea que imprime la hora cada 100 milisegundos. Si el bucle va bien, los intervalos serán de 100 milisegundos clavados. Si alguien lo bloquea, veremos el hueco.

Primero necesitas algo que queme CPU de verdad. Crea heavy.py:

heavy.py python
import numpy as np


def detect_in_process(jpeg: bytes):
    """Pure CPU work, standing in for a model running inside this process."""
    total = 0.0
    for _ in range(3):
        a = np.random.rand(700, 700)
        b = np.random.rand(700, 700)
        total += float((a @ b).sum())
    return total

Multiplicar matrices de 700 por 700 tarda unos cientos de milisegundos y no hace nada de I/O: es cálculo puro, igual que un modelo corriendo dentro de tu proceso.

Ahora crea block_loop.py:

block_loop.py python
async def heartbeat():
    last = time.perf_counter()
    while True:
        now = time.perf_counter()
        print(f"tick   gap {(now - last) * 1000:6.0f} ms")
        last = now
        await asyncio.sleep(0.1)

async def infer_badly():
    for _ in range(4):
        detect_in_process(b"")       # <- pure CPU, inside the loop
        await asyncio.sleep(0)       # too late, the blocking already happened

async def main():
    asyncio.create_task(heartbeat())
    await asyncio.sleep(0.35)
    await infer_badly()
    await asyncio.sleep(0.35)

asyncio.run(main())

Fíjate en main, porque introduce algo nuevo: asyncio.create_task. A diferencia de await, que espera a que la corrutina termine, create_task la lanza a correr en segundo plano y sigue con la línea siguiente. Por eso el latido late mientras el resto del programa hace lo suyo.

Y detect_in_process es una detección hecha dentro de tu proceso: puro cálculo con NumPy, sin red de por medio.

Vamos a equivocarnos a propósito · Vamos a congelar el event loop

Ejecuta esto y mira los números de la columna gap. Un bucle sano late cada 101 milisegundos.

python block_loop.py

tick gap 101 ms tick gap 101 ms tick gap 1292 ms <- tick gap 395 ms <- tick gap 101 ms

Cómo resolverlo · El event loop estuvo más de un segundo desaparecido

Ese 1292 ms significa que durante casi un segundo y medio el event loop no ejecutó absolutamente nada. No es que el latido llegara tarde: es que no había nadie para atenderlo.

La razón es la que anticipamos en el paso 4. detect_in_process es cálculo puro: no tiene ningún await por dentro, así que no hay ni un solo punto donde pueda ceder el control. Una vez que el event loop la llama, esa función es dueña del hilo hasta que termina.

Y ojo con la línea siguiente, que es la trampa dentro de la trampa:

await asyncio.sleep(0) # too late, the blocking already happened

Ese await no arregla nada. Cede el control después de que el bloqueo ya ocurrió. Es como abrir la ventana cuando el incendio ya quemó la casa. Para que un await sirva tiene que estar dentro de lo que tarda, no después.

Qué significa esto en un pipeline

Traduce ese hueco a tu proyecto real. Durante ese segundo y medio:

Una sola función pesada, mal colocada, paraliza el sistema entero. Y lo peor es que no falla: no hay excepción ni error. Simplemente todo va a tirones y no sabes por qué.

Arriba, tareas de I/O: cada una trabaja un instante y suelta el control, y el bucle salta entre ellas. Abajo, una tarea de cálculo puro ocupa toda la línea de tiempo y nadie más avanza.
Arriba, tareas de I/O: cada una trabaja un instante y suelta el control, y el bucle salta entre ellas. Abajo, una tarea de cálculo puro ocupa toda la línea de tiempo y nadie más avanza.

Analogía · El mesero que se pone a lavar los platos

Vuelve al mesero del paso anterior. Funcionaba porque cada cosa que hacía duraba un instante: tomar un pedido, llevarlo a la cocina, dejar una cuenta.

Ahora imagina que alguien le pide que lave la vajilla entera. Mientras lava, no hay nadie tomando pedidos ni llevando platos. La sala se detiene, no porque el mesero sea malo, sino porque le diste una tarea que no se puede interrumpir.

Lavar los platos no es trabajo de mesero. Es trabajo de lavaplatos.

Checkpoint

Deberías tener en tu terminal un gap de más de mil milisegundos, y saber decir por qué ocurrió: una función de cálculo puro, sin await por dentro, ejecutada dentro del event loop.

En el siguiente paso vamos a arreglarlo. Y luego vamos a darnos cuenta de que la solución evidente no era la buena.