Виртуальная память

Почему у каждого процесса своё адресное пространство, как адрес превращается в физический, откуда берутся page faults, почему malloc на гигабайт ничего не стоит, кого убивает OOM killer и что на самом деле показывают RSS и free.

Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.

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

  • Программа работает с виртуальными адресами. Процессор на каждом обращении переводит их в физические по таблице страниц, которую ведёт ядро.
  • Изоляция: у каждого процесса своя таблица. Чужую память нельзя даже адресовать — её просто нет в вашей таблице.
  • Каждый процесс видит всё адресное пространство как своё: один и тот же адрес в двух процессах — разные байты. А общий код библиотек в RAM лежит один раз.

Нажимайте на операции. Код и libc у процессов общие, данные — свои.

Карта адресного пространства

На x86-64 процессу доступны младшие 128 ТБ адресов. В них ядро раскладывает области: код программы, куча, библиотеки и mmap, стек. Между ними — огромные пустые промежутки. Каждая область — VMA со своими правами.

ASLR при каждом запуске сдвигает области на случайное смещение, чтобы эксплойт не знал заранее, где лежат код и стек. Нажмите на область или перезапустите процесс.

Страницы и фреймы

  • Виртуальное пространство нарезано на страницы по 4 КБ, физическая память — на фреймы того же размера.
  • Младшие 12 бит адреса — смещение внутри страницы, оно не меняется. Остальные — номер страницы, его и переводят.
  • Запись таблицы страниц (PTE) — 8 байт: номер фрейма и флаги. Флаги — это рычаги, на которых ядро строит всё остальное: ленивое выделение, swap, copy-on-write, защиту кода.
NX63 номер фрейма51…12 D6 A5 U2 W1 P0
P
present: страница в памяти. 0 — любое обращение вызывает page fault, и ядро решает, что делать.
W
можно писать. Снимают для кода и для страниц после fork — так работает copy-on-write.
U
доступна из пользовательского режима. Страницы ядра — без него.
A
accessed: процессор ставит при обращении. По нему ядро ищет давно не используемые страницы.
D
dirty: была запись. Такую страницу файла надо записать на диск, прежде чем выбросить.
NX
запрет исполнения: данные и стек нельзя выполнить как код.

Четырёхуровневая таблица страниц

Плоская таблица на 236 страниц занимала бы 512 ГБ на процесс. Поэтому она — дерево: 48 бит адреса делятся на четыре индекса по 9 бит и смещение. Каждый уровень — страница из 512 записей. Ветки, где ничего не отображено, просто не создаются.

Корень дерева — в регистре CR3, при переключении процесса ядро меняет его. Введите адрес или выберите готовый и пройдите по шагам.

TLB: кэш переводов

Четыре обращения к памяти ради одного — слишком дорого. Процессор кэширует готовые переводы в TLB: десятки записей в L1 и полторы-две тысячи в L2. Попадание бесплатно, промах — обход таблиц, 20–100+ тактов.

Последовательный проход почти всегда попадает: 512 элементов по 8 байт на страницу. Случайный доступ к большому набору страниц промахивается. Выберите шаблон доступа и размер TLB. Здесь TLB упрощён: полностью ассоциативный, вытеснение LRU.

Huge pages

  • Страница 2 МБ — та же запись TLB покрывает в 512 раз больше памяти, и в дереве на уровень меньше.
  • Явные: hugetlbfs, заранее зарезервированные. Так делают Postgres (huge_pages), JVM (-XX:+UseLargePages), DPDK.
  • Прозрачные (THP): ядро само собирает 2 МБ страницы. Redis и MongoDB советуют их выключать: всплески задержек при сборке и копирование 2 МБ вместо 4 КБ при copy-on-write.

Таблицы страниц — у каждого процесса свои. 100 процессов Postgres, отображающих 32 ГБ общей памяти, — это гигабайты одних таблиц.

Page fault

Если в PTE нет бита P или не хватает прав, процессор прерывает инструкцию и передаёт управление ядру. Ядро смотрит, в какую VMA попал адрес, и решает: дать страницу и повторить инструкцию или послать SIGSEGV.

  • Minor — диск не нужен: выдать обнулённый фрейм, подключить страницу из page cache, скопировать для CoW. Около микросекунды.
  • Major — надо читать с диска или из swap. Поток спит: 100 мкс на NVMe, 10 мс на HDD.

Выберите ситуацию — увидите путь обращения.

Ленивое выделение

