17. Paging и Swapping
Виртуальная память: от абстракции к страницам
«Компьютер похож на рабочий стол: физическая память — это его столешница, а своп — картотека в подвале. Если вы вместо работы весь день бегаете вверх и вниз по лестнице — поздравляем, вы открыли для себя трэшинг».
— Физическая модель иерархии хранения
В предыдущей статье 16. Copy On Write. Почему fork не копирует всю память сразу мы увидели, как виртуальная память дарит каждому процессу иллюзию единоличного владения всем адресным пространством компьютера.
Но что происходит, когда реальные процессы начинают требовать больше физической оперативной памяти, чем распаяно на материнской плате сервера? Как операционная система управляет этой нехваткой на аппаратном уровне, не позволяя системе рухнуть в первую же секунду?
Ответ лежит в двух взаимосвязанных механизмах ядра: Paging (Постраничная организация памяти) и Swapping (Механизм подкачки страниц на диск).
Для бэкенд-инженера на Go понимание этой механики — граница между стабильным сервисом с предсказуемым p99 latency и ночным кошмаром, когда приложение внезапно перестает отвечать на сетевые запросы, а утилита top показывает 100% загрузку дискового ввода-вывода при нулевом полезном CPU.
Paging: Механизм разбивки памяти
Страничная адресация (Paging) — это фундаментальный архитектурный принцип, разделяющий как виртуальное адресное пространство процесса, так и физическую оперативную память на стандартизированные блоки фиксированного размера:
- Физическая память делится на кадры (Frames).
- Виртуальное пространство процесса делится на страницы (Pages).
На распространенных архитектурах x86-64 и ARM64 базовый размер страницы составляет 4 Килобайта (4096 байт). Современные серверные процессоры также аппаратно поддерживают Huge Pages (большие страницы размером 2 МБ и 1 ГБ), которые радикально сокращают размер таблиц страниц и снижают нагрузку на кэш TLB.
Золотое правило страничной адресации: виртуальная страница не обязана быть непрерывно привязана к физическому кадру RAM. Она может отображаться на любой свободный фрейм в памяти, либо временно находиться в состоянии «отсутствия» (not present), располагаясь на NVMe-диске в swap-разделе.
Page Table и аппаратное преобразование адресов
Когда процессор исполняет машинную инструкцию, обращающуюся по виртуальному адресу, блок MMU (Memory Management Unit) на кристалле CPU преобразует его в физический адрес.
Виртуальный адрес математически разбивается на две части:
- VPN (Virtual Page Number): Индекс в иерархической таблице страниц процесса.
- Offset (Смещение): Младшие 12 бит адреса ($2^{12} = 4096$), указывающие точное смещение байта внутри целевой 4-килобайтной страницы.
ВИРТУАЛЬНЫЙ АДРЕС (48 бит)
┌──────────────────────────────────────┬──────────────────────┐
│ VPN: Индекс в таблице страниц (PTE) │ Offset: 12 бит (0..4095)
└──────────────────┬───────────────────┴──────────┬───────────┘
│ │
▼ │
┌─────────────────────┐ │
│ Таблица страниц PTE │ │
│ Physical Frame # │ │
└──────────┬──────────┘ │
│ │
▼ ▼
┌──────────────────────────────────────┬──────────────────────┐
│ Physical Frame Number │ Offset (Без изменений)
└──────────────────────────────────────┴──────────────────────┘
ФИЗИЧЕСКИЙ АДРЕС В ОПЕРАТИВНОЙ ПАМЯТИ DRAM
MMU находит запись в таблице страниц — Page Table Entry (PTE). Запись PTE хранит:
- Physical Frame Number: Базовый физический адрес кремниевой ячейки в RAM.
- Битовые флаги состояния:
Present: находится ли страница в физической RAM прямо сейчас.R/W: флаг прав доступа (0 — Read-Only, 1 — Read/Write).U/S: уровень привилегий (User-mode Ring 3 или Kernel-mode Ring 0).Accessed: выставляется процессором при любом чтении страницы (используется алгоритмами LRU ядра).Dirty: выставляется процессором при любой модификации данных (сигнал ядру, что страницу нужно сбросить на диск перед выселением).
Если бит Present равен 0, процессор не может продолжить операцию и генерирует исключение — Page Fault.
Page Fault: Когда страница «теряется»
Прерывание Page Fault (исключение вектора #14 в архитектуре x86) — это не аварийный сбой программы, а штатный диспетчерский механизм виртуальной памяти ядра.
Ядро классифицирует сбои страниц на три типа:
- Valid Page Fault (Soft / Minor Page Fault):
Страница легально выделена процессу, но физический фрейм еще не привязан (отложенная аллокация Demand Paging в Go) либо данные уже находятся в памяти в системном дисковом кэше Page Cache. Ядро просто прописывает физический адрес в PTE и ставит битPresent = 1. Задержка: доли микросекунды. - Invalid Page Fault (Hard Page Fault / Ошибка сегментации):
Процесс попытался прочитать или записать по невалидному адресу (разыменованиеnil, выход за границы аллокации). Ядро не находит соответствующей структурыvm_area_structи немедленно прибивает процесс сигналом аварийной остановкиSIGSEGV(Segmentation Violation). - Protection Fault:
Попытка записи в страницу, доступную только для чтения (константные строки, сегмент кода.text, страницы CoW приfork()). Ядро либо инициирует копирование страницы (CoW), либо завершает нарушителя.
sequenceDiagram
participant Process as Go-процесс
participant MMU as Аппаратный MMU
participant Kernel as Ядро Linux (do_page_fault)
participant Disk as NVMe / Swap Partition
participant RAM as Физическая RAM
Process->>MMU: Запись по адресу 0x7FFF...
MMU->>MMU: Проверка PTE: Present == 0!
MMU->>Kernel: Прерывание CPU: Page Fault (#14)
Kernel->>Kernel: Проверка vm_area_struct (адрес легален?)
alt Адрес нелегален
Kernel-->>Process: Сигнал SIGSEGV (Crash)
else Адрес легален (Страница в Swap)
Kernel->>RAM: Поиск свободного фрейма
alt RAM переполнена
Kernel->>Disk: Выгрузка неактивной страницы (Swap Out)
end
Kernel->>Disk: Чтение целевой страницы (Swap In)
Disk-->>RAM: Запись 4 КБ в фрейм
Kernel->>MMU: Обновление PTE: Present = 1
Kernel-->>Process: Возобновление инструкции
end
Swapping: Вытеснение на диск
Когда приложения требуют больше физической оперативной памяти, чем доступно в системе, фоновый демон ядра kswapd активирует механизм вытеснения — Swapping.
Ядро делит страницы памяти на активные и неактивные (через очереди LRU: Active List и Inactive List). Неактивные анонимные страницы процесса (куча, стек) сбрасываются на накопитель в специальный раздел или файл подкачки — Swap (Pagefile), а освободившиеся фреймы RAM отдаются более активным задачам.
Почему Swap убивает производительность бэкенда?
Взгляните на физические задержки оборудования:
- Чтение из RAM: $\sim 60\ext{--}100\ext{ нс}$ (наносекунд).
- Чтение с быстрого NVMe SSD: $\sim 50\ext{--}100\ext{ мкс}$ (микросекунд, в 1 000 раз медленнее!).
- Чтение со старого HDD: $\sim 5\ext{--}10\ext{ мс}$ (миллисекунд, в 100 000 раз медленнее!).
Когда поток обращается к странице, вытесненной в swap, процессор останавливается, а ядро переводит поток в непрерываемый сон (TASK_UNINTERRUPTIBLE).
Для высоконагруженного Go-сервиса, обрабатывающего тысячи запросов в секунду, даже несколько сотен таких вызовов в секунду приводят к катастрофическим задержкам (p99/p999 spikes), накоплению очередей и каскадному падению всего кластера.
Thrashing: Когда память превращается в узкое место
Thrashing (Трэшинг памяти) — это катастрофическое состояние операционной системы, когда ядро тратит 99% всего времени процессора и шины ввода-вывода на непрерывное перекладывание страниц из RAM на диск и обратно, при этом полезная работа приложений падает практически до нуля.
Симптомы наступления Thrashing:
- В утилите
vmstat 1счетчики страниц подкачкиsi(swap in) иso(swap out) исчисляются тысячами и непрерывно растут. - В утилите
iostat -x 1процент утилизации диска%utilподскакивает до 100%, а задержка ввода-выводаawaitвырастает с 0.5 мс до сотен миллисекунд. - Метрика Load Average подскакивает до десятков единиц, но утилизация процессора приложениями стремится к 0%: ядра заблокированы в очереди I/O Wait.
Go и Paging/Swapping: Специфика рантайма
Среда выполнения Go спроектирована с глубоким пониманием механики виртуальной памяти:
- Выделение кучи через
mmap:
Рантайм Go не использует устаревшие вызовыbrk/sbrk. Он резервирует виртуальные арены через системный вызовmmapблоками по 64 МБ. Поэтому виртуальный размер процесса (VSS/VSZ) может быть колоссальным, в то время как реально используемый объем физической памяти (RSS) остается небольшим. - Сборщик мусора GC исторически ничего не знает о swap:
Сборщик мусора Go триггерится соотношением выделенной кучи (параметрGOGC=100), но он не может знать, что ядро Linux уже вытеснило половину неактивной кучи в swap-файл!
Если GC начнет сканировать выгруженные страницы кучи, он спровоцирует лавину Major Page Faults, и сборка мусора растянется с 1 миллисекунды до нескольких секунд. GOMEMLIMIT(Введено в Go 1.19+):
Это главный ответ создателей языка на проблемы Paging и OOM-киллера. ПараметрGOMEMLIMITзадает мягкий потолок памяти. Сборщик мусора начинает работать проактивно и агрессивно ужимать кучу по мере приближения к лимиту контейнера, предотвращая вытеснение страниц в swap и защищая сервис от фатального убийства ядром по OOM.
package main
import (
"fmt"
"runtime/debug"
)
func setupMemoryLimits() {
// Устанавливаем мягкий лимит памяти в 2 ГБ через debug.SetMemoryLimit.
// Это заставляет GC Pacer Go агрессивно очищать память при приближении
// к порогу, исключая деградацию в swap и вылет по OOM-killer!
const memoryLimit = 2 * 1024 * 1024 * 1024 // 2 GiB
debug.SetMemoryLimit(memoryLimit)
// Проверяем текущее значение лимита: SetMemoryLimit с отрицательным аргументом
// ничего не меняет и возвращает текущий лимит (функции debug.MemoryLimit в Go нет)
fmt.Printf("Go runtime Memory Limit: %d bytes\n", debug.SetMemoryLimit(-1))
}
func main() {
setupMemoryLimits()
// Основная логика высоконагруженного бэкенда...
}
Как мониторить и контролировать swap в продакшене
Для Senior/Lead инженера контроль swap и страничных отказов — обязательная часть метрик Service Level Objective (SLO).
1. Системные команды и procfs:
# Просмотр оперативной памяти и swap в человекочитаемом виде
free -h
# Просмотр активных разделов подкачки
swapon -s
# Проверка коэффициента склонности ядра к свопингу (0..100 или 0..200 в новых ядрах)
cat /proc/sys/vm/swappiness
# Полное отключение swap на production-ноде Kubernetes (требует root)
sudo swapoff -a
2. Метрики Prometheus и node_exporter:
В системах мониторинга отслеживают счетчики:
node_vmstat_pswpin/node_vmstat_pswpout: скорость подкачки страниц в секунду. Любое ненулевое значение под нагрузкой — тревожный сигнал.node_vmstat_pgfault/node_vmstat_pgmajfault: соотношение Minor и Major Page Faults. Всплеск Major Faults всегда коррелирует с просадкой p99 latency.
3. Диагностика в Go:
Внутри приложения метрики памяти проверяются через runtime.ReadMemStats():
- Разрыв между
runtime.ReadMemStats().Sys(объем виртуальной памяти, запрошенной у ядра) иruntime.ReadMemStats().HeapAlloc(реально занятой объектами) показывает степень фрагментации и объем пулов арены. - В контейнерах Kubernetes лимиты задаются через
resources.limits.memory. При превышении лимита ядро Linux посылает контейнеру бескомпромиссный сигналSIGKILL(OOMKilled). - Huge Pages и оптимизация: Для latency-critical сервисов (торговые системы, базы данных) включают Huge Pages через вызов
madviseс флагомMADV_HUGEPAGEили запускают процесс с привязкой черезnumactl, снижая TLB misses на 10-30%.
Итог
- Paging делит физическую память на кадры, а виртуальное пространство — на страницы по 4 КБ, устраняя внешнюю фрагментацию RAM.
- Page Table связывает виртуальные страницы с физическими фреймами, а блок MMU кэширует трансляции в сверхбыстром аппаратном кэше TLB.
- Swapping выгружает неактивные анонимные страницы на диск. Из-за колоссальной разницы в задержках между DRAM и SSD активный свопинг неизбежно ввергает систему в Thrashing.
- В продакшене на серверах баз данных и узлах Kubernetes swap отключают (
swapoff -a), либо жестко лимитируют черезvm.swappiness. - В языке Go всегда настраивайте мягкий лимит памяти через переменную окружения
GOMEMLIMITили вызовdebug.SetMemoryLimit, а в метриках мониторинга отслеживайте счетчикиpswpin/pswpoutи Major Page Faults.
Мы разобрали, как ядро оперирует страницами и свопом. Но как прикладные программы создают произвольные отображения файлов на память и делят адресное пространство без системных вызовов read/write? В следующей статье мы разберем мощнейший инструмент POSIX: 18. mmap. Отображение файлов и памяти.