MAATRIX / Блог / Как ядро выбирает, какую страницу памяти отправить в swap первой

Как ядро выбирает, какую страницу памяти отправить в swap первой

MAATRIX

Когда на сервере кончается свободная память, ядро не хватает случайную страницу и не выкидывает первую попавшуюся — но оно и не ведёт точный журнал «кто когда обращался к памяти последним». Между этими двумя крайностями есть рабочий компромисс, на котором держится вся подсистема управления памятью Linux. Разберёмся, как он устроен и что из этого следует для настройки vm.swappiness.

Почему точный LRU никто не считает

Идеальный алгоритм вытеснения — LRU, least recently used: выбрасывать страницу, к которой дольше всего не обращались. Логика понятна, но у неё есть проблема: чтобы знать точный порядок обращений, нужно на каждое чтение или запись в память обновлять позицию страницы в списке. На сервере с десятками гигабайт RAM это миллионы страниц, и каждое обращение к памяти — это операция CPU, которая происходит миллиарды раз в секунду. Блокировать список или атомарно двигать в нём элемент на каждое обращение к памяти означало бы посадить производительность всей системы ради бухгалтерии, которая нужна редко — только в момент нехватки памяти.

Поэтому ядро не отслеживает точный порядок. Вместо этого оно использует то, что в литературе называют approximated LRU — приближение, которое почти всегда даёт разумный результат и почти ничего не стоит на горячем пути. Идея опирается на аппаратный бит доступа (accessed bit) в записи таблицы страниц: когда CPU реально читает или пишет по адресу, MMU сам, без участия ядра, выставляет этот бит в единицу. Ядру не нужно ничего логировать при каждом обращении — оно просто периодически проверяет этот бит у страниц-кандидатов на вытеснение и сбрасывает его. Если бит успел взвестись снова до следующей проверки — страницу трогали, ей рано на выход. Вот и весь механизм слежения, который не требует ничего дороже, чем редкий проход по ограниченному числу страниц.

Active и inactive: два списка вместо одной очереди

Вместо единого списка «от самой старой страницы к самой новой» ядро держит два: active list и inactive list (на практике их даже больше — отдельно для анонимной памяти и файлового кеша, но об этом дальше). Active — это «горячие» страницы, к которым точно недавно обращались. Inactive — кандидаты на вытеснение, вероятно холодные.

Новая страница почти всегда сначала попадает в inactive list, а не в active. Это специально: если бы всё сваливалось сразу в горячий список, он рос бы бесконтрольно и терял бы смысл — активным считалось бы вообще всё. Страница получает шанс доказать, что она действительно нужна: если к ней обратились повторно, пока она была в inactive, — она переходит (или, точнее, кандидатствует на переход) в active. Обычный порядок такой: страница создаётся → попадает в конец inactive → если за время пребывания там к ней обращались — при следующем скане reclaim'а её продвигают в active; если нет — она уходит на вытеснение с хвоста inactive.

Это уже само по себе приближение к LRU: вместо точного «когда именно было последнее обращение» ядро отвечает на куда более дешёвый бинарный вопрос — «обращались ли к странице хотя бы раз с момента последней проверки». Грубее, чем точный LRU, зато почти бесплатно.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Second chance: почему страница не улетает с первого раза

Механизм сканирования inactive list на практике реализован как вариант алгоритма second chance (второй шанс), родственника clock-алгоритма, который часто разбирают в курсах по ОС. Ядро идёт с хвоста inactive list — оттуда, где лежат самые давно не потревоженные страницы, — и смотрит на бит обращения (PG_referenced/accessed bit конкретной страницы).

  • Если бит не взведён — страница действительно давно не использовалась, её можно вытеснять: для файловой страницы просто освобождают память, для анонимной — сначала пишут в swap.
  • Если бит взведён — страница получает «второй шанс»: бит сбрасывается, а сама страница либо остаётся в inactive (но уже с обнулённым битом, то есть с чистого листа для следующей проверки), либо перемещается в active, если проверка показывает достаточно активное использование.

Смысл в том, что ядро не спешит увольнять страницу по одному подозрительному признаку. Если её реально не трогали — она получит вторую проверку и всё равно уйдёт довольно быстро. Если она просто попала под горячую руку сканера в неудачный момент — у неё есть шанс остаться. Это и есть суть «LRU-подобной аппроксимации»: не точный порядок последних обращений, а вероятностная фильтрация с несколькими проходами, которая на статистике ведёт себя почти как настоящий LRU, но обходится системе на порядки дешевле.

