wandres.dev
EL ÁRBOL DE DUEÑOS · Ownership y disposal automático

El problema que resuelve: fugas sin llevar la cuenta

Por qué la contabilidad manual de suscripciones falla siempre a escala, qué retiene exactamente una arista huérfana, y cómo diagnosticar una fuga de suscripciones en un motor reactivo.

⏱ 17 min

El árbol de dueños existe por una razón concreta y demostrable: la contabilidad manual de suscripciones no escala. No porque los programadores sean descuidados, sino por una propiedad estructural del problema que hace que el número de sitios donde hay que acordarse crezca más rápido que el número de suscripciones. Esta lección demuestra por qué, muestra qué retiene exactamente una arista huérfana, y da el procedimiento de diagnóstico.

🎯 Al terminar esta lección sabrás
  • Demostrar por qué la contabilidad manual de suscripciones no escala.
  • Trazar la cadena de referencias que retiene una arista huérfana.
  • Diagnosticar una fuga de suscripciones con herramientas reales.
  • Evaluar si un motor sin árbol de dueños es aceptable en tu contexto.

Por qué la contabilidad manual falla

El argumento no es que la gente se olvide. Es que el número de caminos por los que un ámbito puede morir crece con el tamaño del código, y cada camino necesita su baja.

function componente() {
  const s1 = fuente.subscribe(actualizarA);
  const s2 = otra.subscribe(actualizarB);
  const t = setInterval(refrescar, 1000);
  const escuchador = () => ajustar();
  window.addEventListener('resize', escuchador);

  return () => {
    s1.unsubscribe();
    s2.unsubscribe();
    clearInterval(t);
    window.removeEventListener('resize', escuchador);
  };
}

Cuatro recursos, cuatro bajas, y la función de baja tiene que estar sincronizada a mano con el cuerpo. Ahora considera lo que ocurre al evolucionar el código.

Alguien añade una quinta suscripción y olvida la línea correspondiente en la baja. No hay error, no hay aviso, el código funciona perfectamente y la fuga es real.

Alguien añade un return temprano en el cuerpo, y ahora dos de las cuatro suscripciones no llegan a crearse pero la función de baja intenta darlas de baja igualmente.

Alguien mete una suscripción dentro de una condicional. Ahora la baja tiene que ser condicional también, con la misma condición, evaluada en otro momento.

Alguien extrae parte del cuerpo a una función auxiliar que también se suscribe. Ahora la baja de esa suscripción tiene que atravesar la frontera de la función: o la auxiliar devuelve su baja y el llamador la propaga, o hay una fuga.

Ese último caso es el decisivo: la responsabilidad de dar de baja no compone. Igual que las dependencias declaradas del nivel 2, atraviesa fronteras de función y obliga a que cada función devuelva su baja y cada llamador la encadene. En una jerarquía de cinco niveles, eso son cinco puntos donde olvidarse.

📝
Es el mismo argumento que contra la gestion manual de memoria

Que el programador competente pueda hacerlo bien no es el punto: el punto es que el coste de hacerlo bien crece con el tamaño del programa y no se puede verificar localmente. Para saber si hay una fuga hay que mirar todos los caminos de salida de todas las funciones implicadas. Es exactamente el argumento que llevó de malloc y free a la propiedad y a los ámbitos, y la solución que se adoptó aquí es la misma.

Qué retiene una arista huérfana

Es útil ver la cadena completa, porque explica por qué una sola suscripción olvidada puede retener megabytes.

// La fuente vive en un modulo, para siempre
const usuarioGlobal = senal(null);

function fila(datos) {
  const li = document.createElement('li');
  const grande = new Array(10000).fill(datos);      // capturado por el cierre
  efecto(() => { li.textContent = `${usuarioGlobal()?.nombre}: ${grande.length}`; });
  return li;
}

Si ese efecto no se da de baja, la cadena de retención es esta.

usuarioGlobal.observadores contiene el nodo del efecto. El nodo del efecto contiene fn. fn es un cierre que captura li y grande. li es un nodo del DOM que, aunque esté desconectado del documento, no se puede recolectar porque hay una referencia viva. grande es un array de diez mil elementos.

Total retenido por una arista: el nodo reactivo, el cierre, el elemento del DOM y el array. Multiplicado por mil filas de una lista que se recrea en cada navegación, y por veinte navegaciones en una sesión.

flowchart LR
S[senal global viva para siempre] --> O[lista de observadores]
O --> N[nodo del efecto]
N --> C[el cierre fn]
C --> D[nodo del DOM desconectado]
C --> A[array de diez mil elementos]
style S fill:#89b4fa,color:#11111b
style O fill:#f9e2af,color:#11111b
style N fill:#f38ba8,color:#11111b
style C fill:#f38ba8,color:#11111b
style D fill:#f38ba8,color:#11111b
style A fill:#f38ba8,color:#11111b

