Первая часть маленького «срывания покрова» о работе подсистемы виртуальной памяти, связи механизмов mmap, разделяемых библиотек и кэшей вызвало такое бурное обсуждение, что я не смог удержаться от того, чтобы не продолжить исследование на практике

Поэтому, сегодня мы сделаем… Крошечную лабораторную работу. В виде крошечной же программы на C, которую мы напишем, скомпилируем и проверим в деле — со свапом и без свапа.

Программа делает очень простую вещь — она запрашивает большой кусок памяти, обращается к нему и активно с ним работает. Чтобы не мучаться с загрузкой каких-либо библиотек, мы просто создадим большой файл, который отобразим в память так, как это делает система при загрузке разделяемых библиотек.

А вызов кода из этой «библиотеки» мы просто эмулируем чтением из такого mmap-нутого файла.

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

И, чтобы не писать лишнего кода, мы определим две константы, которые определят размер «сегмента кода» и общий размер оперативной памяти:

  • MEM_GBYTES — размер оперативной памяти для теста
  • LIB_GBYTES — размер «кода»

Объем «данных» у нас меньше объема физической памяти:

  • DATA_GBYTES = MEM_GBYTES — 2

Суммарный объем «кода» и «данных» чуть больше объема физической памяти:

  • DATA_GBYTES + LIB_GBYTES = MEM_GBYTES + 1

Для теста на ноутбуке я взял MEM_GBYTES = 16, и получил следующие характеристики:

  • MEM_GBYTES = 16
  • DATA_GBYTES = 14 — значит «данных» будет 14GB, то есть «памяти достаточно»
  • Swap size = 16GB

Текст программы


#include <sys/mman.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
 
#define GB              1073741824l
 
#define MEM_SIZE        16
#define LIB_GBYTES      3
#define DATA_GBYTES     (MEM_SIZE - 2)
 
long random_read(char * code_ptr, char * data_ptr, size_t size) {
   long rbt = 0;
   for (unsigned long i=0 ; i<size ; i+=4096) {
       rbt += code_ptr[(8l * random() % size)] + data_ptr[i];
   }
   return rbt;
}
 
int main() {
   size_t libsize = LIB_GBYTES * GB;
   size_t datasize = DATA_GBYTES * GB;
   int fd;
   char * dataptr;
   char * libptr;
 
   srandom(256);
   if ((fd = open("library.bin", O_RDONLY)) < 0) {
       printf("Required library.bin of size %ld\n", libsize);
       return 1;
   }
 
   if ((libptr = mmap(NULL, libsize,
                     PROT_READ, MAP_SHARED, fd, 0)) == MAP_FAILED) {
       printf("Failed build libptr due %d\n", errno);
       return 1;
   }
 
   if ((dataptr = mmap(NULL, datasize,
                       PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS,
                       -1, 0)) == MAP_FAILED) {
       printf("Failed build dataptr due %d\n", errno);
       return 1;
   }
 
   printf("Preparing test ...\n");
   memset(dataptr, 0, datasize);
   printf("Doing test ...\n");
 
   unsigned long chunk_size = GB;
   unsigned long chunk_count = (DATA_GBYTES - 3) * GB / chunk_size;
   for (unsigned long chunk=0 ; chunk < chunk_count; chunk++) {
       printf("Iteration %d of %d\n", 1 + chunk, chunk_count);
       random_read(libptr, dataptr + (chunk * chunk_size), libsize);
   }
   return 0;
}

Тест без использования swap


Запрещаем swap указав vm.swappines=0 и запускаем тест
$ time ./swapdemo 
Preparing test ...
Killed

real 0m6,279s
user 0m0,459s
sys 0m5,791s


Что произошло? Значение swappiness=0 отключило свап — анонимные страницы в него больше не вытесняются, то есть данные всегда в памяти. Проблема в том, что оставшихся 2GB не хватило для работающих в фоне Chrome и VSCode, и OOM-killer убил тестовую программу. А заодно нехватка памяти похоронила вкладку Chrome, в которой я писал эту статью. И мне это не понравилось — пусть даже автоматическое сохранение сработало. Я не люблю когда мои данные «хоронят».

Включенный swap


Выставляем vm_swappines = 60 (по умолчанию)
Запускаем тест:

