Управление памятью: виртуальная память, страницы, TLB, swap, аллокаторы
Прикладной программист живёт в удобной иллюзии: у него есть непрерывный кусок адресов от нуля до огромного числа, malloc() всегда возвращает валидный указатель, а чужие процессы в эту память заглянуть не могут. Иллюзию поддерживает подсистема виртуальной памяти — самая «протекающая» абстракция в ядре. Она протекает ровно в тот момент, когда сервис начинает тормозить без видимой причины, когда RSS растёт, а утечки нет, когда контейнер убивают с кодом 137, а free -h показывает гигабайты свободной памяти.
Эта статья — про то, как устроена изнанка. Мы пройдём путь от бита в виртуальном адресе до физического фрейма DRAM, разберём TLB и huge pages, обработку page fault, вытеснение и swap, OOM, NUMA, а потом поднимемся на уровень malloc() и посмотрим, почему освобождённая память не возвращается операционной системе. Про то, кто именно потребляет эту память, читайте в статье про процессы, потоки и планировщики, а про архитектурный контекст — в архитектурах ядер.
Зачем вообще виртуальная память
Сформулируем проблему с первых принципов. Пусть у нас есть физическая память и несколько программ. Если каждая программа адресует физические байты напрямую (как в MS-DOS или в микроконтроллере без MMU), возникают четыре беды сразу:
- Нет изоляции. Любая программа может записать в память любой другой — случайно или намеренно.
- Нет переносимости раскладки. Программу нужно линковать под конкретный физический адрес загрузки либо делать полностью позиционно-независимой.
- Фрагментация физической памяти. Программе нужен непрерывный мегабайт, а свободны десять кусков по 100 КиБ.
- Нельзя выделить больше, чем есть. Суммарные потребности процессов ограничены планкой DRAM.
Виртуальная память решает все четыре одним приёмом — вводит уровень косвенности. Каждый процесс работает с виртуальными адресами; аппаратный блок MMU по таблицам, которые заполнило ядро, переводит их в физические. Дальше всё получается почти само: изоляция — просто не кладём в таблицы процесса A страницы процесса B; переносимость — виртуальный адрес загрузки один и тот же для всех, а физические фреймы любые; фрагментация — виртуально непрерывный регион собирается из произвольно разбросанных фреймов; overcommit — можно пообещать память, подставить фрейм только при первом обращении, а холодные страницы выселить на диск.
Плюс бонусы, ради которых виртуальную память часто и держат: разделяемые страницы (одна libc в физической памяти на сотню процессов), copy-on-write при fork(), файловый ввод-вывод через mmap(), защита исполнения (NX-бит), guard-страницы у краёв стека.
Цена — таблицы трансляции, которые нужно хранить, обходить и инвалидировать. Вся дальнейшая инженерия — про то, как сделать эту цену незаметной.
Единица учёта — страница
Транслировать каждый байт отдельно невозможно: таблица была бы размером с память. Поэтому память режется на страницы фиксированного размера, транслируется номер страницы, а смещение внутри проходит насквозь. Узнать размер: getconf PAGE_SIZE (или PAGESIZE — имена различаются между системами). 4 КиБ — исторический выбор x86, живущий с 80386, но другие варианты встречаются постоянно:
| Платформа | Базовый размер страницы | Крупные страницы |
|---|---|---|
| x86-64 (Linux, Windows, FreeBSD) | 4 КиБ | 2 МиБ, 1 ГиБ |
| ARM64 Linux (серверные дистрибутивы) | 4 КиБ (RHEL/Oracle — 64 КиБ) | 2 МиБ, 1 ГиБ (гранула 4 КиБ) |
| Apple Silicon, macOS/iOS | 16 КиБ | 32 МиБ |
| POWER9/POWER10 (Linux) | 64 КиБ | 16 МиБ, 16 ГиБ |
Отсюда первое практическое следствие: никогда не хардкодьте 4096 — пишите sysconf(_SC_PAGESIZE), иначе программа сломается на Apple Silicon и на RHEL для ARM. Второе следствие — гранулярность всего учёта: запрос на 1 байт через mmap() займёт целую страницу, регион на 4097 байт — две. Именно поэтому мелкие аллокации отдают пользовательскому аллокатору, а не ядру.
Трансляция адреса: обход таблиц страниц
На x86-64 в классическом четырёхуровневом режиме используются младшие 48 бит виртуального адреса. Они делятся на четыре индекса по 9 бит и 12-битное смещение. Старшие 16 бит обязаны быть знак-расширением бита 47 — отсюда «дыра» посередине адресного пространства и «канонические» адреса.
Корень дерева — физический адрес таблицы PML4 — лежит в регистре CR3. При переключении контекста между процессами ядро перезаписывает CR3, и адресное пространство меняется целиком, одной инструкцией. Внутри записи (PTE) кроме номера фрейма лежат флаги:
| Бит | Имя | Что значит для программиста |
|---|---|---|
| 0 | P (Present) | 0 — обращение вызовет page fault. Это крючок для всей ленивой логики ядра |
| 1 | R/W | запись разрешена; сброшен у COW-страниц и у .rodata |
| 2 | U/S | доступ из пользовательского режима |
| 5 | A (Accessed) | ставит железо при обращении; ядро использует для LRU |
| 6 | D (Dirty) | ставит железо при записи; страницу нельзя просто выбросить |
| 7 | PS | «здесь трансляция заканчивается» — huge page 2 МиБ или 1 ГиБ |
| 8 | G (Global) | не сбрасывать из TLB при смене CR3 — для ядерных отображений |
| 63 | NX | запрет исполнения; основа W^X и защиты стека |
Обход дерева (page walk) делает железо, но это до четырёх зависимых обращений в память: если кэш промахнулся, одна трансляция стоит сотни циклов. Смягчают это page-walk caches внутри MMU, кэширующие промежуточные уровни, но главный герой — TLB. Пять уровней (LA57, Intel Ice Lake+, Linux с 4.14) добавляют PML5 и расширяют адрес до 57 бит — 128 ПиБ. Ядро включает пятый уровень динамически: если процессу не нужно больше 128 ТиБ, mmap() без хинта никогда не вернёт адрес выше границы 47 бит, чтобы не сломать код, который пакует указатели в 48 бит (JVM, JavaScript-движки с NaN-boxing).
TLB: почему кэш трансляций решает всё
TLB (Translation Lookaside Buffer) — маленький ассоциативный кэш пар «виртуальная страница → физический фрейм». Попадание делает трансляцию бесплатной. Промах запускает page walk.
Порядок величин на современных серверных ядрах: L1 dTLB — порядка 64–100 записей для 4 КиБ, L2 STLB — 1500–3000 записей. Умножаем: 2048 × 4 КиБ ≈ 8 МиБ. Это весь объём памяти, который процессор может адресовать «быстро». Рабочий набор с активным случайным доступом на 40 ГиБ (типичная in-memory база или большой хэш-индекс) не покрывается TLB на два порядка, и каждый второй доступ платит за page walk.
Измеряется это напрямую:
$ perf stat -e dTLB-loads,dTLB-load-misses,dtlb_load_misses.walk_active ./bench
1 402 118 776 dTLB-loads
93 445 201 dTLB-load-misses # 6,66% of all dTLB cache accesses
Шесть процентов промахов при случайном доступе — приговор производительности. Лечится это увеличением покрытия одной записи TLB, то есть huge pages: одна запись на 2 МиБ вместо 512 записей на 4 КиБ. Тот же TLB начинает покрывать 4 ГиБ вместо 8 МиБ.
Второй нюанс — инвалидация. TLB не когерентен между ядрами: если CPU0 поменял таблицы, CPU1 может продолжать пользоваться устаревшей записью. На x86 ядро вынуждено рассылать межпроцессорные прерывания — это называется TLB shootdown.
Практический вывод: массовые munmap()/mprotect() в многопоточном приложении на многоядерной машине стоят дорого и масштабируются плохо. Профиль, где много времени уходит в smp_call_function_many и native_flush_tlb_multi, а /proc/interrupts показывает растущую строку TLB — это именно оно. Типичные виновники: аллокатор с агрессивным возвратом памяти, сборщик мусора, а также JIT, часто меняющий права на страницах кода.
Разница между семействами тут архитектурная, а не «ОС-ная»: ARM64 умеет широковещательные инструкции TLBI ... IS, инвалидирующие TLB на всех ядрах аппаратно, без IPI, — поэтому shootdown-проблема на ARM-серверах заметно мягче (Intel добавил похожий механизм RAR только в свежих поколениях). Третий механизм — PCID/ASID: тег адресного пространства в записи TLB, позволяющий не сбрасывать весь TLB при переключении процессов. Linux использует PCID на x86-64, и он стал критичен после KPTI — изоляции таблиц ядра от Meltdown, которая иначе удваивала число флашей. Проверить: grep -o pcid /proc/cpuinfo | head -1.
Page fault: главный крючок ядра
Все интересные механизмы виртуальной памяти висят на одном событии — обращении к странице, у которой P=0 или нарушены права. Процессор генерирует исключение #PF, кладёт адрес в CR2 и передаёт управление ядру.
Ключевое различие — minor fault (всё решается в памяти, микросекунды) и major fault (нужен ввод-вывод, миллисекунды на HDD, десятки микросекунд на NVMe). Major fault — это блокировка потока, невидимая в профиле пользовательского кода.
Считаем их так:
$ /usr/bin/time -v ./my-service 2>&1 | grep -i faults
Major (requiring I/O) page faults: 12
Minor (reclaiming a frame) page faults: 428913
$ perf stat -e page-faults,major-faults,minor-faults ./my-service
$ ps -o min_flt,maj_flt,cmd -p 4242 # по живому процессу; поля 10 и 12 в /proc/PID/stat
Массовые major faults на прогретом сервисе означают либо swap-in, либо вымывание page cache — и то, и другое лечится диагностикой, а не увеличением потоков.
Ленивое выделение и copy-on-write на практике
// lazy.c — cc -O2 -o lazy lazy.c
// rss_kb() читает второе поле /proc/self/statm (резидентные страницы) и умножает на _SC_PAGESIZE
int main(void) {
const size_t GB = 1UL << 30;
printf("старт: RSS = %ld КиБ\n", rss_kb()); // 1536
// 1. Ядро лишь создаёт VMA — ни одной записи в таблицах страниц не появляется.
char *p = mmap(NULL, GB, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
printf("после mmap 1 ГиБ: RSS = %ld КиБ\n", rss_kb()); // 1536 — не изменился
// 2. Чтение обслуживается ОДНОЙ общей нулевой страницей только для чтения.
volatile long sum = 0;
for (size_t i = 0; i < GB; i += 4096) sum += p[i];
printf("после чтения: RSS = %ld КиБ\n", rss_kb()); // 1664
// 3. Запись — вот теперь по minor fault и по отдельному фрейму на каждую страницу.
for (size_t i = 0; i < GB; i += 4096) p[i] = 1;
printf("после записи: RSS = %ld КиБ\n", rss_kb()); // 1050368
// 4. Возвращаем фреймы ядру, не разрушая отображение.
madvise(p, GB, MADV_DONTNEED);
printf("после DONTNEED: RSS = %ld КиБ\n", rss_kb()); // 1664
return 0;
}
Здесь видно три фундаментальные вещи. Во-первых, mmap() ничего не выделяет — он лишь регистрирует VMA (struct vm_area_struct, диапазон с правами и источником подкачки). Во-вторых, чтение неинициализированной анонимной памяти обслуживается одной общей нулевой страницей: гигабайт «нулей» стоит четыре килобайта. В-третьих, MADV_DONTNEED — это способ вернуть фреймы, не разрушая отображение.
Тонкость, на которой спотыкаются регулярно: MADV_FREE (Linux 4.5+, давно есть во FreeBSD и macOS) помечает страницы как выбрасываемые, но не уменьшает RSS немедленно — ядро заберёт их только под давлением. Go 1.12 перешёл на MADV_FREE, пользователи массово решили, что «Go течёт», и в Go 1.16 значение по умолчанию вернули к MADV_DONTNEED именно из-за читаемости метрик.
Copy-on-write виден так же наглядно: после fork() у ребёнка, который ничего не писал, Rss в smaps_rollup равен родительским 200 МиБ, а Pss — ровно вдвое меньше, потому что каждая страница поделена между двумя процессами. Отсюда правило: fork() дешёвый, а вот запись после fork() — нет. Классический продовый анти-паттерн — сборщик мусора, который трогает все объекты в дочернем процессе (Redis BGSAVE при активной записи, fork() в CoW-режиме у CPython с reference counting: подсчёт ссылок пишет в заголовок объекта и разрушает CoW, отсюда gc.freeze() в Instagram-патче).
Адресное пространство процесса
Всё, что процесс «видит», — это список VMA. Он полностью доступен в /proc:
$ cat /proc/self/maps
55d3f9a00000-55d3f9a02000 r--p 00000000 fd:00 1443124 /usr/bin/cat # .rodata заголовков
55d3f9a02000-55d3f9a07000 r-xp 00002000 fd:00 1443124 /usr/bin/cat # .text, исполняемый
55d3f9a07000-55d3f9a09000 r--p 00007000 fd:00 1443124 /usr/bin/cat # .rodata
55d3f9a09000-55d3f9a0b000 rw-p 00008000 fd:00 1443124 /usr/bin/cat # .data + .bss
55d3fb2b1000-55d3fb2d2000 rw-p 00000000 00:00 0 [heap] # brk-куча
7f4c8a000000-7f4c8a028000 r--p 00000000 fd:00 1441802 /usr/lib/libc.so.6
7ffd5e3e0000-7ffd5e401000 rw-p 00000000 00:00 0 [stack]
7ffd5e5f6000-7ffd5e5f8000 r-xp 00000000 00:00 0 [vdso] # быстрые «сисколлы»
Читается это так: диапазон, права (r/w/x и private или shared), смещение в файле, устройство, inode, путь. Отсутствие пути — анонимная память. Куча и стек помечены явно.
Более полезная для отладки версия — /proc/PID/smaps и агрегат smaps_rollup:
$ grep -E '^(Rss|Pss|Private_Dirty|Shared_Clean|Swap|SwapPss|AnonHugePages)' /proc/self/smaps_rollup
Rss: 215436 kB
Pss: 108120 kB
Shared_Clean: 198012 kB
Private_Dirty: 9204 kB
Swap: 1024 kB
SwapPss: 512 kB
AnonHugePages: 2048 kB
Три метрики, которые надо различать. RSS — все резидентные страницы процесса, включая разделяемые: сумма RSS всех процессов сильно больше объёма RAM, потому что libc засчитана каждому. PSS (Proportional Set Size) делит разделяемую страницу между всеми, кто её держит, — единственная метрика, которую осмысленно суммировать. USS (Private_Dirty + Private_Clean) — сколько освободится, если процесс убить, то есть честный ответ на вопрос «сколько он стоит». Читают всё это pmap -X <pid>, smem -tk, ps_mem — все из того же smaps.
Аналоги в других системах: FreeBSD — procstat -v <pid> (и procstat vm); OpenBSD/NetBSD — pmap/procmap; macOS — vmmap <pid>, footprint <pid> (Apple специально ввела «phys_footprint» как честную замену RSS); Windows — VMMap из Sysinternals, где нативно разделены Reserved / Committed / Private / Shareable / Shared WS.
Overcommit: почему malloc почти никогда не возвращает NULL
Linux по умолчанию раздаёт обещаний больше, чем имеет памяти, — это overcommit. Управляется двумя ручками:
$ sysctl vm.overcommit_memory vm.overcommit_ratio
vm.overcommit_memory = 0 # 0 — эвристика, 1 — всегда разрешать, 2 — строгий учёт
vm.overcommit_ratio = 50 # для режима 2: лимит = swap + ratio% RAM
$ grep -E 'Commit' /proc/meminfo
CommitLimit: 16244628 kB # сколько можно пообещать при overcommit_memory=2
Committed_AS: 28914232 kB # сколько уже пообещали (может быть больше лимита при режиме 0/1)
Последствия важные. В режиме 0/1 malloc() возвращает NULL крайне редко — проверять результат всё равно обязательно (RLIMIT_AS, исчерпание адресного пространства), но полагаться на NULL как на сигнал «память кончилась» нельзя. Обещание превращается в долг при первой записи, поэтому программа падает не там, где просила память, а там, где до неё дотронулась. Режим vm.overcommit_memory=2 даёт честный отказ вместо OOM-killer — то, что нужно БД и системам, где отказ надо обработать; цена в том, что разреженные структуры (JVM-heap, Go-arena, shadow memory у ASan) резервируют терабайты виртуального пространства и в строгом режиме упираются в CommitLimit.
Здесь семейства ОС расходятся принципиально. Windows NT overcommit не делает вообще: VirtualAlloc(MEM_RESERVE) резервирует адреса бесплатно, но MEM_COMMIT списывает с системного «commit charge» (RAM + pagefile), и если бюджета нет — вызов честно вернёт ошибку. Именно поэтому в Windows page file нужен, даже когда памяти много: он расширяет коммит-бюджет. FreeBSD overcommit делает (vm.overcommit есть, но по смыслу это про учёт swap), и при исчерпании памяти pageout-демон убивает крупнейший процесс с сообщением pid N (name), jid 0, uid 0, was killed: out of swap space. OpenBSD исторически консервативнее и учитывает swap строже. macOS/XNU overcommit делает, swap-файлы создаёт динамически в /var/vm и вместо убийства процессов на десктопе сначала включает сжатие памяти.
Жизненный цикл страницы и вытеснение
Физическая память в Linux разбита на списки LRU: активные/неактивные, отдельно для анонимных и файловых страниц. Всё вытеснение — это перемещения по этому графу.
Из этой картинки следует главный вывод про swap, противоречащий народной мудрости «swap надо отключать». Чистую файловую страницу выбросить бесплатно — копия уже на диске. Анонимную страницу выбросить нельзя вообще — если нет swap, её некуда деть. Значит, система без swap под давлением памяти может отбирать только файловые страницы, а это в первую очередь страницы исполняемого кода и mmap-нутых библиотек. Дальше классика: код выселили — сразу major fault — снова прочитали с диска — снова выселили. Система не свопится, но встаёт колом, а вместо аккуратного вытеснения холодных данных получает thrashing по коду. Поэтому небольшой swap полезен даже на машине с 512 ГиБ RAM: он даёт ядру возможность убрать реально холодные анонимные страницы.
Управление агрессивностью:
$ sysctl vm.swappiness # 0..200 начиная с 5.8; >100 — swap важнее кэша
vm.swappiness = 60 # относительная цена вытеснения анонимных страниц против файловых:
# 60 — поровну; 10 — «сначала кэш»; 0 — «только если нечего больше»
$ sysctl vm.vfs_cache_pressure # охота выбрасывать dentry/inode-кэш, 100 = базово
$ grep -E 'pgscan|pgsteal|allocstall|pswpin|pswpout' /proc/vmstat
allocstall_* — счётчик direct reclaim: аллокация не нашла свободных страниц и вынуждена сама заняться вытеснением, прямо в контексте вашего потока. Это латентность, приезжающая внутрь запроса. Здоровая система вытесняет фоновым kswapd (он просыпается на watermark low и работает до high), больная — упирается в direct reclaim. Смотреть watermarks: cat /proc/zoneinfo | grep -A1 'pages free'.
Современная замена классическому LRU — MGLRU (Multi-Gen LRU, в ядре с 6.1, ручка /sys/kernel/mm/lru_gen/enabled), заметно точнее отделяющая горячие страницы от холодных. А главный инструмент для мониторинга — PSI (Pressure Stall Information):
$ cat /proc/pressure/memory
some avg10=0.00 avg60=0.11 avg300=0.05 total=1284512 # хотя бы одна задача стояла из-за памяти
full avg10=0.00 avg60=0.00 avg300=0.00 total=45120 # стояли все — система уже в клинче
PSI отвечает на вопрос «нам уже плохо?» лучше любой метрики свободной памяти, и именно на нём построен systemd-oomd, убивающий сервисы раньше, чем это сделает ядро.
Swap на практике: zram и zswap
$ swapon --show
NAME TYPE SIZE USED PRIO
/swapfile file 8G 512M -2
/dev/zram0 partition 4G 1.2G 100
# swap-файл вместо раздела — проще менять размер:
$ sudo fallocate -l 8G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
zram — блочное устройство в RAM со сжатием, используемое как swap: данные не покидают память, но занимают в 2–4 раза меньше (дефолт в Fedora, ChromeOS, Android). zswap — сжимающий кэш перед реальным swap-устройством: горячее держится сжатым в RAM, холодное дописывается на диск. Ближайший аналог у Apple — compressed memory в XNU (с OS X 10.9): вместо записи на диск страница сжимается алгоритмом WKdm и остаётся в RAM. Смотреть через vm_stat (строки Pages occupied by compressor, Decompressions) или в Activity Monitor. Windows 10+ делает то же самое процессом «Memory Compression». FreeBSD исторически идёт классическим путём (swap-pager + Laundry-очередь, добавленную в 11.0 как отдельную стадию между Inactive и записью на диск): top на FreeBSD показывает Wired / Active / Inactive / Laundry / Buf / Free, и понимать эти категории надо иначе, чем линуксовые.
OOM: кого и почему убивают
Если reclaim не справился, ядро вызывает OOM-killer. Он выбирает жертву по oom_score — грубо, по объёму памяти, с поправкой на oom_score_adj:
$ cat /proc/4242/oom_score_adj # -1000 .. 1000; -1000 = не трогать никогда
0
$ echo -500 | sudo tee /proc/4242/oom_score_adj
$ dmesg -T | grep -i 'killed process'
[Fri Jul 17 03:14:07 2026] Out of memory: Killed process 4242 (java) total-vm:9812344kB,
anon-rss:8123120kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:16420kB oom_score_adj:0
В контейнерах чаще срабатывает не глобальный, а cgroup-OOM — при превышении memory.max. Отсюда знаменитый exit code 137 (128 + SIGKILL):
$ cd /sys/fs/cgroup/system.slice/myapp.service/
$ cat memory.max memory.high memory.current # жёсткий предел, мягкий (троттлинг), текущее
$ cat memory.stat | head -20 # разбивка: anon, file, slab, sock, ...
$ cat memory.events
low 0
high 142 # сколько раз упирались в мягкий предел — ранний сигнал
max 3
oom 1
oom_kill 1
Практические рекомендации: ставьте memory.high ниже memory.max, чтобы приложение сначала притормозили, а вы увидели растущий счётчик high — это ранний сигнал, а не посмертный; memory.min/memory.low защищают критичные сервисы от вытеснения при общем давлении; memory.oom.group=1 убивает всю cgroup целиком, когда полуживой процесс хуже мёртвого. И помните, что RSS процесса на JVM/Go включает не только кучу рантайма, но и метапространство, стеки потоков, direct-буферы, нативные библиотеки и фрагментацию аллокатора — лимит контейнера всегда должен быть с запасом 20–30% над heap.
Подробнее про cgroups как механизм изоляции — в статье про безопасность и изоляцию.
Huge pages: когда помогают, когда вредят
Два разных механизма, которые постоянно путают. hugetlbfs — статически зарезервированный пул больших страниц: память вынимается из общего оборота и живёт отдельно. THP (Transparent Huge Pages) — ядро само склеивает 512 подряд идущих 4 КиБ-страниц в 2 МиБ.
$ sysctl vm.nr_hugepages=1024 # hugetlbfs: 1024 × 2 МиБ = 2 ГиБ из оборота
$ grep Huge /proc/meminfo
AnonHugePages: 122880 kB # это THP
HugePages_Total: 1024
HugePages_Free: 512
Hugepagesize: 2048 kB
$ cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] never
$ cat /sys/kernel/mm/transparent_hugepage/defrag
always defer defer+madvise [madvise] never
Пул hugetlbfs используют через mmap(..., MAP_HUGETLB | MAP_HUGE_2MB, ...) или файлы на смонтированной hugetlbfs — так работают Oracle, PostgreSQL (huge_pages = try|on), DPDK, QEMU для гостевой памяти. THP даёт бесплатный выигрыш на аналитике, симуляциях, JVM с большой кучей. И THP же — источник классических латентных аварий: при defrag=always аллокация может синхронно запустить уплотнение памяти (compaction), и вместо микросекунд поток встаёт на десятки миллисекунд. Плюс khugepaged фоново собирает страницы, потребляя CPU. Плюс раздувание RSS: если приложение реально использует 40 КиБ из 2 МиБ-области, зарезервированы всё равно 2 МиБ.
Поэтому MongoDB, Redis, Couchbase, а также многие БД официально требуют never или madvise. Redis, например, предупреждает в логе, потому что THP сильно ухудшает latency при BGSAVE (CoW копирует не 4 КиБ, а сразу 2 МиБ на каждую записанную страницу).
Осознанный выбор: madvise + явный madvise(ptr, len, MADV_HUGEPAGE) в тех регионах, где вы точно знаете, что будет плотный доступ. Это лучший компромисс между выигрышем на TLB и непредсказуемыми паузами. Проверять эффект — perf stat -e dtlb_load_misses.walk_active ./bench при THP=never против THP=always плюс grep AnonHugePages /proc/<pid>/smaps_rollup, чтобы убедиться, что THP вообще применились.
NUMA: память не одинаково далека
На многосокетных серверах у каждого сокета своя планка памяти. Доступ к «чужой» памяти идёт через межпроцессорную шину — латентность выше в полтора-два раза, пропускная способность ниже.
$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0-15,32-47 node 0 size: 128654 MB node 0 free: 41230 MB
node 1 cpus: 16-31,48-63 node 1 size: 129012 MB node 1 free: 88121 MB
node distances:
node 0 1
0: 10 21 # 21 против 10 — доступ к соседнему узлу в 2.1 раза «дальше»
1: 21 10
$ numastat -p 4242 # где реально лежат страницы процесса
$ numactl --cpunodebind=0 --membind=0 ./my-service # прибить к узлу
Политика по умолчанию — first-touch: страница выделяется на узле того ядра, которое первым к ней обратилось. Отсюда практическое правило для многопоточных приложений: инициализируйте данные тем же потоком, который потом будет с ними работать. Классическая ошибка — один поток инициализирует весь массив в начале (вся память уезжает на узел 0), а потом с ним работают 64 потока на обоих сокетах.
Ядро умеет чинить это на лету (kernel.numa_balancing=1), периодически снимая PTE и мигрируя страницы к потребителю, но эта миграция сама стоит page fault’ов, и на латентно-чувствительных сервисах её нередко выключают, заменяя явным закреплением через numactl или mbind(2).
Аллокаторы ядра
Ядро не может пользоваться malloc() — оно его реализует. Полная лестница слоёв выглядит так:
| Слой | Единица | Кто раздаёт | Где смотреть |
|---|---|---|---|
| Железо | страница 4 КиБ / 2 МиБ | MMU, TLB | perf stat -e dTLB-* |
| Ядро, физические страницы | блоки 2^n страниц | buddy allocator | /proc/buddyinfo |
| Ядро, объекты | объект фиксированного размера | SLUB (kmalloc), vmalloc |
slabtop, /proc/slabinfo |
| Пользователь, библиотека | байты | ptmalloc2 / jemalloc / tcmalloc | malloc_stats(3), MALLOC_CONF |
| Рантайм языка | объект | GC в JVM/Go/.NET, refcount в CPython | профилировщики рантайма |
Buddy allocator раздаёт блоки физических страниц размерами степеней двойки (порядки 0..10, то есть от 4 КиБ до 4 МиБ). Освобождённый блок пытается слиться с «братом» — соседним блоком того же порядка. Это даёт O(log N) на разбиение/слияние и естественную борьбу с внешней фрагментацией.
$ cat /proc/buddyinfo
Node 0, zone Normal 15423 8211 3120 1044 412 128 41 12 3 0 0
# ^order0 ^1 ^2 ... ^order10
Если правые колонки — нули, крупные непрерывные блоки кончились. Драйвер, которому нужен физически непрерывный DMA-буфер, начнёт получать -ENOMEM, а THP перестанут выделяться. Лечится уплотнением: echo 1 > /proc/sys/vm/compact_memory или фоновым kcompactd. Индекс фрагментации виден в /sys/kernel/debug/extfrag/extfrag_index.
SLUB — аллокатор объектов поверх buddy. Он режет страницу на объекты одного размера (slab) и держит per-CPU кэши, чтобы kmalloc() в горячем пути был почти без блокировок. Идея из статьи Джеффа Бонвика про slab-аллокатор Solaris (1994) — она же родила UMA во FreeBSD.
$ sudo slabtop -o | head -5
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
412224 398011 96% 0,19K 19632 21 78528K dentry
198450 191234 96% 1,06K 13230 15 211680K ext4_inode_cache
Именно так ловятся утечки в ядре и драйверах: растущая строка в slabtop при стабильном пользовательском RSS. Заодно видно, куда девается память при миллионах файлов — в dentry и inode_cache. Разница между интерфейсами: kmalloc() даёт физически непрерывную память (годится для DMA), vmalloc() — только виртуально непрерывную (для больших буферов), alloc_pages() — прямо страницы у buddy.
Аналоги: FreeBSD — UMA (vmstat -z, vmstat -m), NetBSD и OpenBSD — UVM (Charles Cranor, 1998; vmstat -s, sysctl vm.uvmexp), Solaris/illumos — оригинальный slab Бонвика (kstat -n ..., ::kmastat в mdb), Windows NT — пулы (NonPagedPool/PagedPool, теги пулов и poolmon), XNU — зоны Mach (zprint).
Аллокаторы пространства пользователя
Ядро выдаёт память страницами; программе нужны 24 байта под узел списка. Разрыв закрывает библиотечный аллокатор.
Что важно понимать про malloc() из glibc, то есть про ptmalloc2:
- Метаданные лежат прямо перед вашими данными. Заголовок 8–16 байт, выравнивание 16 байт, минимальный чанк 32 байта на x86-64. Миллион
malloc(1)— это ~32 МБ, а не 1 МБ. Отсюда же ущерб от переполнения буфера на один байт: вы портите заголовок соседнего чанка, и падение случается позже и в другом месте. - Быстрый путь —
tcache, свой у каждого потока, без блокировок; дальше идут fastbins, small/large bins, top chunk. Арен тоже несколько (до8 × ncpu), чтобы потоки не дрались за один мьютекс: каждая держит собственную кучу по 64 МиБ, и суммарный RSS многопоточного приложения может вырасти в разы. Классическое лекарство для Java/Python в контейнерах —MALLOC_ARENA_MAX=2. free()почти никогда не возвращает память ОС. Возврат возможен только с края кучи (brkвниз, порогM_TRIM_THRESHOLD) или если чанк изначально был получен черезmmap(M_MMAP_THRESHOLD— 128 КиБ, динамически подстраивается до 32 МиБ). Один живой объект на вершине кучи держит всю кучу.
Подкрутить это можно без пересборки: MALLOC_ARENA_MAX=2 MALLOC_TRIM_THRESHOLD_=131072 ./my-service, а изнутри программы — через mallopt(3), malloc_trim(3), malloc_stats(3) и malloc_info(3).
Когда универсальный аллокатор не нужен, пишут свой. Bump-аллокатор — минимальный пример, который стоит написать один раз, чтобы прочувствовать компромисс:
// Арена: O(1) на выделение, освобождение — только всей арены сразу.
// Идеально для «пачки объектов одного времени жизни»: разбор запроса, кадр рендера, узлы AST.
typedef struct { char *base, *cur, *end; } Arena;
static int arena_init(Arena *a, size_t cap) {
// MAP_NORESERVE: берём виртуальные адреса, не тратя коммит-бюджет;
// физические фреймы придут лениво, по page fault.
a->base = mmap(NULL, cap, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE, -1, 0);
if (a->base == MAP_FAILED) return -1;
a->cur = a->base; a->end = a->base + cap;
return 0;
}
static void *arena_alloc(Arena *a, size_t n, size_t align) {
uintptr_t p = ((uintptr_t)a->cur + (align - 1)) & ~(uintptr_t)(align - 1);
if ((char *)p + n > a->end) return NULL; // арена кончилась — растим или сдаёмся
a->cur = (char *)p + n;
return (void *)p; // ни заголовков, ни бинов, ни блокировок
}
static void arena_reset(Arena *a) { // RSS падает мгновенно, отображение цело
madvise(a->base, (size_t)(a->end - a->base), MADV_DONTNEED);
a->cur = a->base;
}
Сложность: выделение O(1) без блокировок и без метаданных, сброс всей арены — O(1) плюс один madvise. Плата — нельзя освободить отдельный объект. Ровно этот компромисс лежит в основе региональных аллокаторов в компиляторах (LLVM BumpPtrAllocator), в arena API Protobuf и в per-request аллокаторах веб-серверов.
Обзор альтернатив системному malloc:
| Аллокатор | Сильная сторона | Где применяют |
|---|---|---|
| glibc ptmalloc2 | универсальность, «просто есть» | дефолт Linux |
| jemalloc | предсказуемая фрагментация, богатая интроспекция (MALLOC_CONF, профилирование кучи) |
системный malloc во FreeBSD; Rust до 1.32; Redis, Cassandra, ClickHouse |
| tcmalloc | per-CPU кэши, максимальная скорость на многоядерных | сервисы Google, Chrome |
| mimalloc | free-list sharding, компактность, отличная многопоточность | .NET, Deno, современные C++-проекты |
| OpenBSD malloc | безопасность: рандомизация, guard-страницы, junk-заполнение, отдельные страницы под метаданные | дефолт OpenBSD |
| macOS libmalloc | nano-зона для объектов < 256 Б + magazine-аллокатор | дефолт macOS/iOS |
| NT Heap / Segment Heap | LFH, отдельные политики под UWP-приложения | Windows |
Подключение без пересборки:
$ LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 \
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,prof:true ./my-service
Философия OpenBSD тут заслуживает отдельного слова: их malloc намеренно жертвует скоростью ради того, чтобы ошибки памяти падали сразу и громко, а не через час в другом месте. /etc/malloc.conf (символическая ссылка с опциями вроде S — «все проверки») превращает систему в постоянно включённый детектор use-after-free. Это прямое продолжение общей философии проекта — подробнее в статье про семейство BSD.
Типичные заблуждения
- «Свободной памяти почти нет — надо докупать RAM». В
free -hсмотрите не наfree, а наavailable: page cache — это полезно занятая память, она отдаётся мгновенно. Пустая RAM — потраченные деньги. - «RSS = потребление моей программы». RSS считает разделяемые библиотеки и page cache от
mmap-нутых файлов. Суммировать RSS процессов бессмысленно — считайте PSS/USS. - «Утечка: RSS растёт, а valgrind ничего не находит». Скорее всего, это фрагментация аллокатора или разросшиеся арены glibc. Проверьте
MALLOC_ARENA_MAX=2,malloc_trim(0)по таймеру или переключитесь на jemalloc с профилированием кучи. - «
free()вернул память системе». Нет: он вернул её аллокатору. СмотритеM_TRIM_THRESHOLDиM_MMAP_THRESHOLD. - «Swap — зло, надо выключить». Без swap ядро под давлением вытесняет код и mmap-файлы, и латентность деградирует сильнее. Правильный подход — небольшой swap плюс мониторинг PSI, а на десктопах и в контейнерах — zram/zswap.
- «Huge pages всегда быстрее». При
defrag=alwaysсинхронное уплотнение даёт паузы в десятки миллисекунд, а при разреженном доступе THP раздувают RSS. Используйтеmadvise. - «Проверять
malloc()на NULL не нужно — при overcommit его не бывает». Бывает:RLIMIT_AS, cgroup-лимиты,vm.overcommit_memory=2, исчерпание адресного пространства на 32 битах. - «mmap файла быстрее, чем read()». Не всегда:
mmapэкономит копирование, но платит page fault’ами (по одному на страницу безMAP_POPULATE), TLB-давлением и дорогимmunmapс shootdown. На потоковом чтенииread()с большим буфером обычно выигрывает;mmapсилён на случайном доступе к горячим данным.
Диагностика: практический чеклист
# 1. Общая картина: si/so в vmstat — реальный своп-трафик, PSI — метрика для алертов
$ free -h; vmstat 1 5; cat /proc/pressure/memory; head -30 /proc/meminfo
# 2. Кто потребляет (smem честнее ps, потому что считает PSS)
$ ps -eo pid,comm,rss,vsz --sort=-rss | head; smem -tk -s pss; pmap -X <pid> | tail -1
# 3. Конкретный процесс
$ cat /proc/<pid>/smaps_rollup
$ grep -E 'VmRSS|RssAnon|RssFile|RssShmem|VmSwap|VmPTE' /proc/<pid>/status
$ perf stat -e page-faults,major-faults,dTLB-load-misses -p <pid> -- sleep 10
# 4. Профилирование аллокаций (ASan/LSan быстрее valgrind в разы)
$ heaptrack ./my-service && heaptrack_gui heaptrack.my-service.*.gz
$ valgrind --tool=massif ./my-service && ms_print massif.out.*
$ cc -fsanitize=address,leak -g ...
# 5. Ядро и трассировка
$ sudo slabtop -o
$ sudo bpftrace -e 'tracepoint:exceptions:page_fault_user { @[comm] = count(); }'
$ sudo bpftrace -e 'kprobe:do_swap_page { @[comm] = count(); }' # кто свопится
$ grep -E 'pgmajfault|pgsteal|allocstall|compact_stall' /proc/vmstat
Аналоги на других системах: FreeBSD — top -o res, vmstat -z, procstat -v, DTrace; macOS — vm_stat, footprint, vmmap, leaks, malloc_history, Instruments; Windows — VMMap, RAMMap, PerfMon (счётчики Memory\Available MBytes, Memory\Pages/sec, Process\Working Set - Private). Общая канва про инструменты наблюдаемости — в статье про наблюдаемость и производительность.
Как это применяют в проде
- Базы данных. Выделяют shared buffers через hugetlbfs, отключают THP, ставят
vm.swappinessнизким, включаютvm.overcommit_memory=2там, где нужен предсказуемый отказ. PostgreSQL при этом сознательно опирается на page cache ОС — двойное кэширование хуже, чем кажется, но переносимость важнее. - Контейнеры и Kubernetes.
requests/limitsпревращаются вmemory.minиmemory.max. Limit без запаса над кучей рантайма = OOMKill. Мониторить надоcontainer_memory_working_set_bytes(этоmemory.currentминус неактивный файловый кэш), а не RSS. - Управляемые рантаймы.
-Xmxограничивает только кучу JVM: реальный RSS = heap + metaspace + code cache + стеки потоков (-Xss × N) + direct buffers + GC-структуры + фрагментация malloc, отсюда-XX:MaxRAMPercentageвместо фиксированного-Xmxв контейнерах и-XX:+AlwaysPreTouchдля предсказуемости. У Go рантайм резервирует огромные виртуальные регионы (VSZв десятки гигабайт — норма) и возвращает память черезMADV_DONTNEED, аGOMEMLIMIT(1.19+) — мягкий предел, заставляющий GC работать чаще вместо OOM. - Latency-критичные системы.
mlockall(MCL_CURRENT|MCL_FUTURE)запрещает вытеснение (нуженRLIMIT_MEMLOCK), плюс прогрев страниц на старте, плюсnumactlдля закрепления, плюсMADV_HUGEPAGEна горячих регионах. - Безопасность. ASLR (
/proc/sys/kernel/randomize_va_space=2), NX/W^X, guard-страницы,mprotect(PROT_NONE)вокруг чувствительных буферов,mlockдля ключей, чтобы они не утекли в swap-файл, иMADV_DONTDUMP, чтобы не попали в core dump.
Мини-итог
- Виртуальная память — уровень косвенности между адресом программы и фреймом DRAM; всё остальное построено на нём. Трансляция стоит до четырёх обращений в память, спасает TLB, а покрытие TLB расширяют huge pages. Инвалидация TLB на многоядерных x86 требует IPI и масштабируется плохо.
- Page fault — универсальный крючок: ленивое выделение, COW, подкачка файлов, swap-in. Различайте minor и major.
- Linux по умолчанию раздаёт обещания (overcommit), Windows NT — нет; отсюда разное поведение при исчерпании памяти: OOM-killer против честной ошибки коммита.
- Вытеснение работает по LRU-спискам; swap нужен не для «расширения RAM», а чтобы ядру было что делать с холодной анонимной памятью. Мониторьте PSI, а не
free. - В ядре память раздают buddy и SLUB, в пространстве пользователя — ptmalloc2/jemalloc/tcmalloc, а поверх — GC и арены. Каждый уровень добавляет своё представление о «занятости», поэтому измеряйте правильные метрики: PSS/USS вместо RSS,
availableвместоfree,working_setвместоcontainer_memory_usage.
Источники и что почитать
- Remzi and Andrea Arpaci-Dusseau, «Operating Systems: Three Easy Pieces», части про виртуализацию памяти — бесплатно: https://pages.cs.wisc.edu/~remzi/OSTEP/. Лучшее введение, если хочется строгости без занудства.
- Mel Gorman, «Understanding the Linux Virtual Memory Manager» — https://www.kernel.org/doc/gorman/. Устарел по версиям, но структура подсистемы объяснена как нигде.
- Документация ядра: https://docs.kernel.org/admin-guide/mm/index.html (THP, hugetlbfs, MGLRU, numa_memory_policy) и https://docs.kernel.org/mm/index.html; плюс
man 2 mmap,man 2 madvise,man 2 mlock,man 3 mallopt,man 5 proc,man 8 numactl. - Ulrich Drepper, «What Every Programmer Should Know About Memory» — https://people.freebsd.org/~lstewart/articles/cpumemory.pdf. Про кэши и TLB, обязательно.
- Jeff Bonwick, «The Slab Allocator», USENIX 1994 — https://www.usenix.org/legacy/publications/library/proceedings/bos94/bonwick.html; Jason Evans, «A Scalable Concurrent malloc(3) Implementation for FreeBSD» — https://www.bsdcan.org/2006/papers/jemalloc.pdf.
- Charles Cranor, «The UVM Virtual Memory System», USENIX 1999 — https://www.usenix.org/legacy/events/usenix99/full_papers/cranor/cranor.pdf (основа VM в NetBSD и OpenBSD); McKusick et al., «The Design and Implementation of the FreeBSD Operating System», глава про виртуальную память.
- Amit Singh, «Mac OS X Internals» и документация Apple по Mach VM: https://developer.apple.com/library/archive/documentation/Performance/Conceptual/ManagingMemory/; Russinovich, Solomon, Ionescu, «Windows Internals» — commit charge, working sets, PFN database.
- Brendan Gregg, «Systems Performance», 2-е издание, глава 7 (Memory), и заметки на https://www.brendangregg.com/memory.html; документация MGLRU — https://docs.kernel.org/admin-guide/mm/multigen_lru.html.
Что дальше
Мы разобрались, как ядро управляет оперативной памятью и как page cache связывает её с диском. Логичное продолжение — посмотреть, что находится по ту сторону этой связи: как устроено хранение данных, зачем нужен слой VFS, чем журналирование ext4 отличается от copy-on-write в btrfs и ZFS.
Файловые системы: VFS, ext4, XFS, btrfs, ZFS, журналирование