Управление физической памятью

Сетевой стек | ELF и линковка

Сетевой стек обрабатывает пакеты, драйверы общаются с устройствами, процессы выполняют код — но для каждой из этих операций ядру нужны физические фреймы. Когда page fault требует фрейм — его кто-то должен выдать. Когда sk_buff для сетевого пакета занимает 240 байт — кто-то должен найти место в физической памяти, не выделяя целую 4 КБ страницу под 240 байт. Когда свободных фреймов нет — кто-то должен решить, какие страницы отобрать и у кого.

Эта подсистема — единственный распорядитель физической RAM. Она должна одновременно выделять фреймы быстро (наносекунды на нормальном пути), возвращать фреймы под давлением (прогрессивно дороже) и никогда не блокироваться в контексте прерывания, где ожидание невозможно.

Сервер: PostgreSQL с shared_buffers = 32 ГБ, приложение на Ruby (Puma, 16 воркеров, RSS — Resident Set Size — ~500 МБ каждый), мониторинг, systemd — суммарно под 60 ГБ. RAM — 64 ГБ. Система работает у границы: page cache занимает оставшиеся 4 ГБ, кешируя файлы WAL (Write-Ahead Log) и индексов. Приходит всплеск трафика, Puma-воркеры начинают активно аллоцировать объекты — каждый новый page fault просит фрейм, а свободных фреймов почти нет. Ядро должно одновременно выделять память быстро (нормальный путь) и возвращать память под давлением (рекуперация). Каждый следующий механизм рекуперации дороже предыдущего.

Buddy allocator: выделение физических фреймов

Ядро управляет физической RAM через buddy allocator (аллокатор близнецов). Вся физическая память разбита на блоки — непрерывные группы фреймов, размер которых кратен степени двойки: 1 страница (4 КБ), 2 страницы (8 КБ), 4 (16 КБ), и так далее до 1024 страниц (4 МБ). Для каждого порядка (order) — от 0 до 10 — аллокатор поддерживает свободный список.

Когда ядро просит один фрейм (order 0), аллокатор берёт блок из списка свободных блоков order 0. Если список пуст — берёт блок order 1 (2 страницы) и разрезает пополам: одну половину отдаёт, вторую кладёт в список order 0. Если и order 1 пуст — берёт order 2, разрезает, одну половину в order 1, вторую снова пополам. Каждое разрезание — O(1): обновить указатели в связном списке. При освобождении аллокатор проверяет, свободен ли «близнец» (buddy) — парный блок того же размера, идущий рядом в физической памяти. Если свободен — объединяет оба в блок следующего порядка и рекурсивно проверяет дальше. Этот процесс слияния (coalescing) борется с внешней фрагментацией — без него физическая память быстро превращается в мозаику мелких блоков, из которой невозможно собрать непрерывный регион для huge page или DMA-буфера.

Текущее состояние аллокатора видно в /proc/buddyinfo:

Node 0, zone   Normal   1234  876  432  210  98  41  18   7   3   1   0

Одиннадцать чисел — количество свободных блоков каждого порядка от 0 до 10. На нашем сервере после нескольких часов работы блоков order 9-10 (2048 и 4096 страниц, нужных для непрерывных аллокаций) может не быть вовсе — физическая память фрагментирована. Это одна из причин, по которой huge pages (2 МБ = order 9) резервируют заранее, при загрузке системы, пока RAM не фрагментирована.

Buddy allocator быстр — выделение и освобождение за O(1) в типичном случае. Но у него есть фундаментальная проблема: минимальная единица выдачи — одна страница (4 КБ). Ядро постоянно создаёт объекты значительно меньшего размера: struct task_struct (~8 КБ), struct inode (~600 байт), struct sk_buff (~240 байт), struct dentry (~192 байта). Выделять 4 КБ страницу под 192-байтовый dentry — расход в 20 раз.

Slab/SLUB: аллокатор для мелких объектов