$ time ./swapdemo 
Preparing test ...
Doing test ...
Iteration 1 of 11
Iteration 2 of 11
Iteration 3 of 11
Iteration 4 of 11
Iteration 5 of 11
Iteration 6 of 11
Iteration 7 of 11
Iteration 8 of 11
Iteration 9 of 11
Iteration 10 of 11
Iteration 11 of 11

real 1m55,291s
user 0m2,692s
sys 0m20,626s

Фрагмент top:

Tasks: 298 total,   2 running, 296 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0,6 us,  3,1 sy,  0,0 ni, 85,7 id, 10,1 wa,  0,5 hi,  0,0 si,  0,0 st
MiB Mem :  15670,0 total,    156,0 free,    577,5 used,  14936,5 buff/cache
MiB Swap:  16384,0 total,  12292,5 free,   4091,5 used.   3079,1 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
  10393 viking    20   0   17,0g  14,2g  14,2g D  17,3  93,0   0:18.78 swapdemo
    136 root      20   0       0      0      0 S   9,6   0,0   4:35.68 kswapd0

Плохой-плохой линукс!!! Он использует swap почти на 4 гигабайт хотя у него 14 гигабайт кэша и 3 гигабайта доступно! У линукса неправильные настройки! Плохой outlingo, плохие старые админы, они ничего не понимают, они сказали включить swap и теперь у меня из-за них система свапится и плохо работает. Надо отключить swap как советуют намного более молодые и перспективные интернет-эксперты, ведь они точно знают что делать!

Ну … Пусть будет так. Давайте максимально отключим свап по советам экспертов?

Тест почти без swap


Выставляем vm_swappines = 1

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

Я верю Крису Дауну, поскольку считаю что он отличный инженер и знает что говорит, когда объясняет что swap-файл позволяет системе лучше работать. Поэтому, ожидая, что, «что-то» пойдет «не так» и возможно система будет ужасно неэффективно работать, я заранее подстраховался и запустил тестовую программу, лимитировав её таймером, чтобы увидеть хотя бы ее аварийное завершение.

Сначала рассмотрим вывод top:

Tasks: 302 total,   1 running, 301 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0,2 us,  4,7 sy,  0,0 ni, 84,6 id, 10,0 wa,  0,4 hi,  0,0 si,  0,0 st
MiB Mem :  15670,0 total,    162,8 free,   1077,0 used,  14430,2 buff/cache
MiB Swap:  20480,0 total,  18164,6 free,   2315,4 used.    690,5 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
   6127 viking    20   0   17,0g  13,5g  13,5g D  20,2  87,9   0:10.24 swapdemo
    136 root      20   0       0      0      0 S  17,2   0,0   2:15.50 kswapd0

Ура?! Свап используется всего лишь на 2.5 гигабайт, что почти 2 в два раза меньше чем в тесте со включенным swap (и swappiness=60). Свапа используется меньше. Свободной памяти тоже меньше. И наверное, мы можем смело отдать победу молодым экспертам. Но вот что странно — наша программа так и не смогла завершить даже 1 (ОДНОЙ!) итерации за 2 (ДВЕ!) минуты:

$ { sleep 120 ; killall swapdemo ; } &
[1] 6121
$ time ./swapdemo
Preparing test …
Doing test …
Iteration 1 of 11
[1]+  Done                    { sleep 120; killall swapdemo; }
Terminated

real	1m58,791s
user	0m0,871s
sys	0m23,998s

Повторим — программа не смогла завершить 1 итерацию за 2 минуты хотя в предыдущем тесте она сделала 11 итераций за 2 минуты — то есть с почти отключенным свапом программа работает более чем в 10(!) раз медленнее.

Но есть один плюс — ни одной вкладки Chrome не пострадало. И это хорошо.

Тест с полным отключением swap


Но может быть, просто «задавить» свап через swappiness недостаточно, и его надо полностью отключать? Естественно, что надо проверить и эту теорию. Мы сюда тесты пришли провести, или что?

Это идеальный случай:

  • унас нет свопа и все наши данные будут гарантированно в памяти
  • свап не будет использоваться даже случайно, потому что его нет

И теперь наш тест завершится со скоростью молнии, старики пойдут на заслуженное ими место и будут менять картриджи — дорогу молодым.

К сожалению, результат запуска тестовой программы аналогичный — не завершилось даже одной итерации.

Вывод top:

