Как рождается процесс: почему fork не копирует память, хотя обещает
По контракту POSIX fork() создаёт для процесса точную копию: тот же код, тот же стек, та же куча, те же открытые файлы — только с новым PID. Интуитивно кажется, что если у родителя занято 8 ГБ RAM, форк должен на секунду-другую подвиснуть, пока ядро физически копирует эти гигабайты. На практике fork() возвращается почти мгновенно независимо от объёма памяти процесса — и это не магия, а конкретный механизм на уровне таблиц страниц, который стоит понимать, если вы разбираетесь в поведении nginx-воркеров, PHP-FPM, Redis BGSAVE или просто хотите понимать, откуда в top берутся странные цифры RSS после форка.
Содержание
- Что fork() обещает и что происходит на самом деле
- Зачем нужен COW: во что обошлось бы честное копирование
- Как COW устроен на уровне страниц
- Момент истины: что происходит при первой записи
- Почему fork остаётся быстрым даже для гигабайтных процессов
- Практические следствия: fork+exec, Redis BGSAVE, контейнеры
Что fork() обещает и что происходит на самом деле
Формально fork() должен дать потомку независимую копию адресного пространства родителя: те же сегменты кода, данных, кучи и стека, отображённые по тем же виртуальным адресам, с тем же содержимым. Если бы ядро выполняло это буквально — читало каждую занятую физическую страницу родителя, выделяло под неё новую физическую страницу и копировала байты — стоимость fork() линейно росла бы с объёмом резидентной памяти процесса. Для процесса на пару сотен мегабайт это было бы терпимо, для процесса с несколькими десятками гигабайт — уже заметная пауза на каждый форк.
Ядро Linux (и большинство современных POSIX-систем) поступает иначе: вместо копирования содержимого страниц оно копирует только *таблицы отображений* — структуры, которые говорят, какому физическому адресу соответствует каждый виртуальный адрес. Сами данные в физической памяти остаются на месте, и оба процесса, родитель и потомок, первое время буквально читают одни и те же физические страницы. Это и есть copy-on-write (COW): копирование откладывается до момента, когда кто-то реально попытается изменить данные.
Зачем нужен COW: во что обошлось бы честное копирование
Представьте процесс с 4 ГБ резидентной памяти — типичный процесс на PHP, Python или Node.js с прогретым рантаймом и закэшированными данными. Честное копирование этих 4 ГБ означало бы не только выделение новых физических страниц, но и обход всех таблиц страниц, инвалидацию TLB на нескольких ядрах, работу с NUMA-размещением — и всё это ради данных, львиная доля которых вообще не изменится за время жизни потомка.
Здесь важна ещё одна деталь: fork() в подавляющем большинстве случаев вызывается не сам по себе, а как первый шаг паттерна fork+exec — родитель форкается, чтобы почти сразу вызвать execve() и заменить всё адресное пространство потомка новым бинарником (это то, что происходит внутри любого шелла при запуске команды, внутри systemd при старте юнита, внутри веб-сервера при спавне обработчика). Если бы fork() честно копировал гигабайты, а следующая строка кода тут же выбрасывала эту копию через exec(), вся работа ушла бы впустую. Именно ради этого сценария в POSIX существуют ещё более радикальные оптимизации — vfork() и posix_spawn(), которые вообще не создают потомку отдельные таблицы страниц до exec(), а временно одалживают память родителя с жёстким условием: потомок не должен ничего в неё писать до замены образа.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверКак COW устроен на уровне страниц
Виртуальная память процесса — это набор областей (VMA — virtual memory area): куча, стек, анонимные mmap, приватные отображения файлов, разделяемые библиотеки. Каждая такая область через таблицу страниц ссылается на физические фреймы. При вызове fork() ядро:
- создаёт новому процессу собственные структуры (таблицы страниц, дескриптор
mm_structв терминах Linux); - проходит по всем приватным записываемым отображениям (
MAP_PRIVATE) родителя и создаёт в таблице потомка записи, указывающие на те же физические фреймы, что и у родителя; - для каждой такой записи снимает бит записи (write) — причём не только у потомка, но и у оригинальной записи родителя, даже если раньше страница была доступна на запись;
- увеличивает счётчик ссылок на физическую страницу.
После этого шага и родитель, и потомок физически читают одну и ту же RAM. Ничего не скопировано — скопирована только сама таблица отображений, а она на порядки меньше, чем данные, на которые она указывает: одна запись таблицы страниц (PTE) на x86-64 занимает 8 байт и покрывает 4 КБ данных, то есть таблица для 4 ГБ адресного пространства — это мегабайты, а не гигабайты, и именно эти мегабайты копируются при форке, а не сами данные.
Важная граница: COW применяется к приватным (MAP_PRIVATE) отображениям — куче, стеку, анонимной памяти, приватным маппингам файлов. Разделяемая память (MAP_SHARED, System V shared memory, mmap с флагом MAP_SHARED) и так одна на всех процессов, которые её отображают, — там COW не нужен и не применяется, изменения сразу видны всем, кто её держит.
Момент истины: что происходит при первой записи
Пока ни родитель, ни потомок не пишут в общую страницу — они безопасно её читают, аппаратный уровень (MMU) не вмешивается. Но как только любой из них выполняет запись в страницу, помеченную read-only из-за COW, процессор генерирует page fault — исключение защиты памяти. Обработчик fault'а в ядре видит: страница действительно должна быть доступна на запись по правам процесса, но помечена read-only из-за разделения через COW (счётчик ссылок на неё больше единицы). Дальше происходит собственно копирование:
- выделяется новая физическая страница;
- в неё копируется содержимое старой (разделяемой) страницы;
- запись в таблице страниц заявителя (того, кто писал) переключается на новую страницу с восстановленным правом записи;
- счётчик ссылок на старую страницу уменьшается.
Если после этого у старой страницы остаётся ровно один держатель, ядро может даже не создавать новую страницу, а просто отдать оставшемуся владельцу право записи прямо в неё — копирование не нужно, когда делить уже не с кем.
Ключевой момент: копирование происходит постранично, а не для всей области целиком. Если процесс после форка тронул записью пять страниц из миллиона отображённых — скопированы будут ровно эти пять, а не весь диапазон памяти, к которому они принадлежат. С точки зрения приложения всё это прозрачно: никакого системного вызова, никакой видимой задержки, кроме собственно стоимости самого page fault — это чисто аппаратно-ядерная механика. Понаблюдать за её работой можно через счётчик минорных page fault'ов процесса:
ps -o pid,ppid,vsz,rss,min_flt,maj_flt,comm -p <PID>
или через /proc/<PID>/stat (поле minflt), или perf stat -e page-faults -p <PID> — рост min_flt сразу после форка при активной записи в общие страницы будет заметен.
Почему fork остаётся быстрым даже для гигабайтных процессов
Стоимость fork() пропорциональна не объёму резидентной памяти (RSS), а количеству и сложности отображений (VMA) и размеру таблиц страниц, которые нужно продублировать. Процесс с 32 ГБ RSS, но с умеренным (типичным для обычного приложения) числом отображений, форкается примерно с той же скоростью, что и процесс на 200 МБ — потому что копируются структуры-указатели, а не сами данные.
Именно на этом свойстве держится паттерн предзагруженного мастер-процесса, который затем форкает воркеров: nginx поднимает master, который слушает сокеты и читает конфиг, а затем форкает worker-процессы — каждый получает уже открытые сокеты и разобранный конфиг «бесплатно», через общие страницы. Так же устроены PHP-FPM (мастер загружает опкэш и общие структуры, дочерние процессы форкаются от него), Unicorn для Ruby (preload_app явно рассчитан на то, что тяжёлая инициализация приложения происходит один раз в мастере, а не в каждом воркере), Gunicorn с --preload. Смысл всегда один: прогреть один процесс (загрузить код, скомпилировать шаблоны, прочитать конфиги, установить соединения) и наплодить от него дешёвых копий через fork(), вместо того чтобы повторять всю инициализацию в каждом воркере с нуля.
Важно не путать «форк дешёвый» с «потомки ничего не стоят». Страницы памяти продолжают физически существовать, пока на них ссылается хотя бы один процесс; настоящее удвоение потребления происходит только тогда, когда содержимое страниц расходится из-за записи — и происходит оно постепенно, отдельными страницами, а не одним скачком в момент форка.
Есть и обратная сторона: сам fork() не бесплатен буквально — при очень большом числе отображений (сильно фрагментированное адресное пространство, тысячи мелких mmap-регионов, что бывает у процессов с множеством загруженных библиотек или JIT-компиляторов, агрессивно выделяющих память кусками) обход и копирование таблиц страниц начинает занимать заметное время. Это линейный рост от количества *отображений*, а не от количества занятых байт — разница принципиальная, но при экстремальном фрагментированном адресном пространстве она всё равно выливается в измеримую задержку.
Практические следствия: fork+exec, Redis BGSAVE, контейнеры
Паттерн fork+exec выигрывает от COW по максимуму: страницы родителя, унаследованные потомком при форке, чаще всего вообще не успевают быть тронутыми записью — execve() тут же заменяет всё адресное пространство новым образом, старые отображения демонтируются, счётчики ссылок уменьшаются, и ни одна страница так и не была скопирована. Это ровно тот случай, где COW отрабатывает идеально: подготовка (форк) дешёвая, а разовое использование памяти родителя потомком равно нулю.
Контринтуитивная ловушка в другом: быстрый fork() не гарантирует, что память останется дешёвой и после него. Если после форка и родитель, и потомок начинают активно писать в большие общие области — например, оба продолжают модифицировать общую структуру данных, унаследованную от общего состояния до форка, — каждая такая запись порождает отдельный page fault и отдельное копирование страницы. При достаточно широкой и интенсивной записи с обеих сторон суммарное потребление памяти двух процессов постепенно приближается к удвоенному объёму данных, которые были общими на момент форка, хотя в момент самого fork() не было скопировано ничего. Рост происходит не сразу и не виден в стоимости вызова fork() — он проявляется позже, постепенно, по мере накопления «разошедшихся» страниц, и обычно застаёт врасплох именно потому, что сам форк отработал мгновенно и никак не сигнализировал о будущей нагрузке.
Классический пример — снятие снапшота в Redis (BGSAVE, а также фоновая перезапись AOF). Redis форкает дочерний процесс, который последовательно вычитывает весь датасет и пишет его на диск как консистентный снимок на момент форка. Сам форк почти мгновенный даже для датасета на много гигабайт — потому что это чистое копирование таблиц страниц. Но пока дочерний процесс медленно, по диску, дописывает снапшот, родительский процесс продолжает обслуживать обычные команды на запись — а каждая модификация ключа, чья страница ещё не разошлась с дочерним процессом, вызывает COW-копирование этой страницы. При интенсивной записи во время долгого (например, диск-ограниченного) BGSAVE это может ощутимо раздуть RSS родителя сверх обычного рабочего объёма — то, что операторы иногда принимают за утечку памяти, хотя это именно накопление COW-копий. Подробнее о похожих сценариях с памятью Redis — в статье про высокое потребление памяти Redis, там же про настройку maxmemory на случай, если COW-рост во время фонового сохранения упирается в лимит.
В контейнерах есть свой нюанс: cgroup обычно относит физическую страницу к той группе, которая её реально выделила или первой затронула, так что общие COW-страницы между родителем и потомком в одной cgroup не удваивают учитываемое потребление сразу после форка. Но если оба активно расходятся записью внутри одной cgroup с жёстким лимитом memory.max, суммарный учтённый объём растёт быстрее, чем ожидалось бы от «просто запустили ещё один воркер», и группа может упереться в лимит раньше расчётного. Похожий, но более резкий эффект — с huge pages: если процесс использует Transparent Huge Pages, единица COW становится не 4 КБ, а обычно 2 МБ, и запись даже в один байт внутри такой страницы копирует сразу все 2 МБ вокруг — при широкой, но не слишком плотной записи после форка это ощутимо более резкий скачок памяти, чем с обычными страницами.
Тот же принцип — не копировать, пока не запишут — используется не только для памяти процессов, но и, например, для дисковых снапшотов: copy-on-write в формате qcow2 устроен по той же идее, только применён не к страницам RAM, а к блокам диска.
Проверить, сколько памяти процесс реально держит эксклюзивно, а сколько всё ещё делит с другими, можно через smaps:
grep -E "Shared_Clean|Shared_Dirty|Private_Clean|Private_Dirty" /proc/<PID>/smaps_rollup
Private_Dirty — это как раз те страницы, которые уже разошлись через COW и принадлежат только этому процессу. А для честного сравнения с другими процессами, не переоценивающего вклад общих страниц, лучше смотреть не на VmRSS, а на VmPSS (proportional set size) — она делит вес разделяемой страницы между всеми держателями:
grep -E "VmRSS|VmPSS|VmHWM" /proc/<PID>/status
Иллюстрация самого момента COW-разрыва на C выглядит так — до этой строки страница ещё общая, после неё уже нет:
pid_t pid = fork();
if (pid == 0) {
/* до этой строки страница buffer ещё разделяется с родителем */
buffer[0] = 'x'; /* здесь происходит page fault и копирование страницы */
_exit(0);
}
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Копируются ли файловые дескрипторы через COW так же, как память?
Нет, это отдельный механизм. При fork() дескрипторы дублируются как ссылки на те же открытые файловые описания (похоже на dup()) — процессы делят смещение чтения/записи и флаги файла, но это не связано с таблицами страниц и page fault'ами.
Почему тогда сразу после fork() в top видно, что у потомка RSS почти как у родителя, если ничего не скопировано?
Потому что RSS считает каждую резидентную страницу полностью для каждого держателя, не деля её вес — общие после форка страницы посчитаны в RSS у обоих процессов, хотя физически это одна и та же память. Для честной картины нужен PSS из /proc/<PID>/smaps_rollup, который делит общую память пропорционально числу владельцев.
Можно ли отключить COW и всегда копировать честно?
Штатного переключателя нет — COW является поведением fork() по умолчанию, и его отключение противоречило бы смыслу вызова. Форсировать раннюю материализацию копий можно лишь косвенно, например записью во все страницы сразу после форка, но это убивает главное преимущество механизма.
А у потоков тоже есть copy-on-write?
Нет, потоки одного процесса и так делят одно адресное пространство напрямую (clone() с флагом CLONE_VM), без отдельных таблиц страниц и без последующего разрыва. Поэтому создание потока дешевле форка, но ценой полного отсутствия изоляции памяти между потоками.
Стоит ли вообще беспокоиться о COW-раздувании памяти на обычном VPS?
Для типичных схем с форком воркеров (nginx, PHP-FPM, preforking-серверы приложений) COW почти всегда чистый выигрыш. Беспокоиться стоит там, где фоновый процесс форкается ради снимка активно изменяемого набора данных в памяти — в такие окна стоит следить за ростом Private_Dirty/PSS и закладывать запас, чтобы скачок из-за COW не столкнулся с решением OOM killer о том, кого из процессов принести в жертву.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →