wandres.dev
MEMORIA · Fugas y presión

La técnica de los tres snapshots

Cómo aislar exactamente lo que un ciclo de uso deja atrás: tamaño propio frente a retenido, el camino de retenedores, y el procedimiento completo paso a paso.

⏱ 19 min

Un snapshot del heap de una aplicación real tiene cientos de miles de objetos y no dice absolutamente nada por sí solo. La técnica de los tres snapshots es el procedimiento que convierte ese montón en una lista corta y concreta: los objetos que tu ciclo de uso creó y que, dos ciclos después, siguen vivos. Sin ese filtro no hay caza de fugas posible; con él, la mayoría se resuelven en veinte minutos.

🎯 Al terminar esta lección sabrás
  • Interpretar tamaño propio, tamaño retenido y distancia en un snapshot.
  • Ejecutar el procedimiento de los tres snapshots y leer el conjunto filtrado.
  • Seguir un camino de retenedores hasta la raíz e identificar al culpable.
  • Reconocer las categorías internas que aparecen en el listado y qué significa cada una.

Las tres cifras que hay que saber leer

Tamaño propio es lo que ocupa el objeto en sí: sus campos, su cabecera. Una instancia con diez propiedades ocupa unos pocos cientos de bytes aunque una de esas propiedades apunte a un array de un millón de elementos.

Tamaño retenido es lo que se liberaría si ese objeto desapareciera. Es decir, lo que ocupa él más todo lo que solo él mantiene vivo. Es la cifra que importa para cazar fugas, y la diferencia con la anterior puede ser de seis órdenes de magnitud.

Distancia es la longitud del camino más corto desde una raíz hasta el objeto. Es útil como señal: los objetos que deberían haber muerto suelen tener distancias cortas y sospechosas, porque están colgando directamente de algo global.

El listado agrupa por constructor, y aparecen categorías internas que conviene reconocer porque son donde se esconde lo interesante:

Entrada Qué es
(closure) Funciones y su contexto capturado
(array) Arrays internos del motor, incluidos los de propiedades
(string) Cadenas
(compiled code) Código compilado, plantillas de funciones
system / Context El objeto de ámbito compartido por las funciones de un mismo alcance
Detached ... Nodos del DOM fuera del documento pero referenciados

system / Context es la que más gente ignora y la que más veces explica una fuga: es el objeto donde el motor guarda las variables que capturan los cierres de un mismo ámbito. Si aparece con tamaño retenido grande, tienes un cierre reteniendo más de lo que crees, y eso tiene su propia lección en listeners y cierres.

El procedimiento

El objetivo del procedimiento es separar tres poblaciones que en un snapshot suelto están mezcladas: lo que ya existía antes de empezar, lo que existe porque el ciclo está a medias, y lo que el ciclo dejó atrás. Solo la tercera es una fuga.

  1. Prepara el ciclo. Un ciclo es una secuencia de acciones que empieza y termina en el mismo estado visible: abrir un panel y cerrarlo, entrar en una sección y volver, aplicar un filtro y quitarlo. Tiene que ser repetible a mano en menos de diez segundos.

  2. Calienta. Ejecuta el ciclo dos o tres veces antes de medir nada. La primera ejecución carga módulos, compila código y llena cachés legítimas, y todo eso aparecería como crecimiento falso.

  3. Snapshot 1. El panel de memoria del navegador fuerza una recolección antes de capturar, así que no hace falta que provoques nada. Este es tu punto de referencia.

  4. Ejecuta el ciclo N veces. Con N entre cinco y diez. Repetir es esencial: una fuga de un objeto por ciclo se pierde en el ruido, diez copias del mismo objeto en el listado son inconfundibles, y además el número te dice si el crecimiento es proporcional a los ciclos.

  5. Snapshot 2.

  6. Ejecuta el ciclo otras N veces.

  7. Snapshot 3.

  8. Filtra. Con el snapshot 3 seleccionado, cambia la vista a la comparación de objetos asignados entre el snapshot 1 y el snapshot 2. Lo que ves ahora es exactamente esto: objetos que nacieron durante la primera tanda de ciclos y que siguen vivos después de la segunda tanda. Si el ciclo fuera limpio, esa lista estaría prácticamente vacía.

El tercer snapshot es lo que hace que la técnica funcione, y es la parte que la gente se salta. Con solo dos snapshots ves lo que se creó y sigue vivo, pero no puedes distinguir un objeto filtrado de uno que simplemente todavía no se ha recolectado o que pertenece a una caché legítima que se estabilizará. El tercero, tomado después de otra tanda entera, elimina esa duda: lo que ha sobrevivido a diez ciclos más no se va a ir.

Ordena el resultado por tamaño retenido, no por número de instancias. Diez mil cadenas pequeñas suman menos que un array retenido, y arreglar el array te devuelve la memoria entera.

Seguir el camino de retenedores

Selecciona el objeto sospechoso y mira el panel inferior, el de retenedores. Muestra el camino desde el objeto hacia arriba, hasta una raíz. Se lee de abajo arriba: abajo está la raíz, arriba tu objeto.