Tasks: 217 total,   1 running, 216 sleeping,   0 stopped,   0 zombie
%Cpu(s):  0,0 us,  2,2 sy,  0,0 ni, 85,2 id, 12,6 wa,  0,0 hi,  0,0 si,  0,0 st
MiB Mem :  15670,0 total,    175,2 free,    331,6 used,  15163,2 buff/cache
MiB Swap:      0,0 total,      0,0 free,      0,0 used.    711,2 avail Mem

    PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
    136 root      20   0       0      0      0 S  12,5   0,0   3:22.56 kswapd0
   7430 viking    20   0   17,0g  14,5g  14,5g D   6,2  94,8   0:14.94 swapdemo

Почему это происходит


Объяснение очень простое — “сегмент кода” который мы подключаем через mmap (libptr) лежит в кэше. Поэтому когда мы запрещаем (или почти запрещаем) swap тем или иным способом, не важно каким — физическим ли отключением swap, или через vm.swappines=0|1 — это всегда заканчивается одним и тем же сценарием — вымыванием mmap’нутого файла из кэша и последующей его загрузкой с диска. А библиотеки загружаются именно через mmap, и чтобы убедиться в этом, достаточно просто сделать ls -l /proc//map_files:

$ ls -l /proc/8253/map_files/ | head -n 10
total 0
lr-------- 1 viking viking 64 фев  7 12:58 556799983000-55679998e000 -> /usr/libexec/gnome-session-binary
lr-------- 1 viking viking 64 фев  7 12:58 55679998e000-5567999af000 -> /usr/libexec/gnome-session-binary
lr-------- 1 viking viking 64 фев  7 12:58 5567999af000-5567999bf000 -> /usr/libexec/gnome-session-binary
lr-------- 1 viking viking 64 фев  7 12:58 5567999c0000-5567999c4000 -> /usr/libexec/gnome-session-binary
lr-------- 1 viking viking 64 фев  7 12:58 5567999c4000-5567999c5000 -> /usr/libexec/gnome-session-binary
lr-------- 1 viking viking 64 фев  7 12:58 7fb22a033000-7fb22a062000 -> /usr/share/glib-2.0/schemas/gschemas.compiled
lr-------- 1 viking viking 64 фев  7 12:58 7fb22b064000-7fb238594000 -> /usr/lib/locale/locale-archive
lr-------- 1 viking viking 64 фев  7 12:58 7fb238594000-7fb2385a7000 -> /usr/lib64/gvfs/libgvfscommon.so
lr-------- 1 viking viking 64 фев  7 12:58 7fb2385a7000-7fb2385c3000 -> /usr/lib64/gvfs/libgvfscommon.so

И, как мы рассматривали в первой части статьи, система в условиях фактической нехватки памяти при отключенном свапинге анонимных страниц выберет единственный вариант который её оставил владелец, отключивший свап. И этот вариант — реклейминг (освобождение) чистых страниц, занимаемых под данные mmap-нутых библиотек.

Заключение


Активное использование методики распространения программ «всё свое везу с собой» (flatpak, snap, docker image) приводит к тому, что количество кода, который подключается через mmap, существенно увеличивается.

Это может привести к тому, что использование «экстремальных оптимизаций», связанных с настройкой/отключением swap, может привести к совершенно неожиданным эффектам, потому, что swap-файл — это механизм оптимизации подсистемы виртуальной памяти в условиях memory pressure, а available memory это совсем не «неиспользуемая память», а сумма размеров кэша и свободной памяти.

Отключая swap-файл, вы не «убираете неправильный вариант», а «не оставляете вариантов»

Следует очень осторожно интерпретировать данные о потреблении памтяи процессом — VSS и RSS. Они отображают «текущее состояние» а не «оптимальное состояние».

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

P.S.: В обсуждениях регулярно задаются вопросы «а вот если включить сжатие памяти через zram...». Мне стало интересно, и я провел соответствующие тесты: если включить zram и swap, как это сделано по умолчанию в Fedora, то время работы ускоряется примерно до 1 минуты.

Но причина этого то, что страницы с нулями очень хорошо сжимаются, поэтому на самом деле данные уезжают не в swap, а хранятся в сжатом виде в оперативной памяти. Если заполнить сегмент данных случайными плохосжимаемыми данными, картина станет не такой эффектной и время работы теста опять же увеличится до 2 минут, что сравнимо (и даже чуть хуже), чем у «честного» swap-файла.