16. Copy On Write. Почему fork не копирует всю память сразу
Копирование по требованию: механизм Copy-On-Write
«Дублирование — враг эффективности. Самый остроумный способ скопировать тысячу страниц — не копировать их вовсе, пока кто-нибудь не возьмет в руки перо».
— Фундаментальный принцип ленивых вычислений
В лекции 6. Как Linux создает процессы. fork, exec, wait мы познакомились с классической моделью создания процессов в Unix через системный вызов fork().
Если бы операционная система реализовывала этот вызов «в лоб» — то есть физически выделяла новый блок оперативной памяти и побайтово копировала туда все содержимое адресного пространства родителя, — запуск любого процесса стоил бы $O(N)$ по времени и памяти.
Представьте: ваш Go-сервис держит в памяти 16 ГБ кэша и решает запустить простую системную утилиту uptime через os/exec. Если бы ядро честно копировало 16 ГБ памяти, создание процесса занимало бы сотни миллисекунд, замораживало бы CPU и требовало дополнительных 16 ГБ свободной RAM на материнской плате. А через мгновение вызов execve() просто стер бы эти 16 ГБ в мусорную корзину!
Эту архитектурную дилемму разрешает механизм Copy-On-Write (CoW, копирование при записи). Он позволяет создать точный клон процесса практически мгновенно (за доли микросекунды) и с нулевым расходом дополнительной физической памяти.
Как работает CoW «под капотом»
Механизм Copy-On-Write — это шедевр совместной работы кремния процессора (MMU) и ядра операционной системы Linux.
Когда прикладная программа вызывает fork(), ядро (внутри функций do_fork() -> copy_process() -> dup_mmap()) совершает изящный маневр:
- Выделяет новый дескриптор
task_struct(описатель процесса) и регистрирует новыйPID. - Дублирует дерево виртуальной памяти (Page Table) родителя, но не копирует ни одного байта физических страниц в DRAM.
- В записях таблиц страниц (PTE — Page Table Entry) как родителя, так и потомка ядро выполняет модификацию флагов:
- Снимает флаг
PTE_WRITE(запрет на запись). - Устанавливает
PTE_RDONLY(страница доступна только для чтения). - Инкрементирует счетчик ссылок (
page_ref_inc) в служебном дескрипторе физической страницыstruct page.
- Снимает флаг
sequenceDiagram
participant Parent as Процесс-родитель
participant Kernel as Ядро ОС Page Fault Handler
participant Child as Процесс-потомок
participant PhysMem as Физическая память (DRAM)
Parent->>Kernel: Вызов fork()
Kernel->>Child: Создать новую Page Table
Note over Parent, Child: Снять PTE_WRITE, поставить PTE_RDONLY
Child->>PhysMem: Чтение данных успешный доступ
Parent->>PhysMem: Чтение данных успешный доступ
Child->>Child: Пытается записать в память
PhysMem-->>Child: Аппаратный сбой MMU!
Child->>Kernel: Page Fault запись запрещена
Kernel->>Kernel: Проверить флаг CoW
Kernel->>PhysMem: Выделить новую физическую страницу 4 КБ
Kernel->>PhysMem: Копировать 4 КБ со старой страницы
Kernel->>Child: Обновить PTE PTE_WRITE разрешено
Child->>Child: Повторить инструкцию записи
Пока оба процесса только читают память, они бесконфликтно разделяют одни и те же кремниевые ячейки RAM с максимальной скоростью аппаратного кэша.
Но как только потомок (или родитель) выполняет инструкцию записи хотя бы одного байта, в дело вступает аппаратный блок MMU:
- MMU видит флаг
PTE_RDONLYи немедленно генерирует прерывание процессора — Page Fault (ошибка защиты страницы #PF). - Ядро перехватывает сбой в функции
do_page_fault()->do_wp_page(). - Ядро видит, что страница помечена как CoW, выделяет ровно одну новую физическую страницу 4 КБ из пула свободных страниц, копирует туда данные старой страницы, уменьшает счетчик ссылок у старой страницы и прописывает новый адрес в PTE пишущего процесса с флагом
PTE_WRITE. - Процессор перезапускает команду записи. Теперь пишущий процесс меняет свою приватную копию, а соседний процесс продолжает читать оригинал!
Go-специфика: os/exec, память и многопоточность
В мире Go разработчики редко вызывают низкоуровневый fork вручную. Чаще всего мы сталкиваемся с ним неявно — при использовании пакета os/exec для запуска внешних бинарников и скриптов.
package main
import (
"fmt"
"os/exec"
)
func main() {
// Под капотом здесь происходит классический цикл:
// 1. fork() -> CoW
// 2. execve() -> замещение памяти
// 3. wait() -> ожидание завершения
cmd := exec.Command("sleep", "10")
if err := cmd.Run(); err != nil {
fmt.Println("Ошибка:", err)
}
}
Память после fork в Go: почему RSS и SHR одинаковы?
Если вы запустите мониторинг через утилиту top или ps, вы столкнетесь с удивительной картиной:
- У родительского и дочернего процессов отображается практически идентичный
RSS(Resident Set Size). - При этом столбец
SHR(Shared Memory) у дочернего процесса будет в точности равен егоRSS!
Это означает, что физически оба процесса делят 100% оперативной памяти. Никакого расхода RAM при старте подпроцесса не произошло. Только когда приложение или рантайм начнет активно писать в кучу, страницы начнут копироваться, и RSS пишущего процесса поползет вверх. (Оговорка: эта картина верна для классического fork(); os/exec использует clone с CLONE_VM (см. ниже), и никакого копирования таблиц страниц и CoW там нет.)
Смертельная опасность: Многопоточный fork() в Go
В стандарте POSIX заложено жесткое правило: при вызове fork() в многопоточном процессе в дочерний процесс копируется только один поток — тот, который вызвал fork(). Все остальные потоки родителя мгновенно исчезают!
Представьте последствия для Go:
- Если в момент форка другой поток родителя удерживал внутренний
sync.Mutexаллокатора кучи или рантайма, в дочернем процессе этот мьютекс навсегда останется заблокированным! - Потока, который должен был его отпустить, больше не существует.
- Любая попытка дочернего процесса выделить память (
make, конкатенация строк) приведет к вечному deadlock!
Оптимизация безопасности через madvise(MADV_DONTFORK)
Если ваш сервис хранит в памяти конфиденциальные данные (криптографические мастер-ключи, токены сессий или гигантские неизменяемые кэши), вы можете запретить ядру копировать эти страницы дочерним процессам при fork.
Для этого используется системный вызов madvise с флагом MADV_DONTFORK:
package main
import (
"fmt"
"os"
"syscall"
)
func main() {
// Выделяем память под приватный ключ шифрования
buf := make([]byte, 1024)
// Системный вызов madvise(MADV_DONTFORK) сообщает ядру Linux:
// "Никогда не включай эти страницы в CoW-копирование при fork!"
if err := syscall.Madvise(
buf,
syscall.MADV_DONTFORK,
); err != nil {
fmt.Println("Ошибка madvise:", err)
}
_ = os.WriteFile("/dev/null", buf, 0600)
}
Флаг MADV_DONTFORK гарантирует, что дочерний процесс не унаследует доступ к этой памяти в своих таблицах страниц. При попытке потомка прочитать эти адреса ядро сгенерирует SIGSEGV, надежно защищая секреты родителя от утечки.
Ловушки и вопросы на собеседованиях
1. В чем разница между RSS и SHR после fork? Почему они одинаковы?
RSS отражает суммарный объем физической памяти, используемый процессором. SHR показывает, какая часть этой памяти разделяется с другими процессами. Сразу после fork таблицы страниц потомка ссылаются на те же самые физические фреймы родителя, поэтому SHR == RSS. Это высшее доказательство эффективности механизма CoW.
2. Как передать терабайты данных от родителя к потомку без копирования?
Использовать системные механизмы разделяемой памяти: каналы pipe() или сокетные пары socketpair(). Данные буферизуются в кольцевых буферах ядра, а потомок считывает их напрямую, исключая избыточное копирование пользовательской памяти.
3. Что такое vfork() и почему он исторически опасен?
Исторический системный вызов vfork() создавал процесс без копирования таблиц страниц: потомок исполнялся в том же самом адресном пространстве, что и родитель, а родитель приостанавливался до вызова потомком exec() или exit().
Опасность: Любая запись в переменную внутри потомка физически меняла память родителя! Ошибка в потомке разрушала стек родительского процесса. В языке Go используется syscall.ForkExec, который в Linux вызывает системный вызов clone() с флагами CLONE_VM | CLONE_VFORK | SIGCHLD, безопасно выполняя перенаправление дескрипторов и переходя к execve без дублирования страниц памяти.
4. Как Go runtime обрабатывает fork в контексте сборщика мусора (GC)?
При вызове fork рантайм Go не останавливает фоновый сборщик мусора в потомке. Если потомок не вызывает execve(), а продолжает работать, сборщик мусора начнет сканировать кучу. При сканировании первой же страницы GC попытается записать биты маркировки, что спровоцирует лавинообразный шквал CoW Page Faults, мгновенно раздувая физическую память потомка и сводя на нет всю экономию CoW!
5. Сравнение с другими языками:
- C/C++: Связка
fork()+exec()остается классическим стандартом POSIX. Для оптимизации используется функцияposix_spawn(), объединяющая обе операции в один системный вызов ядра и исключающая CoW-накладные расходы. - Java: Метод
ProcessBuilder.start()под капотом вызывает нативные системные вызовы. Исторически JVM страдала отfork, так как многопоточная среда HotSpot (потоки компилятора C2, GC воркеры) делала вызов опасным. В современных версиях Java (Java 9+) появились интерфейсProcessHandleи нативная поддержкаposix_spawn(). - PHP-FPM: Идеальный пример архитектуры на базе CoW. Мастер-процесс загружает все расширения PHP и байт-код скриптов в память, а затем делает
forkна каждый рабочий пул. Воркеры обрабатывают входящие запросы, разделяя общую неизменяемую память движка.
Итог
- Copy-On-Write (CoW) — фундаментальный инженерный компромисс операционных систем: создание процесса происходит за доли микросекунды, а реальное выделение памяти откладывается до момента первой записи.
- Механизм опирается на флаги защиты
PTE_RDONLYи аппаратные прерывания Page Fault блока процессора MMU. - В языке Go вызов
os/execблагодаря флагамCLONE_VM | CLONE_VFORK(потомок временно работает в адресном пространстве родителя, и копировать таблицы страниц не нужно) не вызывает скачков расхода оперативной памяти. - Многопоточный
fork()без немедленногоexec()смертельно опасен в Go из-за неизбежных дедлоков мьютексов рантайма. - Системный вызов
madvise(MADV_DONTFORK)— мощный инструмент защиты секретных ключей и криптографических буферов от утечки в дочерние подпроцессы.
Мы разобрались, как ядро защищает память при создании процессов. Но что происходит, когда физическая оперативная память сервера подходит к концу? Как операционная система решает, кого выселить на диск, а кого оставить в RAM? В следующей статье мы разберем механизмы выживания ядра: 17. Paging и Swapping.