wandres.dev
COPY-ON-WRITE · el coste de los valores

El truco: compartir el almacenamiento hasta que alguien escribe

Por qué copiar un `Array` de un millón de elementos cuesta lo mismo que copiar un entero. La distinción entre semántica de valor y representación física, el búfer en el heap, y el instante exacto en que la copia se materializa.

⏱ 18 min

Swift promete algo que, tomado al pie de la letra, sería ruinoso: que asignar una variable a otra copia el valor entero. Si eso ocurriera literalmente, pasar un Array de un millón de enteros a una función costaría ocho megabytes de memcpy, y nadie escribiría Swift. La promesa se mantiene y la factura no llega, y esa contradicción aparente es el asunto central de este nivel. La respuesta es una separación tajante entre lo que el programa puede observar y lo que la máquina hace: dos variables pueden apuntar al mismo búfer mientras ninguna escriba, porque mientras ninguna escriba no existe ningún experimento capaz de distinguir esa situación de dos búferes idénticos. Copy-on-write no es una optimización que el compilador aplica cuando le conviene: es una técnica que la biblioteca estándar implementa a mano, en código Swift ordinario, y que tú también puedes escribir.

🎯 Al terminar esta lección sabrás
  • Distinguir la copia semántica que promete el lenguaje de la copia física que ejecuta la máquina.
  • Describir la representación real de un Array: una palabra en la pila y un búfer con contador de referencias en el heap.
  • Localizar el instante preciso en que una copia se materializa y por qué su coste es amortizable.
  • Identificar qué tipos de la biblioteca estándar usan copy-on-write y cuáles no lo necesitan.

La promesa cara y la representación barata

La semántica de valor es una afirmación sobre lo observable: tras var b = a, ninguna operación sobre b puede cambiar lo que a reporta, y viceversa. Es una propiedad del comportamiento, no del diseño de la memoria. Un compilador puede satisfacerla de muchas maneras, y la más obvia —duplicar los bytes en el acto— es también la más cara y la más innecesaria, porque paga por adelantado una independencia que la mayoría de las copias nunca llega a ejercer.

let millon = Array(0..<1_000_000)
let copia = millon              // no se duplica nada
print(MemoryLayout<[Int]>.size) // 8: un Array es una sola palabra

Ocho bytes. Conviene detenerse en la formulación exacta de la promesa, porque es más débil de lo que parece y esa debilidad es la que deja sitio al truco. El lenguaje no dice que la asignación duplique los bytes; dice que, tras la asignación, ninguna operación sobre una de las variables puede alterar lo que la otra observa. Es una cuantificación sobre observaciones futuras, no una descripción de lo que ocurre en el instante de asignar. Y una implementación es libre de satisfacerla del modo que quiera mientras ningún experimento la desmienta.

Ese margen es exactamente el mismo que la semántica denotacional concede a cualquier optimización correcta, y explica por qué la discusión sobre si Swift “de verdad copia” está mal planteada. Copia en el único sentido en que la palabra tiene contenido verificable dentro del lenguaje, y no copia en el sentido físico. Confundir los dos niveles es la causa de casi todos los malentendidos sobre rendimiento en Swift, en ambas direcciones: quien cree que copia siempre escribe contorsiones innecesarias con inout, y quien cree que no copia nunca se lleva la sorpresa del bucle cuadrático de la cuarta lección.

El valor que Array guarda en la pila es un puntero, y la asignación duplica ese puntero e incrementa un contador. La operación es O(1) y no toca el heap salvo para un retain atómico. Lo que hace legítima esa trampa es una observación epistemológica antes que técnica: dos búferes con contenido idéntico y dos referencias al mismo búfer solo se distinguen si alguien escribe. Mientras no haya escritura, la diferencia no existe para el programa, y un lenguaje solo está obligado a preservar lo que sus programas pueden observar.

Dónde vive de verdad un Array

