wandres.dev
MEMORIA Y ARC · ciclos y captura

Automatic Reference Counting: el compilador que cuenta por ti

ARC no es un recolector de basura ni un servicio que vigile el montículo mientras tu programa corre: es una transformación que el compilador aplica a tu código insertando retenciones y liberaciones allí donde el modelo de propiedad las exige. Esta lección reconstruye ese modelo desde la cabecera del objeto hasta las convenciones de paso de parámetros, explica por qué la destrucción es determinista y qué se paga exactamente por ella, y sitúa el conteo de referencias frente al trazado de alcanzabilidad para mostrar que la diferencia entre ambos no es de eficiencia sino de teoría.

⏱ 18 min

El nombre engaña por partida doble. Automatic sugiere un servicio que corre mientras tu programa vive, inspeccionando el montículo; Counting sugiere una contabilidad que alguien lleva aparte, en tiempo de ejecución. Ninguna de las dos cosas es exacta. ARC es una transformación del compilador: al generar el código intermedio, Swift analiza el flujo de tus referencias y escribe por ti las llamadas a swift_retain y swift_release que en Objective-C tenías que escribir a mano. Lo automático es la escritura, no la vigilancia. Y de ese matiz —la decisión se toma en compilación, el efecto ocurre en ejecución— se deducen las tres propiedades que definen el modelo: su determinismo, su coste distribuido y su único agujero.

🎯 Al terminar esta lección sabrás
  • Describir dónde vive el conteo fuerte y qué significa exactamente el número que guarda.
  • Predecir en qué puntos inserta el compilador retain y release a partir de las convenciones de propiedad.
  • Distinguir el conteo de referencias del trazado de alcanzabilidad y enumerar las consecuencias de cada modelo.
  • Estimar el coste real de ARC y reconocer las optimizaciones que lo eliminan.

El contrato de propiedad

Toda instancia de clase vive en el montículo precedida por una cabecera de dos palabras: un puntero a los metadatos del tipo y un campo de estado que aloja los conteos. El conteo fuerte responde a una pregunta muy concreta y muy pequeña: cuántas referencias se han comprometido a mantener viva esta instancia. No cuántas la conocen, ni cuántas la usan: cuántas la sostienen. Cuando ese número llega a cero, la destrucción es inmediata y en el mismo hilo: se ejecuta deinit, se liberan las propiedades almacenadas y la memoria vuelve al asignador.

final class Nodo {
    let id: Int
    init(id: Int) { self.id = id }
}

func inspeccionar(_ n: Nodo) { _ = n.id }   // parametro prestado

func demo() {
    let a = Nodo(id: 1)   // el inicializador entrega la instancia con conteo 1
    let b = a             // retain: conteo 2
    inspeccionar(b)       // convencion prestada: no toca el conteo
}                         // dos release al cerrar el ambito: conteo 0

La clave está en la tercera línea, que es la que casi nadie predice bien. Pasar una referencia a una función no incrementa el conteo, porque los parámetros se pasan por defecto con la convención prestada: el llamador garantiza que la instancia seguirá viva durante toda la llamada, de modo que el llamado puede usarla sin pagar nada. Solo los valores poseídos —lo que devuelve un inicializador, lo que se almacena en una propiedad, lo que se guarda en una colección— generan tráfico de conteo. Desde Swift 5.9 puedes hacer explícita esa distinción con los modificadores borrowing y consuming, que dejan de ser un detalle interno del compilador para convertirse en parte de la firma.

func observar(_ n: borrowing Nodo) { }   // prestado: cero retain, cero release
func absorber(_ n: consuming Nodo) { }   // poseido: el llamado libera al terminar

La cabecera, además, no guarda un solo número. Junto al conteo fuerte conviven un conteo de referencias no propietarias y, cuando hace falta, un puntero a una tabla lateral donde se registran las débiles. Los tres miden cosas distintas y se agotan en momentos distintos: el fuerte decide cuándo se ejecuta deinit, el no propietario decide cuándo la memoria vuelve al asignador y el débil decide cuándo puede desaparecer la tabla auxiliar. Que sean tres relojes y no uno explica buena parte de los fenómenos que parecen contradictorios al depurar, y es el material de las dos lecciones siguientes.

Conviene fijar una consecuencia que se olvida a menudo: ARC no gobierna las estructuras ni los enumerados, porque esos no viven necesariamente en el montículo ni tienen identidad. Lo que sí ocurre es que una estructura cuyos campos son referencias arrastra el coste de todos ellos: copiarla implica un retain por cada campo de referencia que contenga.

Determinismo frente a trazado

Un recolector de basura clásico responde a la pregunta contraria. En lugar de contar quién sostiene qué, parte de un conjunto de raíces —pila, registros, variables globales— y recorre el grafo marcando lo alcanzable; lo que no se alcanza se recicla. La literatura de recolección demostró hace tiempo que ambas técnicas no son rivales sino duales: el trazado calcula la alcanzabilidad y el conteo calcula su complemento, y cualquier recolector real es una combinación de las dos. Elegir una u otra no es elegir una implementación más rápida, es elegir qué garantías da el lenguaje.

