Почему у каждого процесса своё адресное пространство, как адрес превращается в физический, откуда берутся page faults, почему malloc на гигабайт ничего не стоит, кого убивает OOM killer и что на самом деле показывают RSS и free.
Листайте стрелками ← →, свайпом или кнопками внизу. В схемах нажимайте кнопки и двигайте ползунки.
Нажимайте на операции. Код и libc у процессов общие, данные — свои.
На x86-64 процессу доступны младшие 128 ТБ адресов. В них ядро раскладывает области: код программы, куча, библиотеки и mmap, стек. Между ними — огромные пустые промежутки. Каждая область — VMA со своими правами.
ASLR при каждом запуске сдвигает области на случайное смещение, чтобы эксплойт не знал заранее, где лежат код и стек. Нажмите на область или перезапустите процесс.
fork — так работает copy-on-write.Плоская таблица на 236 страниц занимала бы 512 ГБ на процесс. Поэтому она — дерево: 48 бит адреса делятся на четыре индекса по 9 бит и смещение. Каждый уровень — страница из 512 записей. Ветки, где ничего не отображено, просто не создаются.
Корень дерева — в регистре CR3, при переключении процесса ядро меняет его. Введите адрес или выберите готовый и пройдите по шагам.
Четыре обращения к памяти ради одного — слишком дорого. Процессор кэширует готовые переводы в TLB: десятки записей в L1 и полторы-две тысячи в L2. Попадание бесплатно, промах — обход таблиц, 20–100+ тактов.
Последовательный проход почти всегда попадает: 512 элементов по 8 байт на страницу. Случайный доступ к большому набору страниц промахивается. Выберите шаблон доступа и размер TLB. Здесь TLB упрощён: полностью ассоциативный, вытеснение LRU.
hugetlbfs, заранее зарезервированные. Так делают Postgres (huge_pages), JVM (-XX:+UseLargePages), DPDK.Таблицы страниц — у каждого процесса свои. 100 процессов Postgres, отображающих 32 ГБ общей памяти, — это гигабайты одних таблиц.
Если в PTE нет бита P или не хватает прав, процессор прерывает инструкцию и передаёт управление ядру. Ядро смотрит, в какую VMA попал адрес, и решает: дать страницу и повторить инструкцию или послать SIGSEGV.
Выберите ситуацию — увидите путь обращения.
malloc(1 ГБ) — это mmap: ядро только создаёт VMA. Таблицы пусты, памяти не занято. Фрейм появляется при первой записи в страницу — через minor fault.
MAP_POPULATE, mlock, -XX:+AlwaysPreTouch.Сервис на Go на машине с 4 ГБ RAM. ps показывает VSZ 20 ГБ и RSS 300 МБ. Что происходит?
VmSwap в /proc/PID/status.vm.overcommit_memory: 0 — отказывает только в явно невозможном, 1 — никогда, 2 — строгий учёт до CommitLimit.oom_score: обычно самый большой, а не виноватый.malloc почти никогда не возвращает NULL. Вместо ошибки в коде — SIGKILL и строка в dmesg.RAM 8 ГБ, swap нет. Выделяйте и заполняйте память воркером.
Библиотеки и файлы отображаются через mmap: страница файла лежит в page cache один раз и подключается в таблицы всех процессов. Страницы, унаследованные через fork, тоже общие, пока их не меняют.
fork и copy-on-writefork не копирует память. Ребёнок получает копию таблицы страниц, указывающую на те же фреймы, а ядро снимает у всех этих страниц бит W. Первая запись — page fault: ядро копирует одну страницу, и у каждого появляется своя.
Поэтому fork процесса на 10 ГБ занимает миллисекунды — копируется только таблица. Нажмите fork(), затем на страницы, чтобы писать в них.
vm.overcommit_memory = 1 (иначе fork большого процесса может не пройти) и выключить THP.--preload, uWSGI, multiprocessing): каждое чтение объекта меняет счётчик ссылок, а сборщик мусора пишет в заголовки объектов. Страницы копируются, хотя код «только читает».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 ГБ. Воркеры словарь только читают. Что случилось?
Py_INCREF пишет в заголовок объекта, и страница копируется. Плюс сборщик мусора обходит объекты и тоже пишет в них. Через час почти каждая страница словаря скопирована в каждый из 8 воркеров. gc.freeze() убирает часть записей; надёжнее хранить данные одним буфером — numpy, mmap — или в отдельном сервисе.vm.swappiness — насколько охотно выгружать анонимную память вместо сброса кэша. Признак трэшинга — ненулевые si/so в vmstat 1 и рост PSI в /proc/pressure/memory.RAM 16 ГБ, обращения случайны и равномерны — худший случай. Двигайте размер рабочего набора.
freeВсё, что читается и пишется через файлы, оседает в page cache, и ядро держит его, пока память ни на что другое не нужна. Поэтому free на сервере почти всегда маленький — и это хорошо. Смотрите на available: сколько можно выделить, вытеснив чистый кэш.
Грязные страницы перед вытеснением надо записать на диск. Подробнее о кэше и fsync — в презентации про файлы.
memory.max в cgroup v2. В него входят анонимная память, page cache, tmpfs и часть памяти ядра. Превысили и вытеснять нечего — OOM внутри cgroup, в Kubernetes — OOMKilled, код выхода 137.-XX:MaxRAMPercentage=75.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 МБ. Убьют ли под?
tmpfs и грязных страниц больше лимита. Поэтому алерты строят на container_memory_working_set_bytes, а не на usage.ps, /proc/PID/status. Карта областей — /proc/PID/maps или pmap -x./proc/PID/smaps, сводка — smaps_rollup, по всем процессам — smem./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
malloc резервирует адреса, фрейм появляется при первой записи. VSZ — обещания, RSS — реальность.fork делит страницы до первой записи. Redis BGSAVE и Python-воркеры платят за каждую изменённую страницу.free — норма, смотрите available. В контейнере рантайму нужно знать лимит.Выделенная память — обещание. Тронутая — счёт.