Un camino típico de fuga real tiene esta forma:

Objeto sospechoso                          en Componente
  ↑ retenido por  .estado                  en Componente
  ↑ retenido por  context                  en system / Context
  ↑ retenido por  manejador                en (closure)
  ↑ retenido por  [n]                      en (array)
  ↑ retenido por  listeners                en EventListenerMap
  ↑ retenido por  window                   GC root

Y se traduce solo: hay un listener registrado en window cuya función captura un ámbito donde vive una instancia de tu componente, que a su vez tiene el estado. Nadie retiró el listener. La reparación es evidente en cuanto ves el camino, y encontrar el camino es todo el trabajo.

Dos consejos para leerlos rápido. Busca el primer nombre que reconozcas subiendo desde la raíz: ahí está tu código, y todo lo que hay por encima es consecuencia. Y desconfía de los caminos que pasan por window o por un módulo, que son las dos raíces que retienen para siempre; un camino que sale de un nodo del DOM conectado es normalmente legítimo, porque ese nodo desaparecerá cuando cambies de página.

Cuando hay varios caminos, el panel muestra uno y puedes desplegar el resto. Importa: si un objeto está retenido por dos caminos, arreglar uno no libera nada. Es la causa más frecuente de “he arreglado la fuga y sigue igual”.

Las otras dos herramientas del panel

El snapshot no es lo único, y las otras dos vistas responden preguntas distintas.

La instrumentación de asignaciones en la línea de tiempo graba durante un periodo y dibuja barras verticales por cada asignación, coloreando en gris las que ya se liberaron y en azul las que siguen vivas. Es la mejor herramienta para una pregunta concreta: ¿qué acción exacta es la que filtra? Grabas, haces cinco cosas distintas, y las barras azules te dicen cuál de las cinco dejó residuo. Después vuelves a la técnica de los tres snapshots para el detalle.

El muestreo de asignaciones no captura todo, sino que muestrea, y agrupa por pila de llamadas. Su ventaja es que apenas ralentiza la página, así que sirve para sesiones largas y para producción interna. Su desventaja es que da un perfil estadístico, no un inventario: te dice qué función asigna mucho, no qué objeto concreto está retenido.

La regla de uso: línea de tiempo para localizar la acción, snapshots para localizar el objeto, muestreo para localizar la función que asigna en exceso. Son tres preguntas distintas y usar la herramienta equivocada es la causa habitual de pasarse una tarde sin llegar a nada.

Un snapshot no miente nunca, pero responde a la pregunta que le hiciste, no a la que tenías

El error que más tiempo hace perder cazando fugas no es leer mal un snapshot: es tomarlo mal. Y hay tres formas de tomarlo mal que producen conclusiones convincentes y falsas. La primera es no calentar. Los primeros ciclos de cualquier aplicación moderna crean cosas que no se van a ir nunca y que no son fugas: módulos cargados perezosamente, código compilado, cachés de plantillas, conexiones, el árbol de componentes inicial. Si tu snapshot 1 se toma antes de eso, todo ese crecimiento legítimo aparece en tu lista de sospechosos, y te vas a pasar una hora persiguiendo el sistema de módulos de tu framework. La segunda es medir un ciclo que no cierra. “Entrar en la sección” no es un ciclo; “entrar en la sección y volver a donde estabas” sí. Si el ciclo no vuelve al estado inicial, lo que mides no es una fuga, es la diferencia legítima entre dos estados, y no hay nada que arreglar. Y la tercera, la más sutil, es confundir una caché con una fuga. Una caché de resultados que crece durante los diez ciclos y se estabilizaría en el ciclo cincuenta se ve exactamente igual que una fuga en tres snapshots: crece de forma proporcional a los ciclos y no se libera. La única forma de distinguirlas es preguntarle al código —¿esta estructura tiene política de expulsión?— y, si no la tiene, la respuesta correcta no es “no era una fuga”, es “todavía no, pero lo es”: una caché sin límite es una fuga con calendario. La lección general, que sirve para cualquier medición y no solo para memoria, es que las herramientas de diagnóstico no producen verdades sino respuestas condicionadas al procedimiento. Antes de creerte un número, reconstruye qué pregunta le hiciste exactamente al instrumento. En memoria esa pregunta es siempre la misma: “de lo que nació entre estos dos instantes, ¿qué sigue vivo en este tercero?”, y si tus tres instantes no están donde tienen que estar, la respuesta es correcta y sirve para nada.

⚔️ Caza una fuga real
  1. Define un ciclo cerrado de tu aplicación y ejecútalo con la técnica completa. Anota los tres objetos con mayor tamaño retenido.
  2. Sigue el camino de retenedores del primero hasta la raíz y escribe la traducción en una frase.
  3. Comprueba si ese objeto tiene más de un camino de retención. Si lo tiene, anótalos todos antes de arreglar nada.
  4. Repite el procedimiento saltándote el calentamiento y compara la lista de sospechosos. Cuenta cuántos falsos positivos aparecen.
  5. Usa la instrumentación de asignaciones en la línea de tiempo para determinar cuál de cinco acciones distintas es la que deja residuo.