Определение состояния Memory Pressure в Linux

Что это и зачем это?

Memory pressure — это состояние, когда системе не хватает свободной памяти. Обычно его разделяют на moderate и critical. Moderate — это когда памяти «мало, но пока еще жить можно», и дальнейшие выделения памяти могут вызвать проблемы с отзывчивостью из-за активной работы со swap-файлов или сбросов системных буферов и кэшей. Critical — этап уже более серьезный, означающий «еще чуть-чуть, придет OOM-killer и начнет убивать процессы».

«Для чего нам это знать и контролировать?» — на этот вопрос ответ будет в зависимости от того, что именно вы разрабатываете. Известный всем браузер Chromium (на котором основан Google Chrome), например, придерживается тактики «используем всю доступную память»: если что-то можно забуферизовать и закэшировать в ОЗУ для ускорения работы, оно обязательно будет сделано. Зато при наступлении moderate memory pressure кэши и буферы очищаются, чтобы освободить пространство для работы других приложений, а при наступлении critical memory pressure браузер начинает выгружать фоновые вкладки, чтобы не допустить активации OOM-киллера.

3f97d8b35ed2951f47c373c9e857af73.jpg

Итак, поехали.

Физическая память

Допустим у нас нет свопа в принципе, либо же нас волнует только OOM-киллер, который срабатывает при нехватке именно физической памяти. Соответственно, именно наличие доступной физической памяти мы и будем контроллировать. Заглянем для начала в /proc/meminfo:

~$ cat /proc/meminfo
MemTotal:         493696 kB
MemFree:           23676 kB
MemAvailable:     175408 kB
Buffers:           20592 kB
Cached:           118248 kB
SwapCached:        12112 kB
Active:           105668 kB
Inactive:         119872 kB
Active(anon):      36924 kB
Inactive(anon):    51536 kB
Active(file):      68744 kB
Inactive(file):    68336 kB
Unevictable:           0 kB
Mlocked:               0 kB
SwapTotal:        524284 kB
SwapFree:         450556 kB
...

В первую очередь в глаза бросается, само собой, MemFree и MemAvailable, а именно то, что эти два параметра отличаются довольно сильно. MemFree может иметь очень малые значения даже тогда, когда, казалось бы, в системе ничего не запущено и не потребляет память, а вот MemAvailable при этом показывает что-то гораздо более ожидаемое. Причина проста — ОС точно так же использует ОЗУ, например, для файлового кэша, а MemAvailable — это объем памяти, который приложения могут запросить у системы, при этом не заставляя ее начать скидывать страницы памяти в своп.

Между тем, MemAvailable — это не просто MemFree + кэши, туда еще входит так называемые Reclaimable Slabs (но некоторые из которых могут внезапно оказаться совсем не reclaimable!), и наоборот, не все что в системе называется «Cached» может быть освобождено (сегменты shared memory segments, tmpfs, and ramfs, и т.д.).

Раньше в Linux-ядре MemAvailable вычислялось по такой формуле:

memAvailableKb = memFreeKb - memLowWatermarkKb +  memActiveFileKb + memInactiveFileKb - min(memPageCacheKb/2, memLowWatermarkKb)- min(memSReclaimableKb/2, memLowWatermarkKb) 

где MemFree, Active (file), Inactive (file), and SReclaimable можно найти в уже упомянутом /proc/meminfo файле, а LowWatermarks — это суммарный объем страниц памяти, отмеченных как low в /proc/zoneinfo. За реализацией и описанием расчета можно заглянуть сюда: https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/? id=34e431b0ae398fc54ea69ff85ec700722c9da773

В новых версиях метод расчета немного изменился, но в целом дает плюс-минус такие же результаты как и старый.

Так же нужно помнить, что эта оценка приблизительная, хотя, как показывает практика, в целом соответствует реальности.

Ну и еще имейте на всякий случай в виду, что наличие свободной физической памяти вовсе не означает, что ваш процесс не убьет OOM-киллер. Например, процесс может быть запущен в неймспейсе cgroups с ограничением по памяти, или к нему может быть применен ulimit () с теми же последствиями.

Своп

Если вас интересует просто «сколько места осталось в свопе», то тут все просто — параметр SwapFree из того же /proc/meminfo к вашим услугам. А вот если вас интересует вопрос «а не слишком ли активно мы свопимся», то все становится гораздо сложнее.

Заглянем в /proc/vmstat. В нем есть параметр pgmajfault, который инкрементируется каждый раз, когда в системе происходит событие page main fault, и ОС вынуждена подгружать нужную страницу из свопа. Быстрое увеличение этого параметра как раз-таки означает, что мы активно свопимся.