🧠

Conteo de referencias

Decisión local e incremental. Destrucción determinista en el punto exacto del último release. Sin pausas globales ni hilo recolector. Huella de memoria ajustada. Incapaz de ver un ciclo.

🌐

Trazado de alcanzabilidad

Decisión global y periódica. Destrucción diferida a un momento que el programa no controla. Pausas o trabajo concurrente. Necesita espacio libre de sobra para ser rápido. Los ciclos le salen gratis.

Hay una segunda diferencia menos comentada y igual de importante: bajo trazado, la memoria libre es un recurso que el recolector necesita para ir rápido, de modo que el rendimiento mejora cuando el proceso dispone de holgura. En un dispositivo con memoria limitada y muchos procesos compitiendo, esa holgura no existe, y ese fue uno de los argumentos técnicos que llevaron a la plataforma a quedarse con el conteo.

El determinismo no es un detalle estético: es lo que permite que deinit sirva para algo. Si un objeto cierra un descriptor de fichero, suelta un bloqueo o cancela una suscripción, necesitas saber cuándo ocurre eso, y bajo trazado la respuesta honesta es “en algún momento, quizá nunca antes de que el proceso termine”. ARC convierte la liberación de recursos en una propiedad del ámbito léxico, que es exactamente el patrón que en la tradición de C++ se llama adquisición es inicialización.

El precio de esa garantía es igual de concreto. Un conteo local no puede detectar que dos objetos se sostienen mutuamente, porque desde dentro del ciclo cada uno ve un conteo legítimamente mayor que cero. Esa isla es inalcanzable desde cualquier raíz y aun así permanece viva: es la fuga de memoria canónica de ARC, y es el tema de la lección siguiente.

flowchart TB
a[Inicializador entrega la instancia con conteo uno] --> b[Nueva referencia fuerte inserta retain]
b --> c[El conteo sube]
c --> d[Una referencia sale de ambito e inserta release]
d --> e[El conteo baja]
e --> f[Conteo cero: se ejecuta deinit]
f --> g[Se liberan las propiedades almacenadas]
g --> h[La memoria vuelve al asignador]
e --> c

Lo que cuesta contar

Un retain no es una suma: es una suma atómica. Como cualquier hilo puede tomar o soltar una referencia en cualquier momento, el conteo debe modificarse con operaciones de lectura y escritura entrelazadas, lo que implica barreras de memoria y, bajo contención real, invalidación de líneas de caché entre núcleos. Ese es el coste que aparece en los perfiles cuando el bucle caliente manipula objetos: no una fuga, sino tráfico de ARC.

Ese coste tiene una asimetría que conviene conocer: el caso sin contención es relativamente barato, del orden de unas pocas decenas de ciclos, mientras que el caso con varios núcleos tocando el mismo objeto se dispara, porque cada modificación obliga a los demás a recargar la línea de caché. De ahí una consecuencia práctica que sorprende a quien viene de otros lenguajes: compartir un objeto entre hilos no solo cuesta sincronización lógica, cuesta también tráfico de conteo.

Contra eso trabaja el optimizador. Su labor consiste en demostrar que ciertos pares retain y release son redundantes —porque otra referencia ya garantiza la vida del objeto en ese intervalo— y eliminarlos. Además, las conformidades no genéricas, las llamadas a métodos final y las funciones internas dentro de un módulo compilado como unidad permiten al compilador ver el flujo completo y borrar mucho más.

// Deja que ARC vea la unicidad: base del copy-on-write
struct Buffer {
    private var almacen: Almacen

    mutating func escribir(_ x: Int) {
        if !isKnownUniquelyReferenced(&almacen) {
            almacen = almacen.copia()   // solo si alguien mas lo sostiene
        }
        almacen.datos.append(x)
    }
}

isKnownUniquelyReferenced es el punto donde el conteo deja de ser un detalle invisible y pasa a ser una decisión de diseño: preguntar si eres el único dueño es lo que convierte una estructura con almacenamiento en el montículo en un tipo de valor de verdad. En el extremo opuesto están Unmanaged y las referencias sin gestionar, que apagan el conteo por completo a cambio de que tú respondas por la vida del objeto; herramientas legítimas para un puñado de casos medidos, y una forma rápida de escribir errores de memoria en todos los demás.

Dónde aparece ARC sin que lo veas

La cuenta no se limita a las clases que tú declaras. Un closure que escapa es también un objeto del montículo: contiene un puntero a código y una caja con lo capturado, tiene su propio conteo y retiene todo lo que capturó de forma fuerte. Guardar un closure en una propiedad es, a efectos de memoria, guardar un objeto que a su vez guarda otros; por eso los ciclos con closures son tan frecuentes y por eso el tamaño de lo capturado importa.

También aparece en los existenciales. Cuando escribes any Protocolo, el valor viaja en un contenedor de tamaño fijo; si el tipo concreto no cabe, se guarda en una caja del montículo con conteo propio, de modo que copiar el existencial deja de ser mover bytes y pasa a ser una retención. Lo mismo ocurre con los enumerados marcados indirect, con las cadenas y colecciones no pequeñas, y con cualquier tipo que internamente delegue su almacenamiento en una referencia compartida.

protocol Forma { func area() -> Double }
struct Poligono: Forma {                 // grande: no cabe en el contenedor
    var vertices: [CGPoint]
    func area() -> Double { 0 }
}

let sueltas: [any Forma] = [Poligono(vertices: [])]  // caja con conteo propio
let cerradas: [Poligono] = [Poligono(vertices: [])]  // sin existencial, sin caja

Queda un origen que se olvida siempre: la interoperabilidad con Objective-C. Los métodos importados que devuelven objetos siguen la convención de autoliberación, así que la instancia no se libera al terminar la llamada sino cuando se vacía el charco correspondiente, normalmente al final de la iteración del bucle de eventos. En un bucle cerrado que crea muchos objetos importados, eso produce un perfil de memoria que sube en diente de sierra y que se confunde con una fuga sin serlo.

ℹ️
Que la memoria suba no significa que haya una fuga

El asignador conserva páginas que ya no usas para no pedirlas otra vez al sistema, los charcos de autoliberación difieren la muerte de los objetos importados y muchos frameworks mantienen cachés con intención. Nada de eso es una fuga. La única definición operativa útil es la del grafo: hay fuga cuando existen objetos vivos que ninguna raíz alcanza, o cuando el número de instancias de un tipo crece proporcionalmente a las veces que repites un flujo.

💡
No midas ARC en compilación de depuración

El optimizador de ARC solo actúa con optimización activada. Un perfil tomado en configuración de depuración muestra un tráfico de retenciones que en producción no existe, y te llevará a “optimizar” código que ya era gratis. Mide siempre en modo de publicación, y si necesitas ver la verdad del compilador, emite el código intermedio con swiftc -emit-sil -O y cuenta las llamadas a strong_retain y strong_release.

ARC es una afirmación sobre la propiedad, no un detalle de implementación

Un lenguaje con recolección por trazado te dice, en el fondo, que la memoria no es asunto tuyo: escribe el grafo que necesites, ya vendrá alguien a barrer. Es una promesa generosa y tiene un coste oculto que se paga en latencia impredecible y en la imposibilidad de atar recursos escasos al ámbito. Swift eligió lo contrario, y la elección es de naturaleza semántica antes que técnica: al adoptar ARC el lenguaje afirma que la propiedad es una relación que el programa puede y debe declarar, no una propiedad emergente del grafo que un servicio externo deduzca por su cuenta. De ahí se sigue todo lo demás como un teorema. Si la propiedad se declara, la destrucción tiene un lugar exacto en el código y deinit puede cerrar ficheros y soltar bloqueos con la misma confianza con la que un defer cierra un ámbito. Si la propiedad se declara, el coste es visible y local, atribuible a una línea concreta y por tanto optimizable. Y si la propiedad se declara, entonces declararla mal es un error tuyo y no del recolector: un ciclo de retención no es un fallo de ARC, es una afirmación falsa que escribiste, la de que dos objetos se poseen mutuamente y que ninguno puede morir antes que el otro. Por eso las palabras weak y unowned no son parches de rendimiento sino vocabulario de modelado: son la forma de decir que una relación existe sin que implique posesión. Quien las usa por superstición, para “evitar fugas”, acaba con crashes; quien las usa para describir quién sostiene a quién, acaba con un grafo que se libera solo.

📝
Lo esencial de ARC

El conteo fuerte vive en la cabecera del objeto y mide cuántas referencias se comprometen a mantenerlo vivo. El compilador inserta retain y release según convenciones de propiedad: los parámetros se prestan y no cuestan nada, lo poseído sí. Al llegar a cero se ejecuta deinit de inmediato y en el mismo hilo. El coste son operaciones atómicas que el optimizador elimina en gran medida, y el agujero son los ciclos, que ningún conteo local puede ver.

⚔️ Ver el conteo con tus propios ojos
  1. Emite el código intermedio de una función corta con swiftc -emit-sil y localiza cada strong_retain y cada strong_release; repite con la bandera de optimización y explica qué pares desaparecieron.
  2. Escribe una función que reciba una clase como parámetro normal y otra que la reciba como consuming, y compara el tráfico de conteo en el código intermedio de ambas.
  3. Implementa una estructura con almacenamiento en el montículo y isKnownUniquelyReferenced, y demuestra con un contador de copias que la duplicación solo ocurre cuando hay otro dueño.
  4. Mide en modo de publicación un bucle que recorre un arreglo de clases frente al mismo bucle sobre un arreglo de estructuras de escalares, y atribuye la diferencia al tráfico atómico.
  5. Escribe una clase con deinit que imprima, y razona por escrito el punto exacto del código donde se ejecuta antes de comprobarlo.