Каждую секунду в вашей операционной системе рождаются десятки новых процессов. Браузер создает изолированные среды для вкладок, командная оболочка запускает утилиты, фоновые службы множатся для параллельной обработки данных. Казалось бы, каждый новый процесс должен получить полную независимую копию памяти родителя, что требует колоссальных затрат времени и ресурсов. Однако существует элегантный трюк, превращающий дорогостоящую операцию в почти мгновенную.

Родительский процесс занимает несколько гигабайт оперативной памяти. Потребуется ли копировать все эти данные при создании дочернего? Интуиция подсказывает, что да. Реальность демонстрирует иное. Операционная система применяет стратегию отложенного копирования, известную как Copy-on-Write. Вместо немедленного дублирования памяти ядро создает иллюзию независимости, на самом деле позволяя процессам совместно использовать одни и те же физические страницы до момента, когда кому-то из них понадобится изменить данные.

Анатомия создания процесса

Системный вызов fork создает новый процесс путем дублирования вызывающего. В традиционной реализации это означало бы полное копирование адресного пространства: стека, кучи, сегментов кода и данных. При размере адресного пространства в 2 гигабайта операция копирования займет сотни миллисекунд, в течение которых процессор будет занят исключительно переносом данных между участками памяти.

Механизм COW радикально меняет этот подход. Когда процесс вызывает fork, ядро создает новую структуру задачи с уникальным идентификатором, но страницы памяти не копируются. Вместо этого таблицы страниц обоих процессов настраиваются так, чтобы указывать на одни и те же физические страницы. Ключевой момент: записи в таблицах страниц помечаются как доступные только для чтения, даже если изначально страницы были доступны на запись.

Структура записи в таблице страниц, PTE, содержит не только физический адрес страницы, но и набор битов флагов. Флаг PTE_W определяет, разрешена ли запись в страницу. При настройке COW этот бит очищается как в родительской, так и в дочерней таблице страниц. Дополнительно используются зарезервированные для программного обеспечения биты, такие как PTE_RSW, для маркировки страниц как подлежащих копированию при записи. Это позволяет обработчику страничных исключений отличить легитимные COW-страницы от действительно защищенных от записи областей памяти.

Балет счетчиков ссылок

Физическая страница памяти может одновременно использоваться множеством процессов через механизм COW. Освобождение такой страницы становится нетривиальной задачей: страница должна оставаться в памяти до тех пор, пока последний процесс не откажется от нее. Решение заключается в поддержании счетчика ссылок для каждой физической страницы.

В ядре Linux эта информация хранится в структуре struct page, которая существует для каждой физической страницы системы. Поле _count содержит общий счетчик ссылок на страницу, включая все типы использования. Поле _mapcount отслеживает количество записей в таблицах страниц, ссылающихся на данную страницу. При создании дочернего процесса через fork счетчик ссылок увеличивается для каждой разделяемой страницы.

Функция kalloc, выделяющая физическую страницу, инициализирует счетчик ссылок значением 1. При настройке COW-разделения функция uvmcopy не выделяет новые страницы, а инкрементирует счетчик существующих. Аналогично, когда процесс завершается или страница заменяется, вызывается функция декремента счетчика. Только когда счетчик достигает нуля, страница возвращается в пул свободных через kfree.

Атомарность операций со счетчиками критична в многопроцессорных системах. Несколько ядер могут одновременно пытаться модифицировать счетчик одной страницы. Использование атомарных операций, таких как atomic_inc и atomic_dec, гарантирует корректность без необходимости блокировок. Эти инструкции транслируются в специальные машинные команды уровня процессора, обеспечивающие атомарное чтение-модификацию-запись.

Момент истины в обработчике исключений

Рано или поздно один из процессов попытается записать в разделяемую страницу. Процессор, обнаружив попытку записи в страницу, помеченную только для чтения, генерирует исключение ошибки страницы. Управление передается обработчику страничных исключений ядра, который должен принять решение о типе возникшей ситуации.

Обработчик, реализованный в функции do_page_fault для архитектуры x86, получает два параметра: адрес, вызвавший исключение, и код ошибки. Младшие биты кода ошибки кодируют природу проблемы. Бит 0 указывает, присутствовала ли страница в памяти. Бит 1 сообщает, была ли попытка записи или чтения. Бит 2 различает режим процессора: пользовательский или ядра.