Conviene mirar la estructura real, porque casi todo el comportamiento posterior se deduce de ella. Un Array es un struct de un solo campo que envuelve una referencia a una instancia de clase interna, _ContiguousArrayStorage. Esa instancia es un objeto ordinario del heap: tiene cabecera de objeto con el contador de referencias fuertes que gestiona ARC, seguida de una cabecera propia con count y capacity, y a continuación los elementos almacenados en línea, uno tras otro, asignados en la cola del mismo bloque.

var a = [10, 20, 30]
var b = a

a.withUnsafeBufferPointer { print($0.baseAddress!) } // 0x600000c0dc00
b.withUnsafeBufferPointer { print($0.baseAddress!) } // 0x600000c0dc00 — el mismo

b.append(40)
b.withUnsafeBufferPointer { print($0.baseAddress!) } // 0x600000c0e480 — otro

Tres consecuencias inmediatas. La primera: el contador de referencias que decide todo no vive en el struct sino en el objeto del heap, que es el único lugar donde puede ser compartido por varias copias. La segunda: los elementos viven contiguos y sin indirección adicional, de modo que el acceso por índice sigue siendo un desplazamiento aritmético y el búfer sigue siendo apto para vectorización. La tercera: el array vacío no asigna nada, porque la biblioteca comparte un objeto singleton inmortal para todos los arrays vacíos del proceso, con un contador que nunca se toca.

Hay un detalle de esa cabecera que conviene retener porque reaparecerá en la cuarta lección. La palabra que guarda count comparte espacio con bits de estado, y en plataformas Apple uno de ellos distingue el búfer nativo de un NSArray puenteado. Un Array que provenga del mundo de Objective-C no tiene almacenamiento contiguo garantizado ni contador de referencias consultable, y la biblioteca lo trata por un camino distinto y más lento. Ese es el motivo de que exista ContiguousArray: un tipo idéntico a Array salvo por la promesa de que nunca estará puenteado, lo que le permite prescindir de la comprobación y del camino alternativo.

flowchart TB
subgraph pila[Pila]
  a[var a una palabra]
  b[var b una palabra]
end
subgraph heap[Heap]
  buf[Cabecera de objeto con refcount 2 mas count y capacity mas elementos contiguos]
end
a --> buf
b --> buf
esc[b append 40 exige escritura] --> dec[refcount mayor que uno luego duplicar antes de mutar]
dec --> nuevo[Buffer nuevo exclusivo de b con refcount 1]
style buf fill:#89b4fa,color:#11111b
style dec fill:#f9e2af,color:#11111b
style nuevo fill:#a6e3a1,color:#11111b

La escritura como bifurcación

El momento decisivo no es la copia sino la primera mutación. Cada operación mutating de Array empieza invocando un procedimiento interno que la biblioteca llama hacer el búfer mutable y único: si el contador de referencias fuertes vale uno, la instancia es exclusivamente suya y puede escribir en el sitio; si vale más, asigna un búfer nuevo, copia los elementos y redirige su propio puntero. El resto de copias se quedan con el búfer antiguo, cuyo contador acaba de decrecer.

var a = [1, 2, 3]
var b = a        // refcount 2, cero trabajo
b[0] = 99        // aquí se paga: duplicar y luego mutar
// a == [1, 2, 3], b == [99, 2, 3]
b[1] = 88        // ya no paga nada: b es dueño exclusivo de su búfer

De ahí viene el nombre completo del patrón —copiar al escribir— y su perfil de coste. La duplicación se paga una sola vez por linaje: tras la primera escritura, b posee su búfer en exclusiva y las siguientes mil escrituras no vuelven a comprobar nada caro, porque la comprobación se reduce a leer una palabra y comparar. Los lectores no pagan jamás. El coste que sí es real y constante es el de ARC: cada copia y cada destrucción de un Array implican un retain y un release atómicos sobre el búfer, y ese tráfico no desaparece aunque nadie escriba nunca.

Merece la pena separar esa duplicación de otra reasignación que ocurre por motivos completamente distintos y que es fácil confundir con ella: el crecimiento. Cuando append agota la capacidad, Array asigna un búfer mayor —duplicando la capacidad, lo que da un coste amortizado constante— y migra los elementos. Eso ocurre aunque el búfer sea exclusivo y no tiene nada que ver con la compartición. Las dos causas de reasignación coexisten y se distinguen con reserveCapacity, que elimina la del crecimiento y deja intacta la del copy-on-write.

var v: [Int] = []
v.reserveCapacity(1_000)     // elimina las reasignaciones por crecimiento
let testigo = v              // pero no la duplicacion por comparticion
v.append(1)                  // sigue duplicando: el buffer tiene dos duenos

Esta distinción explica un malentendido habitual. Reservar capacidad no protege de nada relacionado con la compartición, y una función que reserva y luego copia el array antes de llenarlo no ha ganado nada. Son dos ejes ortogonales: uno mide cuánto espacio hay, el otro cuántos dueños.

Hay además una asimetría de coste entre ambas causas que conviene tener presente al leer un perfil. La reasignación por crecimiento copia solo los elementos que ya existen y ocurre un número logarítmico de veces sobre toda la vida del array; la duplicación por compartición copia el búfer completo y puede ocurrir tantas veces como copias vivas haya en el momento equivocado. La primera es un coste acotado por la construcción del array, la segunda es un coste acotado por la topología de referencias del programa, que es un objeto mucho menos predecible.

🪶

Copiar es incrementar un contador

La asignación de un Array duplica una palabra y hace un retain. Es O(1) con independencia del número de elementos, y no toca el contenido del búfer.

✍️

La escritura decide

Toda operación mutating comprueba antes la unicidad del búfer. Si es compartido, duplica y luego muta. La factura llega en la escritura, nunca en la lectura.

📉

El coste se amortiza por linaje

Solo la primera escritura tras una copia paga la duplicación. A partir de ahí la instancia es dueña exclusiva y sus mutaciones son directas.

Qué tipos lo hacen y cuáles no

El criterio que separa a los tipos que aplican el patrón de los que no es sorprendentemente nítido, y merece enunciarlo antes de dar la lista: copy-on-write compensa exactamente cuando el tamaño de la carga útil no está acotado por el tipo. Si el tipo sabe en compilación cuánto ocupa y esa cifra es pequeña, copiar directo gana; si el tamaño depende del contenido y puede crecer sin techo, la copia directa no tiene coste máximo y el patrón se impone.

Usan copy-on-write todos los tipos de la biblioteca estándar cuyo tamaño no está acotado por el tipo: Array, ContiguousArray, ArraySlice, Dictionary, Set, String, Substring, y fuera de la biblioteca estándar Data, NSAttributedString puenteado y las colecciones de swift-collections. Todos comparten la misma anatomía: un struct delgado sobre un almacenamiento de clase.

El patrón, además, compone: un struct tuyo que contenga tres Array hereda su comportamiento sin escribir nada. Copiar ese struct duplica tres punteros y hace tres retain; mutar uno de los arrays duplica solo ese. Es un detalle importante de cara a la tercera lección, porque significa que la inmensa mayoría de los tipos no necesitan copy-on-write manual: ya lo tienen, prestado de sus componentes, y con una granularidad más fina de la que tú escribirías a mano.

No lo usan, y no lo necesitan, los tipos cuyo tamaño se conoce en compilación y es pequeño: Int, Double, Bool, tuplas, enumeraciones sin indirect, un struct de tres Double. Copiar dieciséis bytes cuesta menos que un retain atómico, de modo que introducir un búfer compartido los haría más lentos, no más rápidos. String ilustra el matiz mejor que ningún otro tipo: sus dieciséis bytes de representación guardan las cadenas cortas —hasta quince bytes de UTF-8— literalmente en línea, sin heap y sin contador, y solo pasan al búfer compartido cuando el contenido no cabe. Un mismo tipo aplica dos estrategias según el tamaño del dato, y la frontera es invisible desde fuera.

Hay un caso intermedio que conviene conocer porque tiene una consecuencia práctica desagradable: las rodajas. Un ArraySlice no copia nada, sino que guarda una referencia al búfer del array original más un rango de índices. Eso hace que cortar sea O(1), pero también que una rodaja mantenga vivo el búfer entero.

