Page fault и отложенные механизмы

Предпосылки: трансляция и изоляция (page table, PTE, present/R-W биты, zero page, адресное пространство), TLB (кеш трансляций), процессы (fork, exec), хранилище (NVMe/HDD latency).

TLB и стоимость трансляции | Файловые системы

malloc(1 * 1024 * 1024 * 1024) — запрос на 1 ГБ. Кажется естественным, что ядро сразу выделило гигабайт физической RAM, обнулило его и вернуло указатель. На машине с 4 ГБ RAM такой malloc должен был бы забрать четверть памяти. Но malloc(1 GB) возвращается за микросекунды, и /proc/<pid>/status показывает, что физическая память процесса (VmRSS) не изменилась ни на байт. Куда делись физические страницы, если в программе есть указатель на «выделенный» гигабайт?

Ответ — в третьей ветке трансляции из общей схемы виртуальной памяти: когда MMU не может завершить трансляцию, он останавливает выполнение и передаёт управление ядру. Эта передача управления называется page fault, и Linux использует её не только для обработки ошибок, но и как точку входа в три отложенные оптимизации, которые делают современные malloc, fork и mmap дешёвыми.

Page fault: когда трансляция не удалась

Когда MMU не может завершить трансляцию — present-бит в PTE равен нулю, или запись нарушает атрибуты (запись в read-only страницу) — процессор генерирует исключение: page fault. Управление передаётся обработчику в ядре, который разбирается в причине.

Есть три типа page fault, различающиеся по источнику и стоимости.

Minor page fault (малый): страница может быть обслужена без дискового ввода-вывода. Чаще всего это означает, что физический фрейм нужно выделить и заполнить нулями (для анонимных страниц) или что фрейм уже в RAM, но PTE ещё не настроен (например, при CoW). Ядро заполняет PTE и возвращает управление. Стоимость: ~1–10 мкс — переход в kernel mode, поиск или выделение фрейма, обновление page table, возврат в user mode. При старте программы minor fault идут потоком: каждая новая страница кода, каждая новая страница стека порождает fault. Для типичного процесса при запуске — тысячи minor fault за первые миллисекунды.

Major page fault (большой): данных в RAM нет, их нужно прочитать с диска. Это происходит в двух ситуациях: страница была вытеснена из RAM в swap (механизм подкачки — ядро сбрасывает редко используемые страницы на диск, чтобы освободить фреймы для активных процессов), или страница принадлежит файлу, отображённому через mmap(), и ещё не была загружена. Ядро находит данные, инициирует чтение с устройства хранения, переключает процесс в состояние ожидания (процессор исполняет другие процессы, пока данные читаются). Стоимость: 1–10 мс на HDD, 50–200 мкс на NVMe SSD. На три-четыре порядка дороже minor fault. Один major page fault на критическом пути HTTP-запроса увеличивает latency с 1 мс до 10 мс — поэтому серверы баз данных стремятся держать рабочий набор в RAM и минимизировать swap.

Illegal access (нелегальный доступ): виртуальный адрес не принадлежит ни одному отображению процесса, или обращение нарушает права (например, попытка исполнения кода на странице с NX-битом). Ядро отправляет процессу сигнал SIGSEGV (segmentation fault). Результат — завершение процесса, если сигнал не перехвачен. Это та самая защита: агент метрик обращается по адресу, принадлежащему веб-серверу, — но в его виртуальном пространстве этого адреса нет, page table не содержит записи, и процессор генерирует fault вместо того, чтобы молча записать в чужую память.

Обработчик page fault в ядре различает эти три случая через связку двух структур: VMA (Virtual Memory Area — описание виртуального региона: начальный адрес, размер, права, ссылка на источник данных) покрывает каждый валидный диапазон виртуального пространства процесса, а PTE хранит текущее состояние конкретной страницы. Алгоритм:

Сначала ядро проверяет, есть ли VMA, покрывающий адрес fault. Поиск идёт через red-black дерево (самобалансирующийся бинарное дерево поиска) по VMA процесса — O(log n). Если VMA нет — адрес не принадлежит процессу, это illegal access → SIGSEGV.

Если VMA есть, но PTE содержит present=0 — ядро смотрит, куда указывает VMA: на файл (нужно читать с диска — major fault) или на анонимную память (выделить фрейм → minor fault).

Если PTE содержит present=1, но права не совпадают (запись в read-only) — ядро проверяет, помечена ли страница как CoW. Если да — minor fault с копированием фрейма. Если нет — illegal access, SIGSEGV.

