Операционные системы Управление памятью: виртуальная память, страницы, TLB, swap, аллокаторы
0%

Управление памятью: виртуальная память, страницы, TLB, swap, аллокаторы

Управление памятью: виртуальная память, страницы, TLB, swap, аллокаторы

Прикладной программист живёт в удобной иллюзии: у него есть непрерывный кусок адресов от нуля до огромного числа, malloc() всегда возвращает валидный указатель, а чужие процессы в эту память заглянуть не могут. Иллюзию поддерживает подсистема виртуальной памяти — самая «протекающая» абстракция в ядре. Она протекает ровно в тот момент, когда сервис начинает тормозить без видимой причины, когда RSS растёт, а утечки нет, когда контейнер убивают с кодом 137, а free -h показывает гигабайты свободной памяти.

Эта статья — про то, как устроена изнанка. Мы пройдём путь от бита в виртуальном адресе до физического фрейма DRAM, разберём TLB и huge pages, обработку page fault, вытеснение и swap, OOM, NUMA, а потом поднимемся на уровень malloc() и посмотрим, почему освобождённая память не возвращается операционной системе. Про то, кто именно потребляет эту память, читайте в статье про процессы, потоки и планировщики, а про архитектурный контекст — в архитектурах ядер.

Зачем вообще виртуальная память

Сформулируем проблему с первых принципов. Пусть у нас есть физическая память и несколько программ. Если каждая программа адресует физические байты напрямую (как в MS-DOS или в микроконтроллере без MMU), возникают четыре беды сразу:

  1. Нет изоляции. Любая программа может записать в память любой другой — случайно или намеренно.
  2. Нет переносимости раскладки. Программу нужно линковать под конкретный физический адрес загрузки либо делать полностью позиционно-независимой.
  3. Фрагментация физической памяти. Программе нужен непрерывный мегабайт, а свободны десять кусков по 100 КиБ.
  4. Нельзя выделить больше, чем есть. Суммарные потребности процессов ограничены планкой 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 — отсюда «дыра» посередине адресного пространства и «канонические» адреса.

Четырёхуровневый обход таблиц страниц на x86-64

Корень дерева — физический адрес таблицы 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 байта под узел списка. Разрыв закрывает библиотечный аллокатор.

Раскладка чанка glibc malloc и порядок поиска по бинам

Что важно понимать про malloc() из glibc, то есть про ptmalloc2:

  1. Метаданные лежат прямо перед вашими данными. Заголовок 8–16 байт, выравнивание 16 байт, минимальный чанк 32 байта на x86-64. Миллион malloc(1) — это ~32 МБ, а не 1 МБ. Отсюда же ущерб от переполнения буфера на один байт: вы портите заголовок соседнего чанка, и падение случается позже и в другом месте.
  2. Быстрый путь — tcache, свой у каждого потока, без блокировок; дальше идут fastbins, small/large bins, top chunk. Арен тоже несколько (до 8 × ncpu), чтобы потоки не дрались за один мьютекс: каждая держит собственную кучу по 64 МиБ, и суммарный RSS многопоточного приложения может вырасти в разы. Классическое лекарство для Java/Python в контейнерах — MALLOC_ARENA_MAX=2.
  3. 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.

Источники и что почитать

Что дальше

Мы разобрались, как ядро управляет оперативной памятью и как page cache связывает её с диском. Логичное продолжение — посмотреть, что находится по ту сторону этой связи: как устроено хранение данных, зачем нужен слой VFS, чем журналирование ext4 отличается от copy-on-write в btrfs и ZFS.

Файловые системы: VFS, ext4, XFS, btrfs, ZFS, журналирование

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов