wandres.dev
MEMORIA Y ARC · ciclos y captura

Diagnosticar fugas: grafo de memoria, Instruments y pruebas automáticas

Una fuga no se encuentra mirando el código sino demostrando que un objeto sigue vivo cuando ya no debería, y para eso existen tres instrumentos con alcances distintos que conviene no confundir. Esta lección explica cómo leer el grafo de memoria del depurador para localizar quién retiene a quién, por qué el instrumento de fugas es ciego a la memoria abandonada y hace falta el análisis por generaciones, y cómo convertir cada diagnóstico en una prueba automática que impida que la fuga vuelva.

⏱ 19 min

Perseguir una fuga leyendo el código es un método pésimo: los ciclos reales rara vez tienen dos nodos, atraviesan capas escritas por gente distinta y a menudo se cierran con una arista que pertenece a un framework. El método que funciona invierte el orden. Primero se produce una evidencia —un objeto que debería estar muerto y no lo está—, después se pregunta al grafo quién lo sostiene, y solo entonces se abre el fichero culpable. Y como el diagnóstico cuesta caro, el trabajo no termina al arreglarlo: termina cuando existe una prueba que vuelve a fallar si alguien reintroduce la retención. Esa prueba, además, es sorprendentemente corta.

🎯 Al terminar esta lección sabrás
  • Capturar y leer un grafo de memoria hasta identificar la arista concreta que cierra el ciclo.
  • Distinguir una fuga de una acumulación abandonada y elegir el instrumento adecuado para cada una.
  • Aplicar el análisis por generaciones para detectar crecimiento que ningún detector de fugas señala.
  • Escribir pruebas que fallen cuando un objeto sobrevive a su ámbito y anclarlas al punto de construcción.

El grafo de memoria del depurador

El grafo de memoria de Xcode congela el montículo y lo muestra como lo que es: un grafo dirigido de objetos vivos. Se captura con el botón correspondiente durante una pausa, y para que sirva de algo hay que activar antes el registro de pilas de asignación en las opciones de diagnóstico del esquema; sin él verás qué está vivo pero no de dónde salió. El panel lateral permite filtrar por tipo, y el central muestra, para el objeto seleccionado, las aristas entrantes con el nombre de la propiedad que las produce. Ese detalle es todo el valor de la herramienta: no te dice que hay una fuga, te dice quién retiene a quién y por qué campo.

La técnica que convierte esa foto en un diagnóstico es la repetición. Lleva la aplicación a un estado estable, entra en la pantalla sospechosa, sal, y repite el ciclo tres o cuatro veces antes de capturar. Si al filtrar por el tipo de esa pantalla aparecen cuatro instancias en lugar de una, el número mismo es la prueba y coincide con las veces que entraste. Xcode marca además con un distintivo los bloques que ya reconoce como inalcanzables, pero no te fíes solo de eso: los ciclos que involucran una raíz viva no llevan distintivo y siguen siendo un error.

# Diagnostico desde la terminal, sin Xcode delante
export MallocStackLogging=1        # antes de lanzar el proceso
leaks 4711                         # bloques inalcanzables del proceso 4711
heap 4711 --sortBySize             # censo de instancias por clase
malloc_history 4711 0x600001a2c000 # pila de asignacion de un bloque concreto

Ese censo por clase es especialmente útil cuando la sospecha es difusa: ejecuta el mismo flujo diez veces y compara dos censos. Los tipos cuya cuenta crece linealmente con las repeticiones son los candidatos, y no hace falta ninguna intuición previa sobre dónde está el ciclo.

Fuga no es lo mismo que abandono

El instrumento de fugas hace algo muy concreto y muy limitado: cada cierto tiempo recorre el montículo desde las raíces y señala los bloques que ya no son alcanzables desde ninguna de ellas. Detecta perfectamente los ciclos aislados y los punteros perdidos, y es estructuralmente incapaz de detectar el otro gran patrón de consumo, la memoria abandonada: objetos que siguen siendo alcanzables desde una raíz viva y que nadie va a volver a usar jamás.

🧠

Fuga · inalcanzable y viva

Una isla de objetos que se sostienen entre sí sin ninguna arista entrante. El instrumento de fugas la ve. Ejemplo típico: un modelo de vista y su coordinador que se retienen mutuamente.

📈

Abandono · alcanzable e inútil

Un caché sin política de expulsión, un arreglo de registros que solo crece, un diccionario de observadores del que nadie se da de baja. Ningún detector de fugas lo señalará nunca, y sin embargo es lo que agota la memoria de la aplicación.

Para el segundo caso el instrumento correcto es el de asignaciones con análisis por generaciones. El procedimiento tiene la forma de un experimento controlado: llevas la aplicación a un estado de reposo, marcas una generación, ejecutas la operación completa —entrar y salir, abrir y cerrar, cargar y descartar—, vuelves al mismo estado de reposo y marcas otra generación. Lo que aparezca como persistente en esa generación es memoria que la operación creó y no devolvió. Repítelo tres veces: si el crecimiento persistente es constante en cada vuelta, tienes un problema real; si tiende a cero, era memoria que el sistema cachea con intención.

flowchart TB
s[Sospecha de consumo creciente] --> r[Repetir el flujo varias veces desde un estado estable]
r --> g[Capturar el grafo de memoria]
g --> n[Contar instancias del tipo sospechoso]
n --> m[Hay tantas instancias como repeticiones]
m --> a[Leer las aristas entrantes y nombrar el campo culpable]
a --> f[Degradar esa arista a debil o no propietaria]
n --> u[Hay una sola instancia pero la memoria sube]
u --> gen[Analisis por generaciones en el instrumento de asignaciones]
gen --> ab[Memoria abandonada: revisar caches y colecciones sin limite]
f --> t[Escribir la prueba que fija el resultado]
ab --> t

El test que falla cuando algo no muere

Toda la técnica anterior es cara y manual, y por eso su resultado debe consolidarse en una prueba. El patrón se apoya en una sola idea: si mantienes una referencia débil a un objeto y su ámbito de construcción ya terminó, esa referencia tiene que valer nulo. Si no lo vale, alguien lo retiene.

final class PresentadorTests: XCTestCase {
    func test_elPresentadorNoQuedaRetenido() {
        weak var referencia: Presentador?

        autoreleasepool {
            let sut = Presentador(servicio: ServicioFalso())
            referencia = sut
            sut.cargar()                 // ejercita el camino sospechoso
        }

        XCTAssertNil(referencia, "El presentador siguio vivo tras su ambito")
    }
}

El charco explícito no es decorativo: sin él, cualquier objeto autoliberado por la interoperabilidad con Objective-C podría seguir vivo en el momento de la comprobación y producir un fallo que no corresponde a ninguna fuga. Ejercitar el camino sospechoso dentro del ámbito tampoco es opcional, porque muchos ciclos solo se cierran cuando el objeto se suscribe, registra un observador o guarda un bloque de finalización.

Escribir eso a mano en cada prueba es repetitivo, así que conviene generalizarlo en una utilidad que se enganche al desmontaje de la prueba y que además señale el punto exacto de construcción cuando falle.

extension XCTestCase {
    func rastrearFugas(_ instancia: AnyObject,
                       fichero: StaticString = #filePath,
                       linea: UInt = #line) {
        addTeardownBlock { [weak instancia] in
            XCTAssertNil(instancia,
                         "Instancia no liberada. Posible ciclo de retencion.",
                         file: fichero, line: linea)
        }
    }
}

// En cada fabrica de sujetos bajo prueba
func hacerSut(fichero: StaticString = #filePath, linea: UInt = #line) -> Presentador {
    let servicio = ServicioFalso()
    let sut = Presentador(servicio: servicio)
    rastrearFugas(servicio, fichero: fichero, linea: linea)
    rastrearFugas(sut, fichero: fichero, linea: linea)
    return sut
}

Los parámetros con valores por defecto de fichero y línea hacen que el fallo aparezca marcado en la línea de la prueba que construyó el objeto, y no dentro de la utilidad: sin eso, veinte pruebas fallidas señalan todas al mismo sitio inútil. Con el marco de pruebas moderno la idea es idéntica y el gesto más directo, comprobando la referencia débil con #expect después de que el ámbito de construcción haya terminado.

@Test func elPresentadorNoQuedaRetenido() async {
    weak var referencia: Presentador?

    do {
        let sut = Presentador(servicio: ServicioFalso())
        referencia = sut
        await sut.cargar()
    }

    #expect(referencia == nil)
}

La virtud de este patrón no es que encuentre fugas nuevas —para eso están el grafo y el análisis por generaciones— sino que fija las ya encontradas. Una fuga corregida sin prueba vuelve, porque la arista que la producía suele ser justo la que alguien añade al implementar la siguiente funcionalidad: una referencia de vuelta para “poder avisar”, un caché “temporal”, un observador registrado sin baja. Con la comprobación en el desmontaje, ese cambio se pone rojo en el momento de escribirlo y no seis meses después en un informe de memoria.

ℹ️
Cuándo la prueba falla y no hay fuga

Tres situaciones producen fallos legítimos que no son ciclos, y conviene reconocerlas antes de acusar al código. Una tarea todavía en ejecución sostiene lo que capturó: espera a que termine o cancélala en el desmontaje. Un registro en una tabla global —un centro de notificaciones, un contenedor de dependencias, un caché estático— retiene desde una raíz que sobrevive a la prueba. Y un objeto de la interfaz puede seguir en una jerarquía de vistas que la prueba nunca desmontó. En los tres casos el diagnóstico correcto no es relajar la comprobación sino arreglar la limpieza.

Una fuga no se encuentra: se demuestra

El error metodológico que más tiempo consume en este terreno es tratar la memoria como algo que se inspecciona en lugar de algo sobre lo que se razona. Mirar un gráfico que sube no es un diagnóstico, porque casi todo sube: los cachés suben con intención, los charcos de autoliberación suben y bajan a destiempo, el asignador conserva páginas que no devuelve al sistema aunque tú ya no las uses, y una jerarquía de interfaz que se está animando sostiene legítimamente objetos que parecen difuntos. La única forma de convertir una sospecha en conocimiento es construir una afirmación falsable y ponerla a prueba: este objeto, después de este flujo, debe tener conteo cero. El grafo de memoria sirve porque contesta exactamente esa pregunta y nombra la arista que la contradice; el análisis por generaciones sirve porque compara dos estados que deberían ser idénticos y mide la diferencia; la prueba con referencia débil sirve porque codifica la afirmación en el repositorio para siempre. Nótese que las tres son la misma operación epistemológica ejecutada con distinto instrumento y a distinto coste, y que la última es la única que no se degrada con el tiempo, porque no depende de que alguien se acuerde de mirar. De ahí se sigue el orden de trabajo correcto y también su final: no has arreglado una fuga cuando el objeto desaparece del grafo, sino cuando existe una prueba que volvería a ponerse roja si alguien reintroduce la arista. Lo primero es un alivio; lo segundo es ingeniería.

⚔️ Convertir una sospecha en una prueba
  1. Introduce a propósito un ciclo entre dos clases de un proyecto tuyo, recorre el flujo cuatro veces y comprueba en el grafo que hay cuatro instancias donde debería haber una.
  2. Localiza en ese grafo la arista culpable por el nombre de la propiedad, corrígela y repite la captura para verificar el resultado.
  3. Ejecuta el mismo flujo con el análisis por generaciones y distingue el crecimiento persistente del que se estabiliza tras la tercera vuelta.
  4. Añade la utilidad de rastreo al desmontaje de tus pruebas y aplícala a todas las fábricas de sujetos, comprobando que el fallo señala la línea correcta.
  5. Escribe una prueba que falle por una tarea sin cancelar y otra que falle por un ciclo real, y explica cómo distinguir ambos fallos solo por su mensaje.