Для COW-страниц характерна специфическая комбинация: страница присутствует в памяти, произошла попытка записи, PTE помечена только для чтения, но виртуальная область памяти VMA имеет флаг VM_WRITE. Эта комбинация однозначно идентифицирует COW-исключение. Обработчик выделяет новую физическую страницу, копирует содержимое оригинала в новую страницу, обновляет PTE процесса-инициатора, теперь указывая на новую страницу с установленным битом записи.

Критический аспект: после модификации PTE необходимо сбросить соответствующую запись в буфере ассоциативной трансляции адресов. TLB кэширует трансляции виртуальных адресов в физические, и без сброса процессор продолжит использовать устаревшее отображение. Инструкция invlpg сбрасывает конкретную запись TLB, что эффективнее полного сброса через перезагрузку регистра CR3.

Многопроцессорные головоломки

Сброс TLB в многопроцессорной системе требует координации между ядрами. Каждый процессор поддерживает собственный локальный TLB, и изменение таблицы страниц на одном ядре не автоматически отражается в TLB других ядер. Механизм TLB shootdown решает эту проблему через межпроцессорные прерывания.

Когда одно ядро модифицирует PTE, оно отправляет IPI всем остальным ядрам, которые могут иметь устаревшие записи в своих TLB. Получив IPI, каждое ядро выполняет локальный сброс соответствующих записей TLB. Инициатор ожидает подтверждения от всех адресатов перед продолжением работы. Эта синхронизация может занимать несколько тысяч тактов процессора, что делает TLB shootdown дорогостоящей операцией.

Для уменьшения накладных расходов применяются различные оптимизации. Если процесс никогда не выполнялся на конкретном процессорном ядре, его адресное пространство не может быть закэшировано в TLB этого ядра, и IPI можно не отправлять. Битовая маска mm_cpumask отслеживает, на каких процессорах выполнялся данный процесс. При необходимости сброса TLB проверка этой маски позволяет ограничить круг адресатов IPI.

Пакетная обработка множественных изменений таблиц страниц также снижает частоту shootdown. Вместо немедленного сброса TLB после каждого изменения PTE можно накапливать информацию о модифицированных страницах и выполнить один групповой сброс. Структура mmu_gather в Linux реализует эту идею, собирая адреса измененных страниц и выполняя отложенный TLB shootdown при завершении операции.

Оптимизация обработки записи

Не каждое COW-исключение требует полного копирования страницы. Если счетчик ссылок страницы равен единице после декремента в обработчике исключений, это означает, что данный процесс стал единственным владельцем. В таком случае можно просто изменить бит записи в PTE, избежав ненужного копирования.

Эта проверка происходит после декремента счетчика ссылок, но до выделения новой страницы. Логика проста: уменьшить счетчик, проверить результат. Если счетчик стал равен нулю плюс один от текущего процесса, никто больше не ссылается на страницу. Можно установить PTE_W и продолжить выполнение без копирования данных. Экономия составляет не только память, но и процессорное время на операции копирования и выделения.

Функция copyout, используемая для копирования данных из адресного пространства ядра в пространство пользователя, также должна учитывать COW-страницы. Попытка записи в пользовательскую страницу из режима ядра не вызовет страничное исключение обычным путем. Поэтому copyout явно проверяет каждую целевую страницу и, обнаружив COW-маркер, самостоятельно запускает процедуру разделения перед выполнением копирования.

Страницы нулей и ленивая аллокация

Техника COW расширяется на случай выделения новой памяти процессу. Когда программа запрашивает большой блок памяти через malloc, ядро может немедленно зарезервировать виртуальное адресное пространство, но не выделять физические страницы. Вместо этого все страницы нового региона настраиваются для указания на единственную глобальную страницу, заполненную нулями, помеченную как COW.

При первом обращении к любому адресу в зарезервированном регионе происходит страничное исключение. Обработчик распознает ситуацию "страница нулей" и выделяет реальную физическую страницу, инициализированную нулями. Последующие обращения к этой странице работают без дополнительных исключений. Физическая память расходуется только по мере реального использования, позволяя процессам резервировать больше виртуальной памяти, чем доступно физической.