Slab-аллокатор решает проблему мелких объектов. Идея: взять у buddy allocator одну или несколько страниц, нарезать их на куски одинакового размера и раздавать эти куски по запросу. Каждый тип объекта получает свой slab cache — выделенный пул, где все ячейки имеют размер этого объекта. Для struct task_struct — кеш с ячейками ~8 КБ, для struct inode — ~600 байт, для sk_buff — ~240 байт.

Buddy allocator
      |
      | выделяет страницу (4 КБ)
      v
 Slab cache "dentry" (размер объекта: 192 байта)
 +------+------+------+------+------+--- ... ---+------+
 | obj0 | obj1 | obj2 | obj3 | obj4 |           |obj20 |
 +------+------+------+------+------+--- ... ---+------+
   192B   192B   192B   192B   192B               192B

 4096 / 192 = 21 объект на страницу

Внутренняя фрагментация сводится к остатку от деления размера страницы на размер объекта — десятки байт вместо тысяч.

Современное ядро Linux (начиная с 2.6.22, 2007) использует реализацию SLUB (Simple List Unqueued Buffers) вместо оригинального slab allocator. SLUB оптимизирован для многоядерных систем: каждый CPU имеет свой freelist — указатель на следующий свободный объект в кеше. Выделение на горячем пути — одна атомарная операция cmpxchg без блокировок: прочитать указатель, сдвинуть на следующий свободный объект. Пока поток аллоцирует объекты на своём CPU, конкуренции нет, а значит нет и lock contention.

/proc/slabinfo показывает все активные slab-кеши:

# name            <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab>
task_struct              832       840      8640       3            8
dentry                 98432     98550       192      21            1
sk_buff_head            5120      5120       240      17            1
inode_cache            12800     13440       608       6            1

На нашем сервере dentry и inode_cache — крупнейшие slab-кеши, потому что PostgreSQL и приложение работают с тысячами файлов. sk_buff_head растёт при высоком сетевом трафике. Утилита slabtop показывает ту же информацию, отсортированную по потреблению памяти.

Для аллокаций произвольного размера, когда нет специализированного кеша, ядро предоставляет kmalloc(). Под капотом kmalloc() использует набор slab-кешей для стандартных размеров: 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192 байт. Запрос kmalloc(200, GFP_KERNEL) попадёт в кеш для 256-байтовых объектов — внутренняя фрагментация не более 56 байт. Для объектов крупнее 8 КБ kmalloc() обращается напрямую к buddy allocator.

Когда нужен большой буфер — десятки или сотни килобайт — и при этом нет требования физической непрерывности, используется vmalloc(). В отличие от kmalloc(), vmalloc() выделяет страницы по одной (они могут быть разбросаны по физической RAM) и отображает их в непрерывный виртуальный диапазон через модификацию page table ядра. Цена — манипуляция page table и TLB flush (TLB, Translation Lookaside Buffer), поэтому vmalloc() дороже kmalloc() и используется для редких крупных аллокаций: загрузка модулей ядра, выделение буферов для eBPF-программ.

Зоны, ватерлайны и GFP-флаги

Не вся физическая память одинакова. Некоторые устройства (устаревшие ISA-контроллеры — ISA, Industry Standard Architecture) могут адресовать только первые 16 МБ RAM. 32-битные PCI-устройства видят адреса до 4 ГБ. Buddy allocator учитывает это, разделяя физическую RAM на зоны (zones):

Физическая RAM (64 ГБ):
+------------------+---------------------------+-----------------------------+
|  ZONE_DMA        |  ZONE_DMA32               |  ZONE_NORMAL                |
|  0 - 16 МБ       |  16 МБ - 4 ГБ             |  4 ГБ - 64 ГБ              |
|  (legacy ISA)     |  (32-bit PCI)             |  (основная)                 |
+------------------+---------------------------+-----------------------------+

