⏱️ Lectura: 16 min
Cada vez que un proceso en Linux llama a fork(), el kernel crea un hijo completo en microsegundos sin copiar memoria. El secreto detrás es la técnica copy-on-write: padre e hijo comparten las mismas páginas físicas hasta que alguno escribe en ellas.
📑 En este artículo
- TL;DR
- ¿Qué es la técnica copy-on-write?
- Por qué importa la copia diferida de memoria
- Cómo funciona COW (copy-on-write) por dentro
- Ejemplos prácticos
- Cómo empezar
- Casos de uso reales
- Errores comunes y buenas prácticas
- Comparativa con alternativas
- Profundizando: qué pasa a nivel de MMU
- Preguntas frecuentes
- ¿Qué diferencia hay entre la técnica copy-on-write y clonar un archivo completo?
- ¿Por qué Python multiprocessing no logra el ahorro completo que promete la copia diferida de memoria?
- ¿Funciona COW (copy-on-write) en Windows?
- ¿Redis usa COW (copy-on-write) para los snapshots RDB?
- ¿Qué pasa si el sistema se queda sin memoria mientras copia páginas por duplicación perezosa de datos?
- ¿Qué sistemas de archivos soportan la duplicación perezosa de datos a nivel de bloque?
- Referencias
Git, Docker y ZFS usan la misma idea para evitar copiar archivos completos. Redis la aprovecha para guardar una foto de la base de datos sin bloquear las escrituras.
TL;DR
- Copy-on-write deja que dos procesos compartan la misma página de memoria hasta que uno la modifica.
- fork() en Linux crea un proceso hijo completo sin duplicar memoria porque hereda las páginas del padre.
- Docker copia el archivo completo a la capa de escritura al modificar un solo byte, no solo el bloque cambiado.
- Redis usa fork() para tomar una foto consistente de la base de datos sin bloquear las escrituras.
- cp –reflink=auto clona un archivo de cualquier tamaño en milisegundos en sistemas btrfs o xfs porque no copia bloques.
¿Qué es la técnica copy-on-write?
La técnica copy-on-write es un mecanismo que posterga la copia de un dato hasta que alguien intenta modificarlo. Mientras solo se lee, varios consumidores comparten la misma copia física; en el instante de la escritura, el sistema crea una copia privada solo de esa porción modificada.
El mecanismo aplica a dos niveles distintos. En memoria, la unidad que se comparte es la página, un bloque de 4 KB que administra el sistema operativo. En almacenamiento, la unidad son los bloques del disco o, en el caso de Docker, el archivo completo dentro de una capa.
En ambos casos el ahorro es el mismo. Se evita una copia costosa cuando lo más probable es que nadie necesite modificar ese dato. Si nadie escribe nunca, la copia jamás ocurre.
Por qué importa la copia diferida de memoria
Sin copy-on-write, cada fork() tendría que duplicar toda la memoria del proceso padre antes de arrancar el hijo. Un servidor con varios gigabytes de memoria residente tardaría un tiempo notable en clonarse, y gastaría esa misma cantidad de RAM por cada hijo, incluso si el hijo solo iba a ejecutar un comando corto y salir.
Con copy-on-write, el fork() es casi instantáneo porque solo copia las estructuras de control del proceso (tabla de páginas, descriptores de archivo) y marca las páginas de datos como compartidas. El costo real se paga después, de forma gradual, solo por las páginas que efectivamente cambian.
Lo mismo aplica a nivel de almacenamiento. Clonar una imagen de máquina virtual, tomar un snapshot de una base de datos o crear un contenedor a partir de una imagen serían operaciones lentas y costosas en espacio si cada clon implicara copiar bytes reales. Con COW (copy-on-write), esas operaciones son casi gratuitas hasta que algo cambia.
Cómo funciona COW (copy-on-write) por dentro
La página como unidad mínima
El kernel de Linux organiza la memoria de un proceso en páginas de 4 KB. Cada página tiene una entrada en la tabla de páginas del proceso que apunta a una dirección física en RAM y lleva permisos de lectura, escritura o ejecución.
Cuando un proceso hace fork(), el kernel no copia esas páginas. En su lugar, copia la tabla de páginas del hijo apuntando a las mismas direcciones físicas que el padre, pero marca todas esas entradas como solo lectura, incluso si originalmente permitían escritura.
flowchart TD
A["Proceso padre en memoria"] --> B["fork()"]
B --> C["Proceso hijo: mismas paginas, solo lectura"]
C --> D{"Alguien escribe en una pagina?"}
D -- "No" --> E["Las paginas siguen compartidas"]
D -- "Si" --> F["El kernel copia solo esa pagina"]
F --> G["Padre e hijo ya tienen paginas privadas distintas"]
El fallo de página que dispara la copia
Cuando padre o hijo intentan escribir en una de esas páginas marcadas como solo lectura, la MMU (la unidad de gestión de memoria del procesador) detecta la violación y genera un fallo de página. El kernel atiende ese fallo, asigna una página física nueva, copia el contenido viejo ahí y actualiza la tabla de páginas del proceso que escribió para que apunte a la copia privada.
El otro proceso nunca se enteró de nada. Sigue apuntando a la página original, que ahora es exclusivamente suya. El kernel solo copia esa página de 4 KB, no los gigabytes completos del proceso.
sequenceDiagram
participant P as Proceso
participant M as MMU
participant K as Kernel
P->>M: escribe en una pagina compartida
M-->>K: genera un fallo de pagina
K->>K: copia la pagina a una direccion fisica nueva
K-->>M: actualiza la tabla de paginas del proceso
M-->>P: la escritura se completa en la copia privada
Note over P,K: el resto de las paginas sigue compartido
mmap() con MAP_PRIVATE también usa COW
No solo fork() se apoya en este mecanismo. Cuando un programa mapea un archivo con mmap(..., MAP_PRIVATE, ...), el kernel comparte las páginas del archivo entre todos los procesos que lo mapearon así, pero si alguno escribe, esa página se copia y se vuelve privada sin tocar el archivo en disco. Así comparten los cargadores de bibliotecas dinámicas el segmento de código de una librería entre decenas de procesos, mientras mantienen privado el segmento de datos de cada uno.
Ejemplos prácticos
Estos tres ejemplos en Python van de lo más simple a un patrón real de producción. Todos requieren Linux o macOS, porque Windows no implementa fork().
Fork básico: dos procesos, un mismo punto de partida
import os
pid = os.fork()
if pid == 0:
print(f'Hijo: PID {os.getpid()}')
else:
print(f'Padre: PID {os.getpid()}, hijo PID {pid}')
Este script crea un proceso hijo que imprime su propio PID mientras el padre imprime el suyo y el de su hijo. La salida real varía porque los PID los asigna el sistema, pero siempre sigue este patrón:
Padre: PID 48213, hijo PID 48214
Hijo: PID 48214
Memoria compartida hasta que alguien escribe
import os
datos = [0] * 10_000_000 # una lista grande en memoria
pid = os.fork()
if pid == 0:
print('Hijo ve el primer valor:', datos[0])
os._exit(0)
else:
os.waitpid(pid, 0)
print('Padre sigue con la misma lista sin duplicarla')
El hijo solo lee la lista, nunca la modifica, así que nunca dispara una copia de página. La salida es siempre esta:
Hijo ve el primer valor: 0
Padre sigue con la misma lista sin duplicarla
El patrón real: workers que heredan datos sin copiarlos
import multiprocessing as mp
dataset_grande = {'usuarios': list(range(5_000_000))}
def procesar(worker_id):
total = len(dataset_grande['usuarios'])
return f'worker {worker_id} vio {total} usuarios'
if __name__ == '__main__':
ctx = mp.get_context('fork')
with ctx.Pool(4) as pool:
resultados = pool.map(procesar, range(4))
for r in resultados:
print(r)
Con el contexto fork, los cuatro procesos del pool heredan dataset_grande por copy-on-write en lugar de recibirlo serializado por cada worker. La salida:
worker 0 vio 5000000 usuarios
worker 1 vio 5000000 usuarios
worker 2 vio 5000000 usuarios
worker 3 vio 5000000 usuarios
Cómo empezar
Dependencias: un sistema de archivos con soporte COW (btrfs o xfs con reflink) para el primer ejemplo, y Python 3 con acceso a os.fork() para el segundo. ext4 no soporta reflink.
Clonar un archivo sin copiarlo (btrfs o xfs)
# crea un archivo de prueba de 1 GB
fallocate -l 1G original.img
# copia COW: no lee el gigabyte completo
cp --reflink=auto original.img copia.img
# tamaño aparente vs espacio real en disco
du -h --apparent-size copia.img
du -h copia.img
El primer du muestra el tamaño lógico del archivo. El segundo muestra el espacio real que ocupa en disco, que apenas cambia hasta que modificás la copia:
1.0G copia.img
4.0K copia.img
Verificar que una página sigue compartida
grep -E 'Shared_Clean|Private_Dirty' /proc/$PID/smaps_rollup
Reemplazá $PID por el PID real del proceso. Mientras las páginas sigan compartidas, Shared_Clean va a ser alto y Private_Dirty cercano a cero. Un ejemplo de salida:
Shared_Clean: 81920 kB
Private_Dirty: 0 kB
Casos de uso reales
Redis y el guardado RDB. Para escribir una foto completa de la base de datos a disco sin bloquear las escrituras entrantes, Redis hace fork() de sí mismo. El proceso hijo ve una copia congelada de toda la memoria gracias a COW, mientras el padre sigue atendiendo comandos; si el padre modifica una clave, esa página se duplica, pero el resto sigue compartido.
Docker y overlay2. Las imágenes de Docker se componen de capas de solo lectura apiladas. Cuando un contenedor modifica un archivo, overlay2 copia ese archivo completo a la capa de escritura del contenedor antes de aplicar el cambio. Esa operación se llama copy-up. Docker también soporta un driver sobre Btrfs que sí copia a nivel de bloque, pero overlay2 es el predeterminado por ser más simple de administrar.
flowchart TD
subgraph Imagen
L1["Capa base: sistema operativo"]
L2["Capa 2: dependencias"]
L3["Capa 3: aplicacion"]
end
L1 --> L2 --> L3
L3 --> W["Capa de escritura del contenedor"]
W -. "copy-up al modificar un archivo" .-> L3
Git y los clones locales. Cuando clonás un repositorio local con git clone –local, Git enlaza los objetos del repositorio original mediante hard links en lugar de copiarlos byte a byte. Ambos repositorios comparten el mismo inodo hasta que uno de los dos corre git gc o reescribe un objeto.
ZFS y Btrfs. Un snapshot de ZFS o Btrfs no copia ningún bloque al crearse. El sistema de archivos solo congela los punteros existentes; los bloques nuevos que escribís después del snapshot se asignan en otro lugar, y el snapshot sigue viendo los bloques viejos intactos. ZFS lleva la idea más lejos: su estructura en disco es un árbol de bloques copy-on-write, donde cada escritura crea bloques nuevos y una nueva raíz del árbol, mientras la raíz anterior sigue intacta mientras algo la referencie.
Errores comunes y buenas prácticas
El refcounting de Python rompe el ahorro. CPython guarda un contador de referencias dentro de cada objeto. Cada vez que un worker hijo solo lee un objeto, Python igual incrementa ese contador, y esa escritura al contador basta para disparar la copia de la página completa. En la práctica, un pool de workers que solo lee datos termina copiando buena parte del heap de todas formas.
💡 Tip: para mitigar esto en Python, llamá a gc.freeze() antes del fork(). Congela los objetos existentes fuera del rastreo del recolector de basura y reduce las escrituras de contador que los tocan.
Docker copia el archivo entero, no el bloque. A diferencia de Btrfs o ZFS, overlay2 hace copy-up a nivel de archivo completo. Modificar un byte de un archivo grande dentro de un contenedor copia el archivo completo a la capa de escritura, no solo el fragmento cambiado.
⚠️ Ojo: si tu contenedor escribe con frecuencia en archivos grandes, montá un volumen en vez de confiar en la capa de escritura del contenedor.
Los hilos no necesitan COW. Los hilos de un mismo proceso ya comparten el mismo espacio de memoria sin ningún truco adicional. Copy-on-write resuelve un problema distinto: compartir memoria entre procesos separados que antes eran uno solo.
El sobrecompromiso de memoria puede fallar tarde. El kernel permite que fork() prometa más memoria de la que hay disponible, confiando en que no todas las páginas se van a escribir. Si muchos procesos hijos escriben a la vez y disparan copias simultáneas, el sistema puede quedarse sin RAM real. El kernel pondera cada proceso con un oom_score basado en cuánta memoria usa y un ajuste manual, y el proceso con el puntaje más alto es el primero en caer, sin importar si fue el que disparó las copias.
Comparativa con alternativas
Cada mecanismo resuelve el mismo problema (compartir datos sin duplicarlos) a un nivel distinto del sistema.
| Mecanismo | Qué comparte | Cuándo copia | Ejemplo real |
|---|---|---|---|
| fork() con COW | Páginas de memoria del proceso | Al escribir en una página (fallo de página) | Redis, servidores preforked como gunicorn |
| Hilos (threads) | Todo el espacio de memoria del proceso | Nunca copia; requiere locks para evitar colisiones | Servidores multihilo en Java o Go |
| Memoria compartida explícita (mmap/shm) | Una región de memoria elegida a mano | Nunca copia; la sincronización la maneja la app | Bases de datos con buffer pool compartido |
| COW a nivel de bloque (Btrfs/ZFS) | Bloques del disco | Al escribir en un bloque modificado | Snapshots de volúmenes |
| Copy-up a nivel de archivo (overlay2) | Archivos completos de una capa | Al abrir el archivo en modo escritura | Contenedores Docker |
Profundizando: qué pasa a nivel de MMU
La tabla de páginas de cada proceso vive en memoria y la consulta la MMU en cada acceso. Cada entrada incluye un bit de permiso de escritura y, en muchas arquitecturas, un bit dirty que marca si la página ya fue modificada desde que se cargó. Copy-on-write apaga el bit de escritura aunque la página original lo tuviera activado, y esa discrepancia es la que provoca el fallo de página cuando alguien intenta escribir.
Linux además distingue entre fork() y vfork(). Esta segunda variante, mucho menos usada, ni siquiera crea una tabla de páginas separada para el hijo. Suspende al padre y deja que el hijo use literalmente la misma memoria hasta que llama a exec() o termina. Es más rápida que fork() con COW, pero también más riesgosa, porque un error en el hijo puede corromper la memoria del padre.
El conteo de cuántos procesos comparten una página física vive en una estructura del kernel llamada struct page. Cuando ese contador llega a uno, significa que ya nadie más apunta ahí, y el kernel puede tratar esa página como privada sin mantener el permiso de solo lectura.
📖 Resumen en Telegram: Ver resumen
Tu próximo paso: corré el ejemplo de cp --reflink=auto en una partición btrfs o xfs y compará el tiempo contra cp --reflink=never sobre el mismo archivo para ver la diferencia con tus propios ojos.
Preguntas frecuentes
¿Qué diferencia hay entre la técnica copy-on-write y clonar un archivo completo?
Clonar un archivo completo copia todos los bytes de inmediato, sin importar si alguna vez se van a modificar. La técnica copy-on-write posterga esa copia hasta el momento exacto en que algo cambia, y en muchos casos esa copia nunca llega a ocurrir.
¿Por qué Python multiprocessing no logra el ahorro completo que promete la copia diferida de memoria?
Porque el recolector de basura de CPython modifica el contador de referencias de cada objeto incluso cuando un worker solo lo lee, y esa escritura dispara la copia de la página que contiene ese contador.
¿Funciona COW (copy-on-write) en Windows?
Windows no implementa fork() como Linux, pero sí usa copy-on-write internamente en ciertas rutas de creación de procesos, y ReFS soporta clones de bloque a nivel de sistema de archivos similares a los de Btrfs.
¿Redis usa COW (copy-on-write) para los snapshots RDB?
Sí. Redis llama a fork() antes de escribir el archivo RDB, y el proceso hijo recorre una copia congelada de la base de datos gracias a COW mientras el padre sigue respondiendo comandos normalmente.
¿Qué pasa si el sistema se queda sin memoria mientras copia páginas por duplicación perezosa de datos?
Si el sistema permitió sobrecomprometer memoria y de golpe muchos procesos escriben a la vez, puede no haber RAM física suficiente para todas las copias. El kernel mata procesos para liberar memoria según su oom_score, y no necesariamente elige al proceso que causó el problema.
¿Qué sistemas de archivos soportan la duplicación perezosa de datos a nivel de bloque?
Btrfs y ZFS son los más extendidos en Linux, ambos con snapshots y clones instantáneos. XFS sumó soporte de reflink más tarde, y ReFS lo ofrece en Windows Server. ext4 no lo soporta.
Referencias
- Wikipedia: artículo general sobre copy-on-write en sistemas operativos y almacenamiento.
- man7.org: página de manual de fork() en Linux.
- docs.python.org: documentación oficial de os.fork() en Python.
- docs.docker.com: cómo funciona el driver de almacenamiento overlay2.
- redis.io: documentación de persistencia RDB y el uso de fork().
- git-scm.com: documentación de git clone y la opción –local.
- gnu.org: manual de GNU coreutils sobre cp y la bandera –reflink.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
Imagen destacada: Foto de Liam Briese en Unsplash
¿Te sirvió? ¿Te dio otro error? Contalo abajo: las preguntas se responden y le sirven al siguiente que llegue.
Dejar un comentario
0 Comentarios