La raíz de la retención es siempre una fuente de vida larga: un módulo, un almacén global, un contexto de aplicación. Cuanto más global sea la fuente, más grave es olvidarse de la baja, porque la referencia nunca se va a soltar sola.

Diagnosticar una fuga de suscripciones

El procedimiento tiene tres pasos y funciona con las herramientas del navegador.

Paso uno: confirmar que el grafo crece. Instrumenta el motor con un contador global de nodos vivos y otro de aristas, y expónlos.

let nodosVivos = 0, aristasVivas = 0;
// incrementar en la creacion, decrementar en desechar y desatar
globalThis.__grafo = () => ({ nodosVivos, aristasVivas });

Haz la operación sospechosa —navegar de ida y vuelta, abrir y cerrar un panel— diez veces y mira los contadores. Si suben de forma monótona y no vuelven, hay una fuga y ya sabes con qué operación reproducirla.

Paso dos: localizar la fuente que retiene. Recorre las listas de observadores de tus fuentes globales y busca las que tengan un número desproporcionado.

function observadoresPorFuente(fuentes) {
  return fuentes
    .map(f => ({ nombre: f.nombre, n: f.observadores.size }))
    .sort((a, b) => b.n - a.n);
}

Una fuente con cinco mil observadores en una pantalla con cincuenta componentes es la culpable.

Paso tres: confirmar con un heap snapshot. En el panel de memoria del navegador, toma un snapshot, haz la operación, toma otro, y compara. Busca nodos del DOM desconectados retenidos y sigue su camino de retención hasta la fuente. Verás la cadena completa que dibujamos arriba.

🛑
Los nodos del DOM desconectados son la senal mas fiable

En el panel de memoria del navegador, filtrar por Detached muestra los elementos del DOM que ya no están en el documento pero siguen retenidos. En una aplicación sana ese número vuelve a cero tras una recolección. Si crece con cada navegación, casi siempre es una suscripción no dada de baja, y el camino de retención te lleva directamente al nodo reactivo culpable.

¿Se puede vivir sin árbol de dueños?

Sí, y hay motores que lo hacen. Preact Signals no tiene árbol: effect devuelve una función de baja y punto. Es una decisión razonable en su contexto, porque está pensado para usarse dentro de un framework que ya tiene ciclos de vida y que se encarga de llamar a esa función en el momento correcto.

La pregunta que hay que hacerse es: ¿existe ya en tu sistema una estructura que sepa cuándo muere cada cosa? Si la respuesta es sí, el árbol de dueños es redundante y basta con delegar. Si la respuesta es no —si estás construyendo el sistema, o si creas efectos fuera del ciclo de vida de un componente—, lo necesitas.

Angular ilustra bien la primera opción: no tiene un árbol de dueños propio porque reutiliza el árbol de inyectores, que ya existía y que ya sabe cuándo se destruye cada cosa. Es la misma idea implementada sobre una estructura que ya estaba.

El arbol de duennos es la respuesta a una pregunta que el grafo no puede contestar

Aquí está la separación conceptual que ordena todo el nivel. El grafo de dependencias sabe qué depende de qué, y con eso responde perfectamente a la pregunta de qué recalcular. Lo que no puede responder de ninguna manera es cuándo algo deja de ser relevante, y no puede porque esa información sencillamente no está en el grafo: que nadie observe un nodo no significa que haya que destruirlo —puede ser un memo dormido que se leerá luego—, y que un nodo tenga observadores no significa que deba vivir —los observadores pueden ser todos parte del mismo subárbol que está muriendo—. La relevancia es una noción del programa, no del grafo: depende de qué componente sigue montado, qué ruta está activa, qué modal está abierto. Por eso hace falta una segunda estructura que capture esa información, y por eso esa segunda estructura es un árbol y no un grafo: refleja el anidamiento léxico del programa, que es donde vive el concepto de ámbito. Cuando entiendes que son dos preguntas distintas dejas de intentar deducir una de la otra, que es el error del que salen la mitad de los diseños fallidos de gestión de recursos reactivos: contar referencias en el grafo para decidir cuándo destruir. Eso nunca funciona bien, exactamente por la misma razón por la que el conteo de referencias falla con los ciclos: estás usando la estructura de dependencia para responder a una pregunta de propiedad.

⚔️ Provoca y diagnostica una fuga
  1. Instrumenta contadores de nodos y aristas vivos en tu motor.
  2. Crea una lista de mil filas, cada una con un efecto que lea una señal global, y destrúyela sin desechar los efectos.
  3. Repite diez veces y comprueba que los contadores crecen sin volver.
  4. Toma dos heap snapshots y sigue el camino de retención de un nodo del DOM desconectado hasta la señal global.