На нашем сервере ZONE_NORMAL содержит ~60 ГБ — это рабочая лошадка. ZONE_DMA32 — ~4 ГБ, резерв для устройств с 32-битной адресацией. ZONE_DMA — 16 МБ, наследие ISA-шины, практически не используется на современном оборудовании.

Каждая зона поддерживает собственный buddy allocator со своими свободными списками. У каждой зоны — три ватерлайна (watermarks), определяющие уровни давления на память:

Свободная память в зоне
      ^
      |
      |    pages_high --------- kswapd засыпает
      |
      |    pages_low  --------- kswapd просыпается
      |
      |    pages_min  --------- минимальный резерв (для GFP_ATOMIC)
      |
      0

Когда количество свободных страниц в зоне падает ниже pages_low — ядро будит фоновый поток kswapd для освобождения страниц. Когда свободные страницы выше pages_highkswapd засыпает. pages_min — аварийный резерв, доступный только для аллокаций, которые не могут ждать.

Какую зону использовать и как вести себя при нехватке памяти — определяют GFP-флаги (Get Free Pages), передаваемые при каждом запросе к аллокатору:

GFP_KERNEL — стандартный флаг для кода, выполняющегося в контексте процесса (системные вызовы, workqueue). Может использовать любую зону, может спать, может инициировать рекуперацию — вызвать прямое освобождение страниц (direct reclaim), подождать, пока kswapd освободит память, дождаться записи грязных страниц на диск. Это самый «терпеливый» режим: ядро сделает всё возможное, чтобы найти фрейм.

GFP_ATOMIC — для кода в контексте прерывания (softirq, top half) или при удержании спинлока. Не может спать, не может ждать рекуперации. Использует только готовые свободные страницы, включая аварийный резерв ниже pages_min. Если резерва нет — аллокация немедленно возвращает NULL. Именно поэтому аварийный резерв существует: обработчик сетевого прерывания должен выделить sk_buff для входящего пакета, и если аллокация вернёт NULL — пакет будет потерян.

На нашем сервере PostgreSQL выделяет память в контексте процесса (GFP_KERNEL): запрос SELECT вызывает palloc() внутри PostgreSQL, который в конечном счёте приводит к brk() или mmap() → page fault → buddy allocator с GFP_KERNEL. Сетевой стек, обрабатывающий входящие TCP-соединения к PostgreSQL в softirq, использует GFP_ATOMIC для sk_buff. Если memory pressure высокое, GFP_KERNEL-аллокации процесса будут ждать (и замедляться), а GFP_ATOMIC-аллокации сетевого стека будут проваливаться, теряя пакеты — это проявляется как TCP-ретрансмиссии и рост latency клиентов.

Классификация страниц

Прежде чем освобождать память, ядро должно понять: какие страницы можно освободить и какой ценой? Физические фреймы делятся по двум осям.

По источнику. Анонимные (anonymous) страницы — heap, стек, mmap(MAP_ANONYMOUS) — содержат данные, которых нет на диске. Для их освобождения единственный путь — записать в swap. Файловые (file-backed) страницы — page cache — содержат данные файлов, отображённых в память. Для их освобождения достаточно сбросить содержимое (если страница чистая) или записать на диск и сбросить (если грязная).

По состоянию. Чистые (clean) страницы — содержимое совпадает с тем, что на диске. Их можно просто отбросить — при следующем обращении данные будут перечитаны. Грязные (dirty) страницы — были модифицированы после загрузки с диска. Перед освобождением их нужно записать обратно (writeback), что стоит дискового I/O.

Ядро поддерживает два LRU-списка (Least Recently Used) для каждой зоны: active и inactive. Страницы, к которым часто обращаются, попадают в active list. Страницы, к которым давно не обращались, — в inactive list. Кандидаты на вытеснение выбираются из хвоста inactive list.

Аппаратный бит Accessed в PTE (Page Table Entry) — ключ к этому механизму. Процессор устанавливает Accessed-бит при каждом обращении к странице. Ядро периодически проверяет этот бит и сбрасывает его. Если при следующей проверке бит снова установлен — страница активна, она остаётся в active list. Если бит остался сброшенным — страница перемещается в inactive list. Это приближение к настоящему LRU без необходимости вести точный порядок обращений: вместо временной метки — бинарный вопрос «обращались ли к странице с момента последней проверки».

На нашем сервере: shared_buffers PostgreSQL — 32 ГБ анонимных страниц (PostgreSQL аллоцирует shared memory через mmap(MAP_ANONYMOUS|MAP_SHARED)). Page cache с данными WAL и таблиц — файловые страницы. При нехватке памяти ядро первым делом сбросит чистые файловые страницы из inactive list — бесплатно. Потом — грязные файловые (стоимость: запись на диск). Анонимные страницы shared_buffers останутся последними — они в active list, к ним постоянно обращаются. Если дело дойдёт до swap, анонимные страницы менее горячих процессов (мониторинг, агент логов) уйдут первыми.

kswapd: фоновое освобождение

Пока свободная память выше pages_high, процессы получают фреймы мгновенно из buddy allocator. Когда свободные страницы в зоне падают ниже pages_low, ядро будит kswapd — один фоновый поток на каждый NUMA-узел (NUMA, Non-Uniform Memory Access) (kswapd0, kswapd1, …).

kswapd сканирует inactive list зоны. Чистые файловые страницы — отбрасывает: отвязывает от page cache, обновляет PTE (сбрасывает present-бит — следующее обращение вызовет major page fault, и страница будет перечитана с диска), возвращает фрейм в buddy allocator. Грязные файловые страницы — ставит в очередь на writeback (запись на диск): после завершения записи страница становится чистой и может быть отброшена. Анонимные страницы — записывает в swap, если swap включён.

kswapd работает до тех пор, пока количество свободных страниц не поднимется выше pages_high. Весь процесс происходит в фоне, в контексте потока ядра. Процессы, выделяющие память, не замечают рекуперации — они видят свободные фреймы в buddy allocator и получают их без задержки. В этом ценность kswapd: он поддерживает резерв свободных фреймов проактивно, пока ещё есть запас.

На загруженном сервере kswapd может потреблять 1-5% CPU — это нормальная картина, когда система работает у границы доступной RAM. top или htop покажет kswapd0 в списке процессов. Если kswapd потребляет 10-20% CPU — система под серьёзным давлением памяти, и стоит разобраться, кто аллоцирует.

Direct reclaim: когда kswapd не успевает

Всплеск трафика: 16 Puma-воркеров одновременно обрабатывают запросы, каждый активно аллоцирует объекты Ruby, каждая аллокация через page fault запрашивает фрейм у buddy allocator. kswapd работает, но не успевает возвращать фреймы с такой скоростью — свободные страницы падают ниже pages_min.

Процесс вызывает page fault, ядро обращается к buddy allocator с GFP_KERNEL — свободных фреймов нет даже в аварийном резерве для этого флага. Ядро не может вернуть NULL процессу — GFP_KERNEL обещает «попытаться всеми средствами». Наступает direct reclaim (прямая рекуперация): процесс, запросивший память, сам начинает освобождать чужие страницы. Тот же алгоритм, что и у kswapd — сканирование inactive list, сброс чистых страниц, постановка грязных на writeback — но теперь это происходит синхронно, в контексте процесса, запросившего фрейм. Процесс блокируется на время рекуперации.

Direct reclaim — главный источник непредсказуемых всплесков латентности. Запрос к PostgreSQL, который обычно выполняется за 2 мс, внезапно занимает 50 мс, потому что один из page fault в середине запроса попал в direct reclaim, который ждал записи грязной страницы на диск. perf покажет это как mm_vmscan_direct_reclaim_beginmm_vmscan_direct_reclaim_end, и промежуток между ними — чистое время, проведённое процессом в освобождении памяти.

# Отследить direct reclaim событием perf
perf trace -e 'vmscan:mm_vmscan_direct_reclaim_begin' \
               -e 'vmscan:mm_vmscan_direct_reclaim_end' -a

Для GFP_ATOMIC — аллокаций в контексте прерывания — direct reclaim невозможен: код не может спать. Если аварийный резерв ниже pages_min исчерпан, GFP_ATOMIC возвращает NULL. На практике это означает потерю сетевых пакетов: обработчик softirq не смог выделить sk_buff, входящий TCP-сегмент отброшен, отправитель узнает об этом через таймаут и ретрансмиссию через 200 мс (TCP RTO, Retransmission Timeout). Пользователь видит «подвисание» запроса.

Параметр vm.min_free_kbytes управляет размером резерва pages_min по всем зонам. На сервере с 64 ГБ RAM значение по умолчанию — около 90 МБ. Для сервера с интенсивным сетевым трафиком его увеличивают до 256-512 МБ, чтобы GFP_ATOMIC реже проваливался, а kswapd просыпался раньше и активнее поддерживал резерв.

Writeback: запись грязных страниц

Когда процесс записывает данные через write(), ядро модифицирует страницу в page cache и помечает её как грязную (dirty). Данные в RAM, но на диске — старая версия. Если сервер потеряет питание в этот момент — изменения пропадут (если процесс не вызвал fsync()). Грязные страницы нужно записать на диск — это и есть writeback.

Ядро запускает writeback по двум триггерам.

Таймер. Каждые dirty_writeback_centisecs сотых секунды (по умолчанию 500, то есть каждые 5 секунд) фоновые потоки flush-* (flusher threads, по одному на каждое блочное устройство — per-BDI, Backing Device Info) просыпаются и записывают грязные страницы, которые старше dirty_expire_centisecs (по умолчанию 3000, то есть 30 секунд). На нашем сервере каждые 5 секунд flusher thread диска, где лежат данные PostgreSQL, просыпается и записывает страницы WAL и таблиц, которые были модифицированы больше 30 секунд назад.

Порог. Когда доля грязных страниц от общей памяти превышает dirty_background_ratio (по умолчанию 10%), ядро будит flusher threads для активной записи, не дожидаясь таймера. Это фоновый writeback — процессы продолжают работать.

Но если доля грязных страниц превышает dirty_ratio (по умолчанию 20%), ядро начинает throttling (троттлинг — принудительное торможение): процесс, вызвавший write(), блокируется до тех пор, пока фоновый writeback не снизит количество грязных страниц ниже порога. Это защитный механизм: без него процесс мог бы генерировать грязные страницы быстрее, чем диск способен их записывать, и вся RAM оказалась бы заполнена грязными данными, которые невозможно ни сбросить (нужно ждать I/O), ни отбросить (данные потеряются).

Доля грязных страниц
      ^
      |
 20%  |  dirty_ratio ----------- процесс блокируется при write()
      |
 10%  |  dirty_background_ratio - flusher threads активируются
      |
      0

На нашем сервере PostgreSQL выполняет checkpoint — массовую запись dirty pages из shared_buffers на диск. Во время checkpoint PostgreSQL вызывает write() + fsync() для сотен файлов. Если checkpoint генерирует грязные страницы быстрее, чем NVMe SSD (NVMe, Non-Volatile Memory Express) их записывает, доля грязных страниц достигает dirty_ratio, и рабочие процессы PostgreSQL, выполняющие write() для WAL, начинают блокироваться. Результат — рост latency всех SQL-запросов на время checkpoint. PostgreSQL смягчает это через checkpoint_completion_target (растягивая запись во времени), а на уровне ОС администраторы снижают dirty_ratio до 5-10% и dirty_background_ratio до 3-5%, чтобы flusher threads не допускали накопления большой массы грязных страниц.