Стоит отдельно сказать: в относительно новых ядрах серии 6.x в дополнение к классической двухсписочной схеме появился альтернативный механизм — multi-generational LRU (MGLRU), который делит страницы не на два, а на несколько «поколений» по свежести обращений и точнее оценивает, что действительно холодное. Если вы работаете на современном дистрибутиве, стоит проверить cat /sys/kernel/mm/lru_gen/enabled — там будет видно, какая схема реально активна. Логика подхода при этом остаётся той же: приближённое, а не точное отслеживание давности обращений.

Anonymous и file-backed: разная цена вытеснения

Здесь начинается самая практичная часть. Ядро делит все вытесняемые страницы на два принципиально разных типа, и разница между ними определяет, что вообще происходит с производительностью сервера при нехватке памяти.

File-backed страницы — это память, которая физически является копией содержимого файла: страницы page cache, исполняемый код бинарников и библиотек, memory-mapped файлы. У такой страницы уже есть постоянное место на диске — сам файл. Если страница чистая (clean, её никто не менял с момента чтения с диска), ядро может выбросить её из памяти вообще без записи куда-либо: данные и так лежат на диске, при следующем обращении их просто перечитают. Если страница грязная (dirty, в неё писали и изменения ещё не сброшены на диск), сначала нужен writeback — запись изменений в исходный файл, и только потом освобождение.

Anonymous страницы — это память процессов без файла-прообраза: куча (heap), стек, что-то, выделенное через malloc/mmap(MAP_ANONYMOUS). У такой страницы нет «оригинала» на диске. Единственный способ её вытеснить, не потеряв данные, — записать в swap. То есть анонимную страницу вытеснить дороже почти всегда: это гарантированная операция записи, тогда как чистую файловую страницу можно выбросить бесплатно.

Из этой асимметрии следует, что ядру нужно вести раздельный учёт: сваливать анонимные и файловые страницы в общие active/inactive списки было бы неточно, потому что цена их вытеснения разная, а значит и решения об агрессивности вытеснения каждого типа должны приниматься отдельно. Поэтому на практике LRU-списки в ядре разбиты по обеим осям сразу — тип (anon/file) × состояние (active/inactive), итого несколько независимых списков плюс отдельный список unevictable-страниц (заблокированных в памяти, например через mlock). Reclaim сканирует их с разным весом, и именно этот вес настраивается параметром vm.swappiness.

Практическое следствие: если на сервере активно работает база данных с большим page cache (все прочитанные с диска данные оседают в файловом кеше) и одновременно куча процессов держат анонимную память, при нехватке RAM ядро в первую очередь склонно ужимать file cache — это дёшево и безопасно, данные не теряются. Anonymous-память трогают позже и неохотнее, потому что своп — это диск, а обращение к своп-странице означает page fault и синхронное чтение с диска прямо в момент, когда процессу эта память понадобилась. Подробнее о том, куда девается свободная память и как её съедает файловый кеш, можно почитать в материале про page cache, а про поведение грязных страниц — в статье про файловый кеш и грязные страницы.

Кто запускает вытеснение и когда это превращается в тормоза

Reclaim не работает по расписанию «раз в секунду проверить память». Он реагирует на watermarks — пороги свободной памяти, которые ядро сравнивает на каждой попытке выделения страницы: min, low и high (их можно посмотреть в /proc/zoneinfo, а общий регулятор запаса — vm.min_free_kbytes).

Пока свободной памяти достаточно (выше high), reclaim не трогается вообще. Когда свободная память падает ниже low, просыпается фоновый демон kswapd — он сканирует inactive-списки и постепенно освобождает страницы в фоне, стараясь довести объём свободной памяти обратно до high, не мешая работающим процессам. Это «мягкий» путь: пользовательский код ничего не замечает, кроме небольшой фоновой нагрузки на CPU и диск.

Хуже, если память падает до min быстрее, чем kswapd успевает её освободить — например, при резком всплеске аллокаций. Тогда включается direct reclaim: сам процесс, которому в этот момент нужна память, синхронно блокируется и лично участвует в освобождении страниц, прежде чем получить свою. Это уже заметная задержка на прикладном уровне — с точки зрения приложения это выглядит как необъяснимый скачок латентности именно в момент выделения памяти. Если такие скачки регулярны — это симптом того, что рабочий набор процессов систематически не помещается в физическую память, и тюнинг swappiness тут снимет симптом, а не причину.

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

vm.swappiness на практике: что параметр реально меняет

vm.swappiness — это не переключатель «включить/выключить своп», а число от 0 до 200 (диапазон расширили в относительно новых ядрах; исторически было 0–100), которое задаёт относительный вес анонимных страниц при сканировании reclaim. Грубо: чем выше значение, тем охотнее ядро готово вытеснять анонимную память в своп вместо того, чтобы ужимать file cache; чем ниже — тем сильнее предпочтение отдаётся сохранению анонимной памяти резидентной и сбросу файлового кеша в первую очередь.

Посмотреть и временно поменять значение:

cat /proc/sys/vm/swappiness
sysctl vm.swappiness=10

Закрепить постоянно:

echo "vm.swappiness=10" >> /etc/sysctl.d/99-swappiness.conf
sysctl --system

На cgroup v2 то же самое можно выставить отдельно для конкретной группы процессов, не трогая весь хост:

echo 10 > /sys/fs/cgroup/имя-группы/memory.swappiness

Значение по умолчанию в большинстве дистрибутивов — 60, это компромисс, который исторически подбирался скорее под десктопы и смешанные нагрузки, чем под выделенный сервер с одним тяжёлым сервисом. Практические ориентиры для серверных сценариев:

СценарийТипичный ориентир swappinessПочему
БД с большим shared buffers / bufferpool (PostgreSQL, MySQL)низкое значение (единицы–десятки)своя память процесса важнее file cache, своп бьёт по латентности запросов
Веб-сервер, много статики и активный file cacheзначение по умолчанию или вышеfile cache даёт больше пользы, если его чаще ужимать вместо анонимной памяти
Сервер с большим запасом RAM и редкими пикаминиже среднегоцель — не трогать своп до последнего, использовать его как страховку, а не как рабочий механизм
Малая RAM, много процессов, каждый терпим к паузамближе к умолчанию или вышесвоп реально нужен как буфер, а не как редкое исключение

Важная оговорка: swappiness=0 не отключает своп полностью — это не то же самое, что swapoff. Ноль означает «избегать вытеснения анонимной памяти, пока это возможно», но при реальном давлении на память, когда без свопа процесс упрётся в OOM killer, ядро всё равно может использовать swap как последний резерв. Если вам нужно гарантированно запретить своп — это отдельная операция, а не побочный эффект swappiness.

Второй практический момент: swappiness не защищает от плохого разделения памяти между приложениями. Если на сервере одновременно крутится тяжёлая БД и произвольные фоновые скрипты, которые active выедают RAM всплесками, никакой swappiness не заменит нормальные лимиты по cgroup и мониторинг реальных RSS/VSS процессов — разницу между ними стоит понимать отдельно, она разобрана в материале про то, что на самом деле показывают ps и top.

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

vmstat 1 5
# si/so — страницы swap-in/swap-out в секунду

cat /proc/vmstat | grep -E 'pgscan|pgsteal|pswpin|pswpout'

Если pswpout растёт стабильно и заметно даже при спокойной нагрузке — это сигнал пересмотреть либо swappiness, либо (что чаще правильнее) объём выделенной серверу памяти: тюнинг параметра снимает симптомы нехватки RAM, но не увеличивает саму RAM.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Значит ли низкий swappiness, что сервер вообще не будет использовать swap?

Нет. Это меняет приоритет, а не гарантию. При реальной нехватке памяти анонимные страницы всё равно уйдут в своп — ядро просто до последнего предпочитает сначала ужать file cache.

Почему после установки swappiness=0 своп всё равно иногда занят?

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

Можно ли настроить swappiness отдельно для одного контейнера, не трогая хост?

Да, если используется cgroup v2 — параметр memory.swappiness выставляется индивидуально на группу процессов через /sys/fs/cgroup/<группа>/memory.swappiness, не затрагивая остальные сервисы на том же сервере.

Что произойдёт, если полностью отключить своп на сервере с активным file cache?

Reclaim продолжит работать для файловых страниц — их всё так же можно вытеснять без записи в своп. Пострадает именно обработка анонимной памяти: при её нехватке у ядра не останется резерва, и раньше нормального включится OOM killer.

Стоит ли трогать min_free_kbytes вместе со swappiness?

Это разные рычаги: swappiness балансирует, что вытеснять; min_free_kbytes задаёт, при каком запасе свободной памяти вообще подключать фоновый kswapd. Для большинства серверных сценариев значения по умолчанию для min_free_kbytes менять не требуется, если нет специфической нагрузки с резкими пиками аллокаций.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →