wandres.dev
INSTRUMENTS · medir de verdad

Allocations y Leaks: confirmar una fuga de verdad

Crecimiento, abandono y fuga son tres diagnósticos distintos que casi todo el mundo confunde. Las generaciones de Allocations, lo que Leaks puede y no puede ver, y el memory graph debugger como tribunal donde una sospecha se convierte en prueba con un ciclo de retención visible.

⏱ 21 min

Decir que una app consume mucha memoria no es un diagnóstico, es una queja. Detrás de esa frase se esconden al menos tres enfermedades con causas y tratamientos incompatibles: memoria que crece porque hay más trabajo en vuelo del previsto, memoria que se abandona porque alguien la retiene sin necesitarla, y memoria que se fuga porque ningún camino de propiedad puede alcanzarla ya para liberarla. La tercera es la más famosa y la menos frecuente; las dos primeras son las que en la práctica matan procesos con un jetsam. Distinguirlas requiere dos instrumentos y una herramienta del depurador, cada uno respondiendo una pregunta que los otros no pueden.

🎯 Al terminar esta lección sabrás
  • Separar crecimiento, abandono y fuga como tres diagnósticos con evidencia distinta.
  • Usar generaciones de Allocations para aislar la memoria que sobrevive a un ciclo cerrado.
  • Explicar el algoritmo de Leaks y enumerar los casos que por construcción no detecta.
  • Confirmar un ciclo de retención en el memory graph debugger y romperlo en el código.

Tres enfermedades, tres pruebas

Antes de abrir nada conviene fijar el vocabulario, porque cada término implica un experimento diferente.

Hay crecimiento cuando la memoria sube mientras la app hace más trabajo y baja al terminarlo. No es un fallo: es el coste de una caché, de un búfer de descarga o de una lista que ha cargado más páginas. Se trata dimensionando, no persiguiendo objetos.

Hay abandono cuando la memoria sube y no vuelve a bajar tras cerrar el ciclo que la provocó, pero los objetos siguen siendo alcanzables desde una raíz viva. Un caché sin política de expulsión, un array de observadores al que nadie se da de baja, un singleton que acumula historial. Nada está roto desde el punto de vista de ARC, y sin embargo el proceso muere.

Hay fuga cuando el bloque ya no es alcanzable desde ninguna raíz y aun así su contador de referencias es mayor que cero. En un lenguaje con recuento automático esto solo ocurre por un ciclo: dos o más objetos que se sostienen mutuamente en una isla desconectada del grafo.

ℹ️
Qué mide cada número

Allocations cuenta el heap que pediste explícitamente. El footprint que usa el sistema para decidir a quién matar incluye además texturas, búferes de IOSurface, páginas sucias del binario y mucho más. Una app puede tener un heap plano y aun así morir por presión de memoria: cuando los números discrepan, el árbitro es la pestaña de memoria del navegador de depuración, no el instrumento.

flowchart TD
A[La memoria sube] --> B{Baja al cerrar el ciclo}
B -- Si --> C[Crecimiento normal: dimensionar caches y buferes]
B -- No --> D{El objeto es alcanzable desde una raiz}
D -- Si --> E[Abandono: alguien retiene de mas]
D -- No --> F[Fuga: hay un ciclo de retencion]
E --> G[Se caza con generaciones de Allocations]
F --> H[Se confirma en el memory graph debugger]

Generaciones: el experimento del ciclo cerrado

La técnica decisiva de Allocations no es mirar la gráfica, es marcar generaciones. Una generación es una marca en el tiempo; el instrumento muestra, para cada marca, qué objetos se reservaron después de ella y siguen vivos en el instante actual. El protocolo es siempre el mismo y es un experimento controlado, no una exploración.

Se lleva la app a un estado estable, se espera a que se asienten las cachés perezosas y se marca la primera generación. Se ejecuta entonces un ciclo completo y reversible —abrir una pantalla y volver, hacer una búsqueda y limpiarla— y al regresar exactamente al mismo estado se marca la segunda. Se repite el ciclo tres o cuatro veces más, marcando cada vez.

La lectura es tajante: un ciclo perfecto deja generaciones de crecimiento casi nulo. Si cada generación aporta doscientos kilobytes que nunca se liberan y la cifra es estable entre repeticiones, tienes un abandono cuantificado y con un desglose de tipos y pilas de asignación que apunta directamente al culpable. La cifra estable importa tanto como la cifra alta: un crecimiento que se repite idéntico en cada vuelta es una prueba, uno errático es todavía una sospecha.

// Abandono clasico: la vista se cierra pero la suscripcion sobrevive
final class Modelo {
    private var tareas: [Task<Void, Never>] = []

    func observar(_ fuente: Fuente) {
        // Nadie cancela nunca: cada visita a la pantalla añade una tarea viva
        tareas.append(Task { for await v in fuente.eventos { procesar(v) } })
    }

    deinit { tareas.forEach { $0.cancel() } }   // solo se ejecuta si el modelo muere
}

Leaks y el tribunal del grafo

Leaks hace un trabajo muy concreto: recorre periódicamente las raíces conocidas del proceso —pilas, registros, segmentos de datos globales— y marca todo lo alcanzable; los bloques del heap que quedan sin marcar y siguen reservados se declaran fugas. Es un análisis de alcanzabilidad, y por eso su alcance tiene límites que conviene saber de memoria.

No detecta el abandono, porque un objeto retenido por un caché vivo es perfectamente alcanzable. Puede dar falsos positivos con estructuras que guardan punteros codificados o desplazados, invisibles para el barrido. Y puede dar falsos negativos si el ciclo se rompe justo antes de la comprobación. Su valor real no es la lista que produce, sino que esa lista viene acompañada de la pila de asignación del objeto: sabes exactamente qué línea creó el bloque que ahora nadie puede liberar.

La confirmación se hace en otro sitio. El memory graph debugger de Xcode congela el proceso y dibuja el grafo de objetos completo, con las aristas etiquetadas por el nombre de la propiedad que sostiene cada referencia. Ahí un ciclo deja de ser una hipótesis y pasa a ser una figura cerrada que puedes señalar con el dedo, y el inspector te dice si cada arista es fuerte, débil o sin dueño.

🧭

Filtra por tus tipos

El grafo completo es inabarcable. Escribir el prefijo del módulo en el buscador y ordenar por número de instancias encuentra en segundos el controlador del que existen siete copias cuando debería haber una.

🔗

La arista tiene nombre

Cada flecha del grafo indica la propiedad concreta que retiene. Esa etiqueta es la que convierte el diagnóstico en un cambio de una sola palabra en el código.

🧪

Activa Malloc Stack

Sin el registro de pilas de asignación activado en el esquema, el grafo te dice qué fugó pero no quién lo creó. Es la diferencia entre un dato y una corrección.

El patrón que aparece una y otra vez es la clausura que captura fuertemente a quien la posee, y su corrección es igual de repetitiva.

final class Descargador {
    var alTerminar: ((Data) -> Void)?
}

final class Pantalla {
    let descargador = Descargador()

    func preparar() {
        // Ciclo: Pantalla retiene a Descargador y la clausura retiene a Pantalla
        descargador.alTerminar = { datos in self.mostrar(datos) }
    }

    func prepararBien() {
        descargador.alTerminar = { [weak self] datos in self?.mostrar(datos) }
    }

    func mostrar(_ datos: Data) {}
}

Cerrar el caso y evitar la recaída

Encontrar el ciclo es la mitad del trabajo. La otra mitad es elegir la corrección adecuada, y hay tres familias con consecuencias distintas.

La primera es debilitar la arista de retorno. Cuando un hijo necesita hablar con su padre, la referencia hacia arriba se marca weak y el problema desaparece. Es la solución correcta cuando la relación es realmente jerárquica y el hijo puede sobrevivir sin el padre.

La segunda es invertir la dirección de la dependencia. En lugar de que el hijo guarde al padre, el padre pasa una clausura o un objeto de salida. Suele producir un diseño mejor, porque obliga a nombrar exactamente qué necesita el hijo del padre en vez de darle acceso a todo.

La tercera es acortar la vida deliberadamente. Un caché con NSCache en lugar de un diccionario, una política de expulsión por número de elementos o por coste, o una limpieza atada a la notificación de memoria baja. Esta es la única que ataca el abandono, que como ya se dijo es la causa más frecuente de muerte por presión.

final class CacheDeMiniaturas {
    private let cache = NSCache<NSString, UIImage>()

    init() {
        cache.countLimit = 200
        cache.totalCostLimit = 32 * 1024 * 1024
    }

    func imagen(para clave: String) -> UIImage? {
        cache.object(forKey: clave as NSString)
    }
}

Para que el arreglo no se deshaga en el siguiente refactor conviene dejar una prueba. Un test que instancie el objeto, lo use, lo suelte y compruebe que una referencia débil ha pasado a valer nada es barato de escribir y cierra el caso para siempre.

@Test func elModeloNoSobreviveASuVista() {
    weak var debil: Modelo?
    do {
        let modelo = Modelo()
        debil = modelo
        modelo.observar(FuenteFalsa())
    }
    #expect(debil == nil)
}
📝
El orden correcto de la investigación

Primero generaciones para cuantificar, después Leaks para clasificar, y solo entonces el grafo para señalar la arista. Empezar por el grafo funciona con apps pequeñas y se vuelve inmanejable en cuanto hay miles de instancias vivas.

Alcanzabilidad, propiedad y por qué el recuento de referencias no es recolección de basura

Conviene entender por qué un lenguaje con memoria automática sigue teniendo fugas, porque la respuesta no es un defecto de implementación sino una elección de diseño con consecuencias que se notan cada día. Un recolector trazador define vivo como alcanzable desde las raíces, y por tanto un ciclo aislado es basura por definición y se recoge sin que nadie haga nada; el precio es que hay que parar el mundo o coordinarse con él, mantener metadatos de objetos y aceptar pausas no deterministas justo cuando el usuario está arrastrando el dedo por la pantalla. El recuento de referencias define vivo como contador mayor que cero, una propiedad estrictamente local que se decide sin mirar el grafo entero: eso da destrucción inmediata, determinista y sin pausas, permite ejecutar deinit en un momento predecible y hace posible que un fichero se cierre o un bloqueo se libere exactamente cuando la última referencia desaparece. La contrapartida es matemáticamente inevitable: contador mayor que cero e inalcanzable son condiciones independientes, y su intersección es precisamente el ciclo. Por eso Swift traslada al programador una decisión que un trazador se guardaba para sí, y la traslada mediante el vocabulario de strong, weak y unowned, que no son anotaciones de rendimiento sino declaraciones de propiedad. Elegir weak es afirmar que este objeto puede sobrevivirme; elegir unowned es afirmar que jamás lo hará, y firmar con un fallo en tiempo de ejecución si te equivocas. Leído así, un ciclo de retención deja de ser un accidente y pasa a ser lo que realmente es: dos partes del programa que se han declarado dueñas la una de la otra, es decir, un modelo de dominio en el que nadie ha decidido quién manda. La corrección técnica —añadir un [weak self]— es entonces la última línea de una respuesta cuya primera línea es de diseño, y por eso las apps que apenas fugan no son las que revisan más grafos, sino aquellas en las que la dirección de la propiedad se dibujó antes de escribir la primera clase.

⚔️ De la sospecha a la prueba
  1. Marca cinco generaciones en Allocations repitiendo el mismo ciclo de navegación y anota el crecimiento por generación.
  2. Comprueba si ese crecimiento es estable o errático, y justifica cuál de los dos casos permite concluir algo.
  3. Provoca un ciclo de retención deliberado con una clausura y verifica que Leaks lo detecta y que deinit no se ejecuta.
  4. Abre el memory graph debugger, localiza la figura cerrada y nombra la propiedad exacta de cada arista.
  5. Construye un abandono que Leaks no detecte —un caché sin expulsión— y explica por qué el barrido de alcanzabilidad lo ignora.