var enorme = Array(0..<10_000_000)
let dosElementos = enorme[0..<2]   // retiene los diez millones
enorme = []                        // no libera nada mientras viva la rodaja

Guardar rodajas a largo plazo es, por tanto, una fuga de memoria disfrazada de optimización, y la conversión explícita con Array(rodaja) es la manera de cortar el vínculo. La misma advertencia vale para Substring respecto de String, y es la razón de que las APIs bien diseñadas devuelvan Substring para el trabajo intermedio y exijan una conversión deliberada a String cuando el resultado va a almacenarse.

Y no lo usa, por defecto, ningún struct tuyo. Nada en el lenguaje aplica el patrón automáticamente ni podría hacerlo: exige una clase intermedia, un inicializador de copia y un punto único de mutación, y ninguna de esas tres cosas puede sintetizarse sin decidir por ti cuál es la frontera de la copia profunda. Si defines un tipo con diez propiedades almacenadas, copiarlo copia las diez, siempre, sin comprobación ni búfer compartido. Esto casi nunca es un problema, porque los struct bien diseñados son pequeños; pero si el tuyo envuelve un búfer grande o una colección que quieres gestionar tú, tendrás que escribir el patrón a mano, y eso es exactamente lo que hará la tercera lección de este nivel.

Una ley del lenguaje, no una optimización

Aquí hay una distinción que separa a quien ha entendido Swift de quien lo usa. Copy-on-write no es una optimización del compilador. Las optimizaciones son promesas condicionales: se aplican si el optimizador las ve, desaparecen en compilaciones de depuración, cambian de versión a versión y nunca debes escribir código cuya corrección dependa de ellas. Copy-on-write es lo contrario: es una propiedad del contrato de Array, escrita a mano en código Swift ordinario dentro de la biblioteca estándar, que se cumple exactamente igual en depuración que con optimizaciones. Puedes razonar sobre coste asintótico contando con ella, porque no es una gentileza sino una garantía. La consecuencia profunda es que Swift resuelve mediante biblioteca un problema que otros lenguajes resolvieron mediante sistema de tipos. Rust hace explícito quién posee qué y obliga a declarar los préstamos: elimina la copia por construcción, y a cambio te cobra en anotaciones y en una curva de aprendizaje empinada. C++ tiene semántica de valor real y pagó copias profundas durante veinte años hasta que la semántica de movimiento las mitigó, a costa de un modelo mental notoriamente sutil. Swift eligió una tercera vía: mantener la simplicidad conceptual de la copia total y hacerla barata en tiempo de ejecución mediante un contador de referencias. Lo que ganas es un modelo mental que cabe en una frase. Lo que pagas es una comprobación dinámica en cada escritura y tráfico atómico de ARC en cada copia, un coste que ningún análisis estático te devuelve y que las tres lecciones siguientes te enseñarán a ver, a medir y, cuando haga falta, a evitar.

📝
Lo esencial de esta lección

Un Array es un struct de una palabra que referencia un búfer del heap con contador de referencias. Copiarlo incrementa el contador: O(1). La primera mutación tras una copia comprueba ese contador y, si es mayor que uno, duplica el búfer antes de escribir. Los lectores nunca pagan, la copia se amortiza por linaje, y ARC cobra un peaje atómico constante en cada copia.

⚔️ Observa el búfer con tus propias manos
  1. Crea un Array de cien mil enteros, cópialo y verifica con withUnsafeBufferPointer que ambas variables comparten la misma dirección base.
  2. Muta el segundo array en un solo elemento y comprueba que la dirección cambia. Muta otra vez y comprueba que ya no cambia.
  3. Imprime MemoryLayout de Array, String, Set y Dictionary, y explica por qué todos ocupan una o dos palabras con independencia de su contenido.
  4. Construye una cadena de quince caracteres ASCII y otra de veinte, cópialas y compara si comparten almacenamiento. Relaciona el resultado con la representación en línea de las cadenas cortas.
  5. Define un struct con cinco propiedades Double y otro con un Array dentro. Argumenta cuál de los dos copia bytes al asignarse y por qué el diseño de la biblioteca estándar es el correcto en ambos casos.