malloc(1 ГБ) — это mmap: ядро только создаёт VMA. Таблицы пусты, памяти не занято. Фрейм появляется при первой записи в страницу — через minor fault.

  • VSZ — сколько адресов зарезервировано, RSS — сколько страниц реально в RAM.
  • Go, JVM и V8 резервируют гигабайты адресов заранее, поэтому VSZ у них огромный. Это нормально.
  • Нужна память сразу, без fault'ов на горячем пути — MAP_POPULATE, mlock, -XX:+AlwaysPreTouch.

Проверьте себя

Сервис на Go на машине с 4 ГБ RAM. ps показывает VSZ 20 ГБ и RSS 300 МБ. Что происходит?

VSZ — сумма размеров всех VMA, включая зарезервированные, но ни разу не тронутые области: арены рантайма, стеки потоков, отображённые файлы. Физическую память занимает только RSS. Следить надо за RSS — и за PSS, если процессы делят память. Swap здесь ни при чём: выгруженное видно в VmSwap в /proc/PID/status.

Overcommit и OOM killer

  • Раз память выдаётся лениво, ядро обещает больше, чем есть. vm.overcommit_memory: 0 — отказывает только в явно невозможном, 1 — никогда, 2 — строгий учёт до CommitLimit.
  • Когда обещания пора выполнять, а память кончилась, ядро сначала вытесняет кэш. Не помогло — OOM killer убивает процесс с наибольшим oom_score: обычно самый большой, а не виноватый.
  • malloc почти никогда не возвращает NULL. Вместо ошибки в коде — SIGKILL и строка в dmesg.

RAM 8 ГБ, swap нет. Выделяйте и заполняйте память воркером.

Общие страницы: RSS, PSS, USS

Библиотеки и файлы отображаются через mmap: страница файла лежит в page cache один раз и подключается в таблицы всех процессов. Страницы, унаследованные через fork, тоже общие, пока их не меняют.

  • RSS считает общие страницы в каждом процессе целиком. Сумма RSS по воркерам — сильно завышена.
  • PSS делит общую страницу между всеми, кто её использует. Сумма PSS — реальное потребление.
  • USS — только своё: столько освободится, если процесс убить.

fork и copy-on-write

fork не копирует память. Ребёнок получает копию таблицы страниц, указывающую на те же фреймы, а ядро снимает у всех этих страниц бит W. Первая запись — page fault: ядро копирует одну страницу, и у каждого появляется своя.

Поэтому fork процесса на 10 ГБ занимает миллисекунды — копируется только таблица. Нажмите fork(), затем на страницы, чтобы писать в них.

Copy-on-write в жизни

  • Redis BGSAVE: форк пишет снимок, родитель продолжает принимать запись. Каждая изменённая страница копируется — при интенсивной записи память временно растёт до двух раз. Отсюда советы: оставлять запас RAM, vm.overcommit_memory = 1 (иначе fork большого процесса может не пройти) и выключить THP.
  • Python prefork (gunicorn --preload, uWSGI, multiprocessing): каждое чтение объекта меняет счётчик ссылок, а сборщик мусора пишет в заголовки объектов. Страницы копируются, хотя код «только читает».
  • Node cluster и Java не форкают с данными: каждый процесс загружает всё сам.

# gunicorn.conf.py — сохранить общие страницы
import gc
gc.disable()          # конфиг читается до загрузки
                      # приложения: меньше дыр в памяти
preload_app = True    # загрузить приложение до fork

def pre_fork(server, worker):
    gc.freeze()       # объекты мастера — вне GC

def post_fork(server, worker):
    gc.enable()

# Большие данные — не миллионом объектов,
# а одним буфером: numpy, array, mmap.
# Счётчик ссылок у буфера один, страницы общие.

Проверьте себя

Мастер gunicorn с --preload загрузил словарь на 2 ГБ и форкнул 8 воркеров. Сразу после старта занято около 2,5 ГБ, через час — 17 ГБ. Воркеры словарь только читают. Что случилось?

В CPython «прочитать» — значит взять ссылку: Py_INCREF пишет в заголовок объекта, и страница копируется. Плюс сборщик мусора обходит объекты и тоже пишет в них. Через час почти каждая страница словаря скопирована в каждый из 8 воркеров. gc.freeze() убирает часть записей; надёжнее хранить данные одним буфером — numpy, mmap — или в отдельном сервисе.