Комбинация COW и demand paging создает мощный механизм управления памятью. Процесс может выделить гигабайты памяти, но если он использует лишь малую часть, физическая память не будет потрачена впустую. Это особенно ценно для приложений с разреженными данными или для ситуаций, когда программа выделяет большие буферы на всякий случай, не используя их полностью.

Взаимодействие с командой exec

Типичный паттерн создания процесса в Unix-подобных системах: fork с последующим exec. Дочерний процесс немедленно заменяет свое адресное пространство новой программой. Без COW вся работа по копированию памяти в fork была бы напрасной, так как exec выбрасывает скопированные страницы.

С механизмом COW сценарий fork-exec становится чрезвычайно эффективным. Fork завершается за микросекунды, создав только структуры процесса и таблицы страниц. Exec освобождает большую часть виртуального адресного пространства, декрементируя счетчики ссылок родительских страниц. Физическое копирование происходит только для тех страниц, которые родитель модифицирует после fork, но до завершения ребенка.

Ядро Linux оптимизирует эту последовательность, запуская дочерний процесс первым после fork. Если ребенок сразу вызовет exec, родитель еще не начнет писать в разделяемые страницы, избегая вообще каких-либо COW-исключений. Эта оптимизация основана на эвристике: вероятность немедленного exec после fork достаточно высока, чтобы оправдать нестандартный порядок планирования.

Темная сторона совместного использования

Механизм COW не лишен подводных камней. Одна из проблем связана с захватом ссылок на пользовательские страницы из ядра через функции get_user_pages. Эта функция получает указатели на физические страницы, ассоциированные с виртуальными адресами пользовательского процесса, увеличивая их счетчики ссылок.

Представим ситуацию: процесс вызывает fork, родитель и ребенок разделяют COW-страницы. Затем ядро захватывает ссылку на одну из этих страниц от имени ребенка через get_user_pages, например, для операции асинхронного ввода-вывода. Позже родитель записывает в эту страницу, вызывая COW-исключение. Родитель получает новую копию, но ядерная подсистема ввода-вывода все еще держит ссылку на оригинальную страницу, которая теперь ассоциирована только с ребенком. Завершение операции ввода-вывода может модифицировать данные не того процесса, что создает проблемы целостности.

Решение этой проблемы, реализованное в современных ядрах, заключается в принудительном разделении COW-страницы при попытке захвата ссылки через get_user_pages. Даже если запрашивается только ссылка для чтения, система создает приватную копию страницы для процесса. Это устраняет возможность возникновения рассинхронизации, хотя и ценой дополнительного копирования.

Влияние на производительность реальных систем

Эффективность COW варьируется в зависимости от паттернов использования памяти. Для сценария fork-exec выигрыш драматичен: процесс создается в десятки раз быстрее по сравнению с полным копированием. Исследования показывают, что время создания процесса сокращается с сотен миллисекунд до единиц микросекунд.

Для приложений, активно работающих с памятью после fork без exec, картина сложнее. Каждая запись в разделяемую страницу вызывает страничное исключение, выделение памяти, копирование данных, обновление таблиц страниц и сброс TLB. Если процессы интенсивно модифицируют большие объемы памяти, накладные расходы на обработку исключений могут превысить выигрыш от отложенного копирования.

База данных Redis использует fork для создания снимков состояния без остановки основного процесса. Дочерний процесс получает COW-копию всей базы данных и сохраняет ее на диск, пока родитель продолжает обслуживать запросы. Производительность основного процесса падает на время существования дочернего из-за накладных расходов на COW-исключения. Оптимизации, такие как предварительное копирование групп страниц с высокой локальностью доступа, могут снизить количество исключений и улучшить ситуацию.

Контейнерные технологии, такие как Docker, активно используют COW на уровне файловой системы и блочных устройств. Образы контейнеров организованы в слои, разделяемые между несколькими контейнерами. Модификации выполняются в верхний записываемый слой, оставляя нижние слои нетронутыми. Это позволяет запускать сотни контейнеров на одном хосте с минимальным дублированием данных.

Copy-on-Write остается одной из фундаментальных техник управления памятью, демонстрирующей, как уровень косвенности способен превратить дорогостоящую операцию в почти бесплатную. Каждый fork, каждый новый процесс, каждая выделенная страница памяти в вашей системе проходят через этот механизм. Невидимая для пользователя оптимизация, заложенная в основание операционной системы, продолжает экономить миллионы циклов процессора и гигабайты памяти ежесекундно.