Три ветки алгоритма раскрываются в три независимых механизма — demand paging, copy-on-write, overcommit — каждый использует page fault как точку входа и работает по-своему.

Demand paging: ленивая загрузка

Когда ядро загружает программу через exec(), оно не читает весь исполняемый файл в RAM. Ядро разбирает ELF-заголовок файла (ELF = Executable and Linkable Format, стандартный формат исполняемых файлов в Linux), определяет секции (Text, Data, BSS) и их виртуальные адреса. Для каждой секции создаётся VMA, ссылающийся на соответствующий участок файла на диске. Но физических фреймов не выделяет. PTE всех страниц содержат present=0.

Первая инструкция программы обращается к виртуальному адресу точки входа (entry point — адрес функции _start, записанный в ELF-заголовке). MMU видит present=0 — page fault. Ядро определяет, что VMA указывает на исполняемый файл, читает соответствующие 4 КБ с диска, выделяет физический фрейм, копирует данные, заполняет PTE (present=1, R/W=0, executable=1) и возвращает управление. Процессор повторяет инструкцию — на этот раз TLB получает валидную трансляцию. Следующие инструкции попадают в ту же страницу — TLB hit, никаких fault. Когда выполнение дойдёт до кода за пределами первых 4 КБ — снова page fault, снова загрузка одной страницы.

На практике программа размером 100 МБ использует только 2–3 МБ кода за всё время работы. Demand paging гарантирует, что в RAM окажутся только эти 2–3 МБ, а остальные 97 МБ останутся на диске. Время запуска тоже выигрывает: вместо чтения 100 МБ с диска (~15–20 мс на NVMe) — чтение одной страницы (~50–200 мкс).

Demand paging работает не только для кода. Анонимные страницы (heap, stack) тоже выделяются лениво: malloc() создаёт VMA, но физический фрейм появляется только при первом обращении. Именно поэтому malloc(1 GB) мгновенный — ядро создаёт виртуальное описание региона, а не 262 144 физических фреймов. Разницу между виртуальным и физическим потреблением видно в /proc/<pid>/status: поле VmSize показывает суммарный размер всех VMA (виртуальная память), а VmRSS — количество физически занятых фреймов (resident set size). Для только что запущенного процесса с malloc(1 GB) VmSize вырастет на гигабайт, а VmRSS — на ноль.

Copy-on-write: fork() без копирования

fork() создаёт дочерний процесс — копию родительского. Эффект: дочерний процесс видит те же данные, но изменения в одном не затрагивают другой. Наивная реализация — скопировать всю физическую память родителя — потребовала бы для процесса с 1 ГБ RSS (Resident Set Size — размер резидентного набора) около 250 мс и 1 ГБ свободной RAM. Это неприемлемо: типичный веб-сервер вызывает fork() при обработке запросов, и задержка в четверть секунды на каждый fork убила бы производительность.

Copy-on-write (CoW, копирование при записи) решает проблему через page table. Идея: не копировать физическую память — копировать только метаданные (page table), а физические фреймы разделять между процессами до тех пор, пока один из них не попытается записать. Запись перехватывается через page fault, и только тогда создаётся физическая копия — одной страницы, а не всей памяти.

Что происходит при fork():

  1. Ядро создаёт новую page table для дочернего процесса — копию page table родителя. Копируются только PTE (записи таблиц), не физические фреймы. Page table для процесса с 1 ГБ памяти — порядка 2 МБ, копирование занимает ~50–100 мкс.

  2. Ядро очищает бит R/W (writable) во всех PTE обоих процессов — и родителя, и ребёнка. Обе page table теперь указывают на одни и те же физические фреймы, но помечены как read-only.

  3. Ядро увеличивает счётчик ссылок (refcount) на каждом физическом фрейме. Refcount хранится в структуре struct page — дескрипторе физического фрейма в ядре.

Сразу после fork() прирост физической памяти — около нуля: все фреймы разделяются между родителем и ребёнком, новых не выделено. При этом VmRSS ребёнка в /proc/<pid>/status покажет значение, близкое к родительскому — RSS учитывает все resident-страницы, включая разделяемые CoW-страницы. Реальное потребление «собственной» памяти ребёнка отражает метрика PSS (Proportional Set Size) из /proc/<pid>/smaps, которая делит разделяемые страницы поровну между процессами.

fork() для процесса с 1 ГБ RSS завершается за ~50–100 мкс — ядро копирует только page table (~2 МБ), не физические данные. Без CoW тот же fork() занял бы 30–500 мс (копирование 1 ГБ при пропускной способности RAM ~2–30 ГБ/с в зависимости от поколения и конфигурации) и потребовал бы 1 ГБ свободной физической памяти.

Когда один из процессов выполняет запись:

  1. Процессор видит: PTE помечен read-only. Запись в read-only страницу — page fault.
  2. Ядро получает управление и проверяет: это CoW-страница (refcount > 1)? Да.
  3. Ядро выделяет новый физический фрейм (4 КБ).
  4. Копирует содержимое старого фрейма в новый — 4 КБ данных, ~100 нс.
  5. Обновляет PTE пишущего процесса: новый фрейм, бит R/W установлен.
  6. Уменьшает refcount старого фрейма. Если refcount стал 1 — оставшийся процесс единоличный владелец фрейма, и R/W-бит его PTE может быть восстановлен без копирования при следующей попытке записи.
fork():
  Родитель PTE          Ребёнок PTE
  +-----------+         +-----------+
  | vpage 0   |--+      | vpage 0   |--+
  | R/W = 0   |  |      | R/W = 0   |  |
  +-----------+  |      +-----------+  |
                 |                     |
                 +---> [ фрейм X ] <---+
                       refcount = 2
 
Запись ребёнком:
  Родитель PTE          Ребёнок PTE
  +-----------+         +-----------+
  | vpage 0   |-----> [ фрейм X ]   | vpage 0   |-----> [ фрейм Y ]
  | R/W = 0   |        refcount=1   | R/W = 1   |       (копия 4 КБ)
  +-----------+                     +-----------+

Если дочерний процесс сразу после fork() вызывает exec() (загружает другую программу), он заменяет всё адресное пространство — ни одной страницы родителя не копируется. Это типичный паттерн: fork() + exec() — стандартный способ запуска нового процесса в Unix, и CoW делает его практически бесплатным.

CoW проявляется и в неожиданных контекстах. Redis выполняет фоновое сохранение (BGSAVE) через fork(): дочерний процесс сериализует данные на диск, а родитель продолжает обрабатывать запросы. Для Redis с 10 ГБ данных page table занимает ~20 МБ, и её копирование при fork() — 500–1000 мкс (скорость зависит от поколения CPU; само копирование — последовательная запись в RAM + TLB shootdown). Пока BGSAVE идёт, клиенты пишут в Redis. Каждая запись порождает CoW-fault: ядро копирует 4 КБ страницу, прежде чем разрешить запись родителю. Если за время BGSAVE клиенты модифицируют 30% данных — 3 ГБ скопируется через CoW. Пиковое потребление: 10 ГБ (оригинал) + 3 ГБ (CoW-копии) = 13 ГБ. На машине с 12 ГБ RAM это вызывает OOM kill. Именно поэтому для Redis рекомендуют держать maxmemory не выше 50–60% от физической RAM — запас под CoW при BGSAVE.

Overcommit: виртуальная память без физической

malloc(1 GB) создаёт виртуальное отображение на 262 144 страницы, но физического фрейма не выделяет (см. задачу выше). Ядро позволяет суммарно выделенной виртуальной памяти быть больше, чем физической RAM + swap — это называется overcommit (избыточное резервирование).

Десять процессов могут запросить по 1 ГБ каждый на машине с 4 ГБ RAM — и это работает, пока не все страницы реально используются. Типичный Java-процесс резервирует виртуальной памяти в 3–5 раз больше, чем его рабочий набор: JVM запрашивает большой непрерывный блок для кучи (heap) через mmap() при старте, но фактически использует только часть. Без overcommit пришлось бы иметь физическую RAM под максимальный размер кучи каждого Java-процесса — даже если реально занята лишь пятая часть.

Overcommit контролируется через /proc/sys/vm/overcommit_memory:

  • 0 (эвристический) — ядро отклоняет явно безумные запросы, например 100 ТБ на машине с 16 ГБ; всё остальное разрешает. По умолчанию.
  • 1 (всегда разрешать) — malloc никогда не вернёт NULL, даже для явно невыполнимых запросов.
  • 2 (строгий) — виртуальная память ограничена swap + настраиваемый процент RAM из overcommit_ratio.

На продуктовых серверах баз данных часто выставляют 2, чтобы malloc возвращал ошибку вместо молчаливого overcommit с последующим OOM kill. PostgreSQL, например, рекомендует overcommit_memory=2 в своей документации — потому что OOM kill процесса postmaster приводит к перезапуску всего кластера.