Swap и трэшинг

  • Swap — место на диске для анонимных страниц. Редко используемые страницы уходят туда, и RAM достаётся кэшу и тем, кому нужнее.
  • Пока рабочий набор помещается в RAM, swap почти бесплатен. Как только нет — каждое обращение может стать major fault, и система «молотит» диском.
  • vm.swappiness — насколько охотно выгружать анонимную память вместо сброса кэша. Признак трэшинга — ненулевые si/so в vmstat 1 и рост PSI в /proc/pressure/memory.

RAM 16 ГБ, обращения случайны и равномерны — худший случай. Двигайте размер рабочего набора.

Page cache и free

Всё, что читается и пишется через файлы, оседает в page cache, и ядро держит его, пока память ни на что другое не нужна. Поэтому free на сервере почти всегда маленький — и это хорошо. Смотрите на available: сколько можно выделить, вытеснив чистый кэш.

Грязные страницы перед вытеснением надо записать на диск. Подробнее о кэше и fsync — в презентации про файлы.

Контейнеры и лимиты памяти

  • Лимит контейнера — memory.max в cgroup v2. В него входят анонимная память, page cache, tmpfs и часть памяти ядра. Превысили и вытеснять нечего — OOM внутри cgroup, в Kubernetes — OOMKilled, код выхода 137.
  • Рантайм должен знать лимит, а не RAM хоста. JVM с 8u191 и 10 читает cgroup; куча по умолчанию — 25% лимита, задают -XX:MaxRAMPercentage=75.
  • Go не ограничивает кучу сам: GOMEMLIMIT ≈ 90% лимита, иначе GC позволит куче вырасти вдвое. Node: --max-old-space-size. Python кучу не ограничивает вовсе: считайте воркеры × RSS.

$ cd /sys/fs/cgroup     # изнутри контейнера
$ cat memory.max memory.current
1073741824
1004535808
$ grep -E '^(anon|file|shmem) ' memory.stat
anon 612368384
file 371195904     # page cache: вытесняемый
shmem 0
$ cat memory.events
low 0
high 0
max 1840           # упирались в лимит
oom 2
oom_kill 2         # убито процессов

# Kubernetes считает working set:
# usage − неактивный файловый кэш

Проверьте себя

Под с лимитом 512 МБ. Приложение занимает 200 МБ и пишет большой лог в файл. График container_memory_usage_bytes ползёт к 512 МБ. Убьют ли под?

Page cache записывается на счёт cgroup и входит в usage — отсюда пугающий график. Но чистые страницы ядро вытесняет, как только cgroup подходит к лимиту; грязные сначала дописывает на диск. OOM будет, только если анонимной памяти, tmpfs и грязных страниц больше лимита. Поэтому алерты строят на container_memory_working_set_bytes, а не на usage.

Как это увидеть у себя

  • VSZ, RSS, swap процесса — ps, /proc/PID/status. Карта областей — /proc/PID/maps или pmap -x.
  • RSS, PSS, общие и грязные страницы по областям — /proc/PID/smaps, сводка — smaps_rollup, по всем процессам — smem.
  • Page faults — /usr/bin/time -v, perf stat; промахи TLB — perf stat -e dTLB-load-misses.
  • Система: free -m, vmstat 1 (si/so — swap, majflt — в sar -B), /proc/meminfo. Кого убили — dmesg | grep -i oom.

$ grep -E 'Vm(Size|RSS|Swap)' /proc/4242/status
VmSize:  20481024 kB     # VSZ
VmRSS:     307200 kB
VmSwap:         0 kB

$ cat /proc/4242/smaps_rollup
Rss:       307200 kB
Pss:       198410 kB     # честная доля
Shared_Clean: 141312 kB
Private_Dirty: 151552 kB

$ /usr/bin/time -v ./app 2>&1 | grep faults
Major (requiring I/O) page faults: 12
Minor (reclaiming a frame) page faults: 263841

$ vmstat -S M 1
 r  b   swpd   free   buff  cache   si   so
 3  0      0    412    220  11468    0    0

Главное

  • Каждый процесс живёт в своём адресном пространстве. Перевод адресов — дерево таблиц страниц плюс TLB; промахи TLB лечат локальностью и huge pages.
  • Память выдаётся лениво: malloc резервирует адреса, фрейм появляется при первой записи. VSZ — обещания, RSS — реальность.
  • Ядро обещает больше, чем есть. Когда счёт предъявлен, приходит OOM killer — и убивает самого большого.
  • fork делит страницы до первой записи. Redis BGSAVE и Python-воркеры платят за каждую изменённую страницу.
  • Сумма RSS врёт, считайте PSS. Маленький free — норма, смотрите available. В контейнере рантайму нужно знать лимит.

Выделенная память — обещание. Тронутая — счёт.