Копирование при записи - один из тех беззвучных механизмов, на которых держится половина экономики памяти современной системы. Процессы форкаются, библиотеки отображаются, снапшоты делаются, и всё это почти бесплатно, пока кто-нибудь не начнёт писать. Стоит понять, как устроено обещание "поделим страницу, а скопируем только если придётся менять", где Windows применяет этот приём, какие дивиденды он приносит и как им научились злоупотреблять.
Суть механизма и где он работает в системе
Идея проста до элегантности. Когда двум потребителям нужно одно и то же содержимое страницы памяти, система не копирует её дважды. Обеим сторонам выдаётся одинаковая физическая страница, помеченная как доступная только для чтения, и мирно сосуществующие читатели делят её сколь угодно долго. Трюк срабатывает в момент записи: процессор встречает инструкцию записи на страницу "только чтение", генерирует исключение страничной ошибки, и тут включается менеджер памяти. Он аллоцирует новую физическую страницу, копирует в неё содержимое, переписывает таблицу страниц писавшего процесса и повторяет прерванную инструкцию - уже без ошибки. Со стороны программы всё выглядит так, будто память была её собственной всегда.
Карта применений в Windows обширна. При создании процесса страницы образа исполняемого файла и разделяемых библиотек отображаются с копированием при записи: сотни процессов читают одну копию kernel32, а модификация (например, отладочная заплатка в адресном пространстве отлаживаемого процесса) вызывает копию только пострадавшей страницы. Унаследованная память при дублировании процессов, разделяемые секции с флагом copy-on-write, приватные копии маппингов - везде тот же принцип. На этой механике стоит и система создания снапшотов тома (VSS): пока том спокойно живёт, снимок - лишь ссылка на текущие блоки, а цена реальной копии платится только за изменённые блоки.
Размер выигрыша зависит от рабочей нагрузки и считается в лоб. Возьмём процесс с образом на 20 мегабайт, из которых 80 процентов приходится на страницы, общие для всех копий, и 40 таких процессов на сервере терминалов:
Без разделения: 40 * 20 МБ = 800 МБ
С копированием при записи, запись идёт в 5% страниц:
общая часть 1 * 16 МБ = 16 МБ
приватные копии 40 * (4 МБ + 5% от 16 МБ) = 192 МБ
итого = 208 МБ
Экономия = 592 МБ, в 3,8 раза
Если же запись затрагивает половину общих страниц, выигрыш падает до полутора раз, и это та граница, за которой механизм перестаёт окупать стоимость исключений. На сервере терминалов, где десятки сессий гоняют одни и те же приложения, экономия на разделяемых страницах достигает существенной доли всей памяти. На типичной рабочей станции эффект скромнее, но тоже ощутим: ежедневно стартующие копии браузеров и проводников делят между собой гигабайты.
Внутреннее устройство механизма, прототипные записи PTE и счётчики ссылок в базе PFN
Внутреннее устройство механизма достойно отдельной главы. Таблица страниц процесса заполнена PTE - записями, переводящими виртуальные адреса в физические. Для разделяемых страниц Windows создаёт особую прослойку: прототипные PTE, лежащие в специальной области и общие для всех процессов, отобразивших секцию. PTE процесса указывает на прототипную запись, а уже та - на физическую страницу. Заодно у каждой физической страницы есть учётная карточка в базе PFN (page frame number): счётчик ссылок и флаги состояния. Когда пишущий процесс спотыкается о страницу, менеджер памяти по этой карточке видит, сколько ещё читателей остаётся: если писатель последний - страницу можно просто сделать записываемой, без копирования; если читателей много - создаётся копия.
Кусочная картинка того, как отладчик показывает такую страницу, выглядит примерно так:
kd> !pte 00007ff6`a1b2c000
VA 00007ff6a1b2c000
PXE at 00007FFB4F67E000 PPE at 00007FFB4F000000 PDE at 00007FFA00001DC8 PTE at 00007FF800380000
contains 0000000000000063 contains 0000000000000E63 contains 0000000000123863 contains 8A00000012345865
pfn 0 ---DA--KWEV pfn e ---DA--KWEV pfn 123 ---DA--KWEV pfn 12345 -C-DA--K-EV
Флаги в последней колонке и рассказывают историю. Буква C означает copy-on-write, отсутствие W говорит, что запись в страницу сейчас запрещена, V отмечает действительность записи, а K и её отсутствие разделяют ядерный и пользовательский режим. Номер физической страницы в конце, pfn 12345, это тот самый кадр, который делят несколько процессов, и по нему легко проверить разделение: два разных виртуальных адреса с одинаковым pfn и есть общая страница.
Рядом полезны ещё две команды, которые показывают состояние области целиком:
kd> !address 00007ff6`a1b2c000
kd> !vprot 00007ff6`a1b2c000
Первая печатает тип выделения и текущую защиту, у разделяемого образа там будет PAGE_WRITECOPY, вторая показывает историю изменения защиты области. Есть и более тонкие градации, например guard pages и write-watch - механизм слежения за записью, используемый сборщиками мусора и отладочными профилировщиками, что тоже строится на игре с флагами доступа PTE.
Стоимость механизма не нулевая, и опытный разработчик её чувствует в затылке. Каждый первый пробой разделяемой страницы - это исключение, работа менеджера памяти, новая физическая страница: на массово пишущих нагрузках расходы накапливаются. Поэтому механизм object manager'а и менеджера памяти окружён аккуратными оптимизациями: массовые слепки страниц при старте процесса, приоритетное освобождение, агрегирование запросов.
Знаменитые злоупотребления и что из них выросло
Класс проблем, о котором нельзя умолчать, - гонки вокруг момента копирования. Самая известная история в этой области получила в народе имя Dirty COW (родом из мира Linux, но урок общесистемный): гонка между путём копирования и путём сброса страницы позволяла записать в разделяемый файл, на который прав не было. Семейство аналогичных ошибок находилось и вокруг Windows-подсистем: механика "выдать страницу для чтения и скопировать при записи" предполагает корректную сериализацию всех путей, которые могут изменить состояние страницы между проверкой и самой записью, и стоило какой-то ветке кода опоздать с блокировкой - физическая страница модифицировалась там, где иметь такой возможности не предполагалось.
Практическое резюме для защитника звучит так: любой код ядра, работающий с shared-страницами и флагами доступа, обязан быть гиперпараноиком к порядку операций, атомарности переключения флагов и времени жизни ссылок на страницы. Со своей стороны платформа отвечала усилением: ужесточением блокировок в менеджере памяти, проверками целостности при переключении PTE, инструментальными проверками на уровне верификатора.
Для корректного программиста урок иной: механика копирования при записи прозрачна, но не бесплатна, и опираться на неё, не понимая цены первой записи, - значит складывать сюрпризы производительности в долгий ящик.
Как используют копирование при записи себе на пользу, рабочие проектные приёмы
Профи строят на copy-on-write целые оптимизации. Первый приём - отложенное дублирование больших структур: нужна "своя копия" многомегабайтной таблицы, читаемой в 99 процентах случаев? Секция с COW даёт её бесплатно до первой записи. Второй приём - снапшоты состояния внутри приложения: вместо полной копии данных строят отображение структуры через shared memory и позволяют писателям платить только за затронутые страницы, именно так исторически разворачивали некоторые in-memory компоненты и системы теневого копирования. Третий приём - контролируемая изоляция модификаций: отладчик записывает точку останова в память целевого процесса, и благодаря COW эта заплатка не портит страницу библиотеки, разделяемую остальными процессами, - каждый живёт со своей копией.
Организовать это в своём коде можно через проецируемые в память файлы, где доступность механики зависит от способа отображения секции:
HANDLE file = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ, NULL,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
// PAGE_WRITECOPY в секции и FILE_MAP_COPY при отображении
// и дают то самое копирование при записи
HANDLE section = CreateFileMappingW(file, NULL, PAGE_WRITECOPY, 0, 0, NULL);
PVOID base = MapViewOfFile(section, FILE_MAP_COPY, 0, 0, 0);
// первая запись в страницу создаст приватную копию,
// файл на диске при этом останется нетронутым
*((PUCHAR)base + 16) = 0;
Тонкий момент с выбором флага защиты: неверная комбинация либо выключает копирование немым отказом, либо даёт ненужные копии там, где рассчитывали на совместное владение. Классическая ошибка новичка - отобразить секцию как доступную для записи и удивляться, почему объём занятой памяти вдруг удвоился.
Проверка, действительно ли страницы делятся (а не дублируются), меряется проще всего прямым сравнением показателей до и после старта вторичного процесса: счётчик разделяемых и приватных байт в процессах (видимый через Process Explorer и счётчики производительности) показывает, какая доля памяти реально общая, а какая - приватная.
Свежие изменения и связанные механизмы ядра
Механизм не стоит на месте, и знакомство с его соседями помогает правильно выбирать инструмент. Близких по духу механизмов два, и оба строятся на той же идее. Первый - сжатие памяти: вместо выгрузки редко используемых страниц на диск система упаковывает их и держит в специальном хранилище. Второй - дедупликация страниц на серверах с однотипными виртуальными машинами: гипервизоры отыскивают идентичные страницы у разных гостей и сливают их в одну с копированием при записи, технология известна как страничное слияние. Система заимствовала эту идею для фоновой дедупликации памяти, и увидеть её работу можно по псевдопроцессу, который держит сжатые страницы:
Get-Process -Name "Memory Compression" |
Select-Object Name,Id,WorkingSet64,PrivateMemorySize64
Get-Counter '\Memory\Pages/sec','\Memory\Page Reads/sec' -SampleInterval 2 -MaxSamples 5
Смежная тема - кеши записи на уровне файловой системы и VSS, упомянутая выше. Связка "копируй блоки при первой записи в охраняемый том" предоставляет дешёвую панель отката: возвращение к прежнему состоянию без полного резервного копирования. Цена механизма ощущается на высокочастотной записи: файловые хранилища с интенсивной перезаписью отключают защиту снимками на горячих томах, потому что двойная запись "сначала в снимок, потом на место" удваивает нагрузку на дисковую подсистему.
Как профилировать эффект копирования при записи на живой системе
Последний штрих - ремесло измерения. Для понимания, какую долю в работе системы занимает копирование страниц, достаточно трёх наблюдений. Первое снимают счётчиками производительности:
Get-Counter '\Process(chrome*)\Page Faults/sec' -SampleInterval 1 -MaxSamples 10
Get-Counter '\Memory\Page Reads/sec','\Memory\Pages Input/sec' -SampleInterval 1 -MaxSamples 10
Счётчик страничных ошибок по процессу, снятый в динамике, показывает фон исключений; рост их числа при инициализации новых процессов указывает на массовые пробои страниц с копированием при записи. Доля разделяемых байт против приватных по ключевым процессам выдаёт эффективность разделения: если "общих" страниц мало при нагрузке, рассчитанной на совместное владение, механика не срабатывает (вероятно, что-то мешает, вплоть до защитных программ, переписывающих страницы библиотек в адресных пространствах и тем самым форсирующих копии). Третье наблюдение - время старта процессов: на стойках, где тысячи короткоживущих процессов рождаются ежечасно, аккуратная настройка COW-ориентированной экономики видна хоть в метриках запуска. Механизмы, пронизывающие систему насквозь, вроде копирования при записи, любят именно такие приземлённые подтверждения - и платят за внимание к себе спокойной работой там, где другие платят полной ценой.
Небольшое замечание о совместной работе с инструментами контроля целостности: защитные механизмы, проверяющие подписи страниц или контролирующие модификации памяти процессов, неизбежно взаимодействуют с механикой копирования при записи, и понимание, что первая запись в разделяемую страницу - легальное и нормальное событие, уберегает оперативную аналитику от ложных тревог, а разработчика - от лишних проверок в коде, где система уже всё сделала сама.
Итоговая мысль проста: механика совместного владения страницами служит тем, кто понимает её цену, и становится источником долгих разборов производительности у тех, кто обращается с ней наугад. Час, вложенный в изучение счётчиков, окупается годами тихой экономии.
Проектная мудрость и границы применимости механизма
Проверенные правила применения складываются в короткий список:
- Там, где модификации редки и читателей много, COW даёт выигрыш почти даром - структуры стоит проектировать с прицелом на разделение;
- Первый пробой страницы дороже обычной записи: в горячих путях, где запись идёт почти всегда, механика не оправдывает себя, и честное копирование вперёд может оказаться прямей и дешевле;
- Замер тонких мест - единственный судья: профилирование через счётчики страничных ошибок и сравнение доли приватных страниц точно покажет, сработал приём или нет;
- В многопоточном коде нельзя забывать о правилах видимости записи: скопированная страница остаётся предметом общих правил синхронизации, и COW не освобождает от барьеров памяти;
- Чужая память - не площадка для сюрпризов: вмешательство в разделяемые области чужих процессов даже "для отладки" оформляется строго по документированным интерфейсам.
Рассказ о копировании при записи - хороший пример того, как одна простая идея прорастает через всю систему: от снапшотов томов до отладчиков, от экономии гигабайтов на сервере терминалов до споров за корректность в самом ядре. Понимание этой механики выручает неожиданно часто: и при настройке параметров памяти сервиса, и в два часа ночи у счётчиков в попытке понять, кто съел память, и при проектировании структур, рассчитанных на десятки тысяч читателей и дюжину писателей.