Другое дело, что именно считать «быстрым увеличением», ведь далеко не всегда page faults — это плохо. Например, в свопе вполне могут оказаться вытесненные страницы памяти какого-нибудь фонового процесса, который просыпается только раз в час, а в остальное время не активен и памяти не просит.

В таком случае будет разумно при мониторинге количества page faults применять фильтрацию. Тогда единичные «всплески» будут игнорироваться, а вот резкое нарастание значение и удержание его на уровне выше того, что было до этого в течении некоторого времени уже будет тревожным звоночком.

Самый простой способ достичь этого — использовать фильтр низких частот.

По-научному он описывается так:


6488df5877f465c5ddb180588addb755.png

где
EMAt — значение экспоненциального скользящего среднего в точке t (последнее значение, в случае временного ряда);
EMAt−1 — значение экспоненциального скользящего среднего в точке t−1 (предыдущее значение в случае временного ряда);
pt — значение исходной функции в момент времени t (последнее значение, в случае временного ряда);
α — коэффициент характеризующий скорость уменьшения весов, принимает значение от 0 и до 1, чем меньше его значение тем больше влияние предыдущих значений на текущую величину среднего.

В человеческом виде эта формула будет выглядеть следующим образом:

currentFaultsPerSecond =  coefficient * sampledFaultsPerSecond + (1 - coefficient) * oldFaultsPerSecond

Коэффициент α часто подбирают заранее учитывая периодичность выборок, но в нашем суровом мире разработки ПО все не так просто, например, система может включать-выключать ядра процессора, что может существенно повлиять на количество процессов, одновременно работающих с памятью, что в свою очередь может очень сильно исказить оценку интенсивности page fault’ов. Поэтому вполне логично будет логично пересчитывать коэффициент каждый раз как-то так:

adjustedEwmaCoefficient = 1  - Exp2(-deltaTime/lowPassHalfLife)

где deltaTime — это время, прошедшее между проверкой значения счетчиков на основании cpu time из /proc/stat, а lowPassHalfLife — это нужное нам время затухания, вполне можно начать экспериментировать с 30 секунд. В конце статьи я дам ссылку на Github с готовым кодом для экспериментов.

После всего этого достаточно раз в N секунд производить описанную выше проверку и сравнивать новое фильтрованное значение со средними показателями (те же pgmajfault за все время работы процесса или системы деленые на всё cpu time за все время работы процесса или системы), и если мы видим превышение значений в несколько раз (подбирается тоже экспериментально, можно начать с 5x для moderate и 10x для critical) — бьем тревогу.

CGroups

Подсистема cgroups содержит встроенный механизм определения нехватки памяти в системе. Производится это путем анализа количества переиспользованных страниц памяти.

В CGroups существует три уровня memory pressure:

«Low» означает, что система освобождает память для новых выделений блоков. То есть свободной физической памяти уже нет, но можно без проблем освобождать не сильно нужные страницы памяти.

«Medium» означает, что система испытывает среднюю нехватку памяти, возможен своппинг, выгрузка активных файловых кэшей, и прочих вещей, которые могут влиять на отзывчивость и производительность системы.

«Critical» уровень означает что уже всё, по оценкам ядра очень скоро проснется OOM-киллер… ну либо он уже проснулся и работает.

С деталями реализации можно ознакомиться вот тут, ну, а мы посмотрим, как это можно использовать на практике.

Каждый запущенный процесс (в том числе, не принадлежащий никакому специальному cgroups namespace’у!) может использовать /sys/fs/cgroup/memory/memory.pressure_level для определения memory pressure от cgroups. Одно НО: по указанному пути лежит не обычный файл, вы не сможете его открыть и прочесть оттуда текущее значение. Необходимо использовать чуть более сложный механизм с eventfd. Допустим, вы хотите подписаться на уведомления о medium memory pressure (для других подписок все аналогично, повторить еще два раза). Что для этого нужно:

  1. Создать файловый дескриптор для уведомлений вызовом eventfd () (назовем его efd)

  2. Открыть файл /sys/fs/cgroup/memory/memory.pressure_level и запомнить его файловый дескриптор (назовем его pfd)

  3. Открыть файл /sys/fs/cgroup/memory/cgroup.event_control

  4. Записать в файл открытый в пункте 3 строку вида «efd pfd medium», то есть номера двух файловых дескрипторов в виде строки через пробелы плюс название триггера, на который мы хотим подписаться.

  5. После этого можно ждать событий из efd используя стандартный блокирующий системный вызов read (), либо делать то же самое асинхронно через select (), poll () или даже epoll ().

Простой пример реализации вышеописанного на Си можно найти здесь, либо, как я уже сказал, в конце статьи будет ссылка на гитхаб, где можно поиграть с готовым кодом.

Какие здесь минусы? А очень простые. Писать в /sys/fs/cgroup/memory/cgroup.event_control могут только процессы с правами суперпользователя. То есть будучи запущенными от имени обычного юзера, подписаться на уведомления от cgroups вы не сможете. Всё, финиш. Зачем это сделано так — вопрос отдельный, можно ли это как-то обойти — не знаю. Если кто знает, напишите в комменты.

Pressure Stall Information (PSI)

Слава инженерам Facebook, которые начиная версии 4.20 запилили данную подсистему в ядро. Pressure Stall Information позволяет оценивать, какое количество процессов простаивает с ожидании получения того или иного ресурса — в том числа — да, и памяти тоже. Чем сильнее memory pressure — тем дольше процесс, попросивший у ядра выделить ему блок памяти, будет ждать, пока ядро почистит кэши или выгрузит что-нибудь ненужное в своп.

Вариант простой: открыть обычный файл /proc/pressure/memoryи мы увидим в нем что-нибудь такое:

~$ cat /proc/pressure/memory
some avg10=0.24 avg60=0.06 avg300=0.01 total=398572372
full avg10=0.00 avg60=0.00 avg300=0.00 total=72405356

В данном случае «some» означает параметры, показывающие, сколько времени ждал хотя бы один процесс системы, а full — сколько времени были заблокированы все процессы системы. Числа в avg10, avg60, avg300 обозначают временные интервалы, в течении которых производилось наблюдение (например, в течении последних прошедших 10 секунд), а значения после знака = сколько времен в % из этого непосредственно процесс или процессы были заблокированы.

/proc/pressure/memory доступен на чтение всем, и это хорошо.

Другой вариант — подписываться на уведомления. Открыв тот же самый файл на запись, можно записать в него строку в следующем формате:

  

где some/full — интересующие нас процессы (некоторые или все), time windows — «временное окно» для отслеживания в микросекундах, а stall amount — максимальное время ожидания освобождения ресурса за это «временное окно», при превышении которого мы получим уведомление. Обратите внимание, строка должна быть нуль-терминированная, иначе ничего не сработает.

После этого точно так же с помощью poll (), select () или epoll () ждем событий из файлового дескриптора.

Пример и подробности здесь.

Недостаток тут точно такой же, как и в случае с cgroups — без прав рута на уведомления подписаться не получится.

Время экспериментов

А теперь, как я и обещал, ссылка на код, с которым можно поиграться.

Написано на Go, компилируется под Linux, позволяет выделять память постепенно или сразу большим куском для имитации memory pressure, и при этом записывать наблюдения про использование физической памяти и свопа, MemAvailable, число ошибок страниц памяти (интенсивность своппинга) в сыром и фильтрованном виде, мониторить значения PSI, а также подписываться на триггеры CGroups и PSI.

Выглядит выхлоп примерно так:

2019/09/18 19:58:01 System page size is 4096 bytes
2019/09/18 19:58:01 System timer frequency is 100 Hz
2019/09/18 19:58:01 Using block size 128 Mb
alloctd, cgroups, mem_avail, mem_avail_est, mem_pcnt, mem_total, psi_full, psi_some, swp_flts_mult, swp_flts_sec, swp_flts_sec_f, swp_free, swp_pcnt, swp_tend, swp_total, time, 
    640,       0,  21502.88,      21524.23,    32.99,  32091.43,     0.00,     0.00,          0.88,         0.00,           9.81, 30518.00,     0.00,    11.75,  30518.00,    5, 
   1280,       0,  20837.51,      20858.86,    35.07,  32091.43,     0.00,     0.00,          0.79,         0.61,           8.83, 30518.00,     0.00,    11.75,  30518.00,   10, 
   1920,       0,  20172.38,      20193.73,    37.14,  32091.43,     0.00,     0.00,          0.70,         0.00,           7.83, 30518.00,     0.00,    11.75,  30518.00,   15, 
   2560,       0,  19509.39,      19530.74,    39.21,  32091.43,     0.00,     0.00,          0.63,         0.00,           7.11, 30518.00,     0.00,    11.75,  30518.00,   20, 
   2944,       1,  18992.82,      19002.17,    40.82,  32091.43,     0.00,     0.00,          0.59,         0.00,           6.55, 30518.00,     0.00,    11.75,  30518.00,   23, 

Инструкции как собрать, как запустить, и описание аргументов командной строки — все в README на гитхабе. Играйтесь, ставьте звездочки, предлагайте идеи.

© Habrahabr.ru