Запись грязных страниц выполняется flusher threads, которые работают как обычные потоки ядра и могут быть спланированы на любой CPU. Запрос на I/O уходит через слой блочного ввода-вывода: softirq BLOCK_SOFTIRQ обрабатывает завершение дисковых операций. Когда диск подтверждает запись, страница помечается как чистая и становится кандидатом на освобождение.

OOM killer: крайняя мера

Все механизмы исчерпаны: kswapd вычистил inactive list, direct reclaim освободил всё, что мог, swap (если есть) заполнен, грязные страницы на диске — а фреймов по-прежнему нет. Процесс повис в direct reclaim, ядро не может выделить фрейм. Последняя мера — OOM killer (Out Of Memory killer): ядро выбирает процесс-жертву и отправляет ему SIGKILL.

Пользовательская перспектива OOM — overcommit, oom_score_adj, cgroup OOM, диагностика через dmesg — подробно разобрана в управлении памятью (userspace). Здесь — механика выбора на стороне ядра.

OOM killer вызывается из контекста direct reclaim: функция out_of_memory() в mm/oom_kill.c. Ядро перебирает все процессы и для каждого вычисляет oom_score — эвристику от 0 до ~1000, где основной вклад делает RSS (Resident Set Size): процесс, занимающий 50% RAM, получает score ~500. Администратор корректирует через oom_score_adj (от -1000 до +1000) в /proc/<pid>/oom_score_adj. Процесс с максимальным итоговым score получает SIGKILL. Логика проста: убить того, кто освободит больше всего памяти одним действием.

На нашем сервере PostgreSQL-постмастер потребляет 32 ГБ shared_buffers — его oom_score будет высоким. Без защиты OOM killer убьёт PostgreSQL первым, что обрушит все клиентские подключения. Поэтому в production-среде постмастеру ставят oom_score_adj = -1000 (через systemd: OOMScoreAdjust=-1000), а приложение (Puma) получает нейтральный или положительный oom_score_adj. При OOM ядро убьёт Puma-воркер (8 ГБ RSS), освободит память — а PostgreSQL продолжит работать.

В контейнерной среде OOM изолирован через memory cgroups: когда pod в Kubernetes превышает memory.max, OOM kill срабатывает внутри cgroup — убивается процесс в этом pod, а не произвольный процесс на ноде. Подробнее — в управлении памятью (userspace).

Цепочка рекуперации: от дешёвого к дорогому

На нашем сервере полная цепочка выглядит так. При нормальной работе buddy allocator отдаёт фреймы за наносекунды. Когда свободная память падает ниже pages_low, kswapd в фоне сбрасывает чистые файловые страницы — стоимость почти нулевая, но сервер теряет кешированные файлы, и следующий read() пойдёт на диск (50-200 мкс на NVMe вместо ~100 нс из page cache). Если kswapd не успевает, direct reclaim блокирует процесс на десятки миллисекунд, ожидая writeback грязных страниц. Если и это не помогает — OOM killer убивает процесс, что стоит потери данных (незакоммиченные транзакции, потерянные запросы) и времени на перезапуск.

Стоимость    Механизм              Что происходит
---------------------------------------------------------------
~10 нс       buddy allocator       свободный фрейм из freelist
~1 мкс       kswapd (clean page)   сброс чистой файловой страницы
~100 мкс     kswapd (dirty page)   writeback + сброс
~1-50 мс     direct reclaim        процесс сам ждёт writeback
~секунды     OOM kill              SIGKILL, перезапуск процесса

Каждый переход — рост стоимости на порядок. Задача администратора — удерживать систему в верхних строках таблицы: достаточно RAM, чтобы хватало с запасом; правильные dirty_ratio/dirty_background_ratio, чтобы грязные страницы не копились; vm.min_free_kbytes, чтобы kswapd просыпался заблаговременно; oom_score_adj, чтобы при худшем сценарии ядро убило наименее ценный процесс.

Sources


Сетевой стек | ELF и линковка