wandres.dev
MEMORY II · Cazar una fuga

La técnica de los tres snapshots y por qué tres

El procedimiento que aísla exactamente los objetos creados durante un ciclo y no liberados al terminarlo, por qué con dos instantáneas no funciona, y el filtro que lo hace posible.

⏱ 20 min

Comparar dos instantáneas del montón parece la forma obvia de encontrar una fuga y produce tanto ruido que resulta inutilizable: entre dos momentos cualquiera de la vida de una aplicación se crean decenas de miles de objetos perfectamente legítimos, se llenan cachés, se compila código, se inicializan cosas por primera vez. La técnica de las tres instantáneas resuelve ese problema de señal con una idea muy simple, y es la única forma fiable de cazar una fuga en una aplicación real.

🎯 Al terminar esta lección sabrás
  • Ejecutar el procedimiento de las tres instantáneas sobre un ciclo definido.
  • Explicar por qué dos instantáneas no bastan y qué añade exactamente la tercera.
  • Usar el filtro de objetos asignados entre dos instantáneas.
  • Diseñar un ciclo que produzca una señal limpia.

El procedimiento

flowchart TB
a[Estado inicial estable] --> b[Instantanea 1 linea base]
b --> c[Ejecutar el ciclo N veces]
c --> d[Volver al estado inicial]
d --> e[Instantanea 2]
e --> f[Ejecutar el ciclo N veces mas]
f --> g[Volver al estado inicial]
g --> h[Instantanea 3]
h --> i[Ver la 3 filtrando por objetos creados entre 1 y 2]
i --> j{Queda algo}
j -->|No| k[No hay fuga en este ciclo]
j -->|Si| l[Esos objetos son la fuga]
l --> m[Leer su camino de retencion]
style a fill:#cba6f7,color:#11111b
style b fill:#89b4fa,color:#11111b
style e fill:#89b4fa,color:#11111b
style h fill:#89b4fa,color:#11111b
style i fill:#f9e2af,color:#11111b
style k fill:#a6e3a1,color:#11111b
style l fill:#f38ba8,color:#11111b
style m fill:#94e2d5,color:#11111b

El paso clave es el penúltimo. Con la tercera instantánea abierta, la vista de comparación permite filtrar por los objetos asignados entre la instantánea 1 y la 2. Lo que se está preguntando con ese filtro es exactamente:

De todo lo que se creó durante el primer bloque de ciclos, ¿qué sigue vivo después de haber ejecutado un segundo bloque completo y haber vuelto al estado inicial?

Y esa pregunta tiene una propiedad extraordinaria: su respuesta debería ser el conjunto vacío. Cualquier objeto que la responda es sospechoso por construcción, sin necesidad de juzgar si parece legítimo o no.

Por qué dos no bastan

Con dos instantáneas, la única comparación posible es “qué hay ahora que no había antes”, y ese conjunto está contaminado por cuatro fuentes de ruido que no son fugas.

Inicialización perezosa. Muchas cosas se crean la primera vez que se necesitan: un módulo que se importa dinámicamente, una expresión regular compilada, una tabla de traducciones, una conexión. Aparecen en la comparación y no volverán a aparecer nunca más.

Cachés que se llenan. Una caché que guarda los últimos cincuenta resultados crece durante los primeros cincuenta ciclos y después se estabiliza. En una comparación de dos instantáneas parece una fuga y no lo es.

Compilación y optimización. El motor compila y recompila código durante la ejecución. Aparecen objetos de código nuevos entre dos instantáneas sin que nadie haya asignado nada.

Trabajo pendiente. Promesas en vuelo, temporizadores programados, tareas encoladas. Objetos vivos legítimamente en el instante de la foto.

La tercera instantánea filtra las cuatro de golpe con un argumento muy limpio: si algo se creó en el primer bloque de ciclos y sobrevivió al segundo bloque entero, no es inicialización, no es caché en llenado, y no es trabajo pendiente, porque el segundo bloque hizo exactamente lo mismo y por tanto habría reutilizado la inicialización, habría estabilizado la caché y habría completado el trabajo.

ℹ️
Nota

Esta es también la razón de ejecutar el ciclo varias veces en cada bloque en lugar de una. Una sola ejecución produce una señal débil y muchas coincidencias; diez ejecuciones producen diez instancias de cada objeto filtrado, y ese número redondo es en sí mismo una confirmación. Si tras diez ciclos hay exactamente diez instancias de algo, la relación es de uno por ciclo y el diagnóstico está prácticamente cerrado.

Diseñar el ciclo

El ciclo es la parte que decide si la técnica funciona, y merece más atención de la que se le suele dar. Un buen ciclo cumple cuatro condiciones.

Vuelve al mismo estado lógico. Al final, la aplicación tiene que estar donde estaba. Si el ciclo deja algo abierto, algo seleccionado o algo en un histórico, la diferencia contendrá objetos que están vivos con razón.

Es corto. Segundos, no minutos. Vas a ejecutarlo veinte veces.

Ejercita una sola cosa. Un ciclo que abre un modal, navega a otra ruta y hace una búsqueda mezcla tres fuentes posibles de fuga. Si encuentra algo, no sabrás cuál.

Es automatizable. Ejecutarlo veinte veces a mano introduce variabilidad y es tedioso hasta el punto de que se acaba haciendo mal.

// Ejecutor de ciclos para sesiones de memoria: repite y avisa cuando parar
async function ejecutarCiclo(ciclo, vueltas = 10, pausaMs = 150) {
  console.log('Ejecutando', vueltas, 'ciclos...');
  const t0 = performance.now();
  for (let i = 1; i <= vueltas; i++) {
    await ciclo(i);
    await new Promise(r => setTimeout(r, pausaMs));
  }
  // Dos fotogramas de margen para que se completen desmontajes diferidos
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
  console.log('Hecho en', Math.round(performance.now() - t0), 'ms.');
  console.log('Ahora: fuerza la recoleccion y toma la instantanea.');
}

// Ejemplo de ciclo real: abrir un panel y cerrarlo, con esperas explicitas
const cicloAbrirCerrar = async () => {
  document.querySelector('[data-abrir-detalle]')?.click();
  await new Promise(r => setTimeout(r, 200));
  document.querySelector('[data-cerrar-detalle]')?.click();
  await new Promise(r => setTimeout(r, 200));
};

window.ejecutarCiclo = ejecutarCiclo;
window.cicloAbrirCerrar = cicloAbrirCerrar;
console.log('Usa: await ejecutarCiclo(cicloAbrirCerrar, 10)');

Las esperas explícitas dentro del ciclo no son adorno: sin ellas, el ciclo puede terminar antes de que el desmontaje asíncrono haya ocurrido, y entonces la instantánea contiene objetos que estaban a punto de liberarse. Es la causa número uno de falsos positivos en esta técnica.

Leer el resultado

Con la tercera instantánea y el filtro aplicado, la tabla que queda debería ser corta. Tres lecturas posibles.

Está vacía o solo contiene nodos internos del motor. No hay fuga en ese ciclo. Es un resultado válido y valioso: descarta una hipótesis.

Contiene un número redondo de instancias de una clase tuya. El caso ideal. El número dividido entre las vueltas del primer bloque da cuántas se filtran por ciclo. Selecciona una y lee su cadena de retención.

Contiene muchos objetos genéricos: cadenas, arrays, objetos sin nombre. El caso incómodo. Significa que lo que se acumula no tiene identidad propia. Dos salidas: ordenar por tamaño retenido para encontrar el dominador del grupo, que sí suele tener nombre; o volver atrás y marcar los objetos como se vio en el nivel anterior antes de repetir la sesión.

Una comprobación adicional que conviene hacer siempre: mira también el saldo neto por constructor en la comparación entre la instantánea 2 y la 3. Si el ciclo fuga de forma constante, ese saldo será aproximadamente igual al del bloque anterior, y esa consistencia es la confirmación definitiva. Una fuga real es reproducible; el ruido, no.

La tercera instantánea no añade información: añade una hipótesis nula, y eso es lo que la convierte en un experimento

Merece la pena entender por qué esta técnica funciona tan bien, porque el principio que hay debajo se aplica a muchos otros diagnósticos y casi nadie lo enuncia. Con dos instantáneas, la pregunta que uno se hace es “¿qué ha aparecido?”, y esa pregunta no tiene una respuesta esperada: en una aplicación viva aparecen miles de objetos legítimamente y no hay ningún criterio objetivo para separar los normales de los anómalos. El analista tiene que juzgar cada grupo con su conocimiento del sistema, y ese juicio es lento, subjetivo y se equivoca. Lo que hace la tercera instantánea es construir una situación en la que la respuesta esperada es conocida y es cero. Al preguntar qué sobrevive de un bloque de ciclos después de otro bloque idéntico, se establece una hipótesis nula muy fuerte: si el sistema es correcto, ese conjunto está vacío. Y a partir de ahí no hace falta juzgar nada: cualquier elemento que aparezca es, por construcción, una desviación de lo esperado. Se ha pasado de un problema de interpretación a un problema de detección, y los problemas de detección son los que las herramientas resuelven bien. Ese mismo salto es el que convierte cualquier medición en un experimento y merece generalizarse, porque es aplicable a todo lo demás de esta guía: una medición sin un valor esperado es una observación; una medición con un valor esperado es una prueba. Un perfil de rendimiento aislado es una observación y por eso cuesta interpretarlo; el mismo perfil comparado con el de la versión anterior es una prueba. El número de peticiones de una página es una observación; el número de peticiones comparado con el de la misma página con la caché en frío es una prueba. La disciplina que hay que adquirir, y que separa a quien depura rápido, consiste en preguntarse antes de medir qué resultado debería salir si todo estuviera bien. Si no puedes responder eso, la medición que estás a punto de hacer no te va a decir nada, por precisa que sea, y es mejor invertir cinco minutos en diseñar una comparación que dos horas en interpretar un número solo.