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:
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:
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:
- Ninguna cámara se leyó. Los frames que llegaron por la red se quedaron en el buffer del sistema operativo, o se perdieron.
- Ninguna cola se vació.
- Ningún evento se publicó.
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é.
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.