Проблема наступает, когда суммарное потребление физической памяти превышает RAM + swap. Ядро получает page fault, пытается выделить фрейм — свободных нет. Сначала ядро пытается освободить память: сбрасывает чистые страницы page cache (область RAM с копиями содержимого файлов; чистые = неизменённые, на диске уже есть точно такие же, поэтому фрейм можно отнять без записи), вытесняет редко используемые страницы в swap. Если и это не помогает — вступает OOM killer (Out Of Memory killer): ядро выбирает процесс-«жертву» на основе oom_score (эвристика, учитывающая RSS процесса, его возраст и пользовательскую корректировку oom_score_adj) и завершает его сигналом SIGKILL, освобождая фреймы для остальных. OOM kill — событие, которое пишется в dmesg и syslog, и на продуктовых серверах оно означает инцидент.

Все три вместе: malloc, touch, fork, write

Три механизма — demand paging, CoW, overcommit — работают вместе через общую точку входа (page fault). Последовательность malloc → touch → fork → write задействует каждый из них.

Процесс вызывает malloc(1 GB). Ядро создаёт VMA на 262 144 страниц. Физических фреймов — ноль. RSS = 0 дополнительных байт. Это overcommit: виртуальная память обещана, физическая ещё не выделена.

Процесс записывает один байт: buffer[0] = 'A'. MMU транслирует виртуальный адрес → PTE → present=0 → page fault (minor). Ядро выделяет один фрейм (4 КБ), заполняет нулями, записывает адрес в PTE, ставит present=1, R/W=1. MMU повторяет трансляцию — успешно. RSS вырос на 4 КБ. Это demand paging: физическая страница появилась только в момент первого обращения, через page fault.

Процесс вызывает fork(). Ядро копирует page table (в данном случае — несколько КБ, так как реально выделена одна страница; для полностью заполненного 1 ГБ — ~2 МБ). Все PTE обоих процессов получают R/W=0. Refcount на единственном выделенном фрейме становится 2. RSS ребёнка — 0 дополнительной физической памяти (не считая page table). Это подготовка к CoW: два процесса разделяют одни и те же фреймы, запись каждого перехватывается через fault.

Дочерний процесс записывает байт: buffer[0] = 'B'. MMU → PTE → R/W=0 → page fault. Ядро проверяет: refcount > 1? Да (CoW). Ядро выделяет новый фрейм, копирует 4 КБ из старого, обновляет PTE ребёнка (новый фрейм, R/W=1) и возвращает управление — процессор повторяет инструкцию, и запись 'B' проходит уже в собственный фрейм ребёнка. Refcount старого фрейма = 1 → при следующей попытке записи родителя его PTE получит R/W=1 обратно без копирования. Одна запись одного байта стоила один page fault + одну копию 4 КБ. Это CoW: физическая копия создаётся только под реально записанные страницы.

Итого: после malloc(1 GB) + touch + fork() + write под данные этого гигабайтного блока выделено ровно два физических фрейма — 8 КБ из гигабайтного виртуального пространства (не считая нескольких страниц под саму page table). Все остальные 262 142 страницы не подкреплены физической памятью. Overcommit обещает гигабайт, demand paging откладывает выделение, CoW разделяет страницы до первой записи — все три используют один и тот же механизм page fault как точку входа.

Проверить это можно через /proc/<pid>/status: у родителя VmRSS покажет ~4 КБ (одна страница), у ребёнка — тоже ~4 КБ (одна CoW-копия), при том что VmSize у обоих процессов — порядка 1 ГБ.

От виртуальной памяти к файловым системам

Виртуальная память управляет RAM как набором 4 КБ страниц: выделяет, отображает, разделяет между процессами, вытесняет на диск при нехватке. Когда процесс обращается к данным — виртуальная память гарантирует, что нужная страница окажется в RAM и будет доступна по виртуальному адресу. Это абстракция для чтения и вычислений.

Но данные, записанные в RAM, не переживут перезагрузку. Системный вызов write(fd, buf, size) помещает данные в page cache — область RAM, кэширующую содержимое файлов. Данные появились в RAM, но ещё не на диске. Внезапное отключение питания — и записанные данные потеряны. Чтобы данные дожили до следующей загрузки, нужен механизм, организующий их на устройстве хранения, контролирующий когда и как страницы из page cache сбрасываются на диск — файловая система.

См. также

  • Ruby GC — bitmap marking (флаги в отдельных страницах) как адаптация к CoW после fork(): избегает грязных страниц при trace-разметке в preload-серверах (Unicorn, Puma).

Sources


TLB и стоимость трансляции | Файловые системы