Если вы сталкивались хотя бы раз, что важная задача была убита OOM killerʼом…
Заготовки к этой статье очень старые, но проблема ещё старее. Такое впечатление, что с 1980-х никто не заинтересован в её осмысленном решении, хотя жалобы на последствия, похоже, не писал только тот, кто вообще не работал с компьютером. Здесь я попытаюсь сформулировать общую картину и тот метод решения, который мне кажется способствующим хоть какому-то конструктивному решению.


(ходит птичка весело по тропинке бедствий, не предвидя от сего никаких последствий)

Проблема


Начнём с азов (но без ненужных тут глубинных подробностей) и будем продвигаться к деталям.

Никакая современная система уровнем выше условного «компактного встроенного (embedded)», как AVR или младшие ARM, не обходится без виртуальной памяти.

Виртуальная память с точки зрения процессора актуального типа — это сопоставление адресу в виртуальной памяти адреса в физической памяти или признака отсутствия доступа по адресу, и прав доступа к физической памяти (что можно — читать, писать, исполнять), и это сопоставление для каждого процесса своё. Обычно этот доступ гранулирован страницами (4096 байт базовый размер на x86).

Но кроме реализации в процессоре, есть ещё реализация поддержки в ОС, и тут начинается много интересного.

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

  1. Возможность совмещения в одной странице физической памяти разных страниц виртуальной памяти (одного процесса или нескольких). Самое простое применение: если у нас один бинарник запущен в нескольких экземплярах, и много процессов использует одну и ту же библиотеку, зачем держать много копий, если можно одну?
  2. Возможность подгрузки отдельных страниц только по необходимости. Симметрично, возможность выгрузки из памяти отдельных страниц, а не процесса целиком; собственно, отсюда поэтому в некоторых традициях разделяют swapping как вытеснение процесса целиком и paging как вытеснение отдельных страниц.

Ситуация радикально изменилась с принятием в большинство современных систем механизмов, отработанных в экспериментальной разработке Mach. В результате, в юниксах (большинство, кроме особо embedded-нацеленных) и в заметной мере в Windows применяется Mach-styled VM (VM — тут аббревиатура от virtual machine, а не memory, хотя это не те «виртуальные машины», что внутри VMWare, VirtualBox, хостингов AWS, Azure..., а просто метод построения всей работы под ОС на основе виртуальной памяти).

Её основные концепции и подходы:

1. RAM есть кэш диска. Диск (говоря в общем, плоский диск или файл) — в её терминах — backing store. В диск входит область своппинга/пейджинга (swap area, paging area, или просто «своп»), если нет иного источника; но для многих страниц — есть, например, для бинарников, библиотек… если содержимое страницы не менялось по сравнению с тем, что на диске (повторюсь, возможно — в файле файловой системы, даже если эта файловая система виртуальна) — то область свопа не участвует.



При этом может быть двухслойное (иногда больше) устройство — изменённая версия для одного процесса или группы, и неизменённая — на диске. Например, это делается для библиотек с PIC (positional-independent code), которыми сейчас являются практически все SO (shared object) и DLL. В отображённом файле изменяется, для конкретного процесса, содержимое только нескольких страниц. Возможно, что после изменения некоторых страниц, вызвался fork() и в новых процессах было ещё изменение, тогда может быть более одного слоя изменений по сравнению с версией на диске (точные детали зависят от конкретного флавора Unix).



2. Допускается lazy commit, то есть формальное выделение памяти без фактического её обеспечения в backing store. Фактическое обеспечение возникает по факту первой записи в область, до того она пуста (и можно читать — чтение даёт нулевые байты).

Все стандартные механизмы стараются использовать отображение файлов в память: это относится как минимум к бинарникам и библиотекам. Если посмотреть на состав типового процесса (хоть bash, хоть браузер...), основной исполняемый файл и библиотеки будут отображены в память, и бо́льшая часть отображения — неизменны.
Результатом является то, что, например, система может освободить все неактивные страницы неизменённых данных (да и загружает изначально только те, что нужны), оставив только специфичные для процесса; кроме того, неизменённые страницы хранятся в RAM в одной копии, что резко сокращает затраты памяти. В некоторых случаях она может объединять и изменённые, если они идентичны, но это уже очень дорогая проверка.

Для иллюстрации:

smaps в Linux
Вот я взял bash из соседнего терминала и набрал `less /proc/$$/maps`, видим первые 5 строк:

5619242dc000-561924309000 r--p 00000000 08:05 1049639                    /bin/bash
561924309000-5619243ba000 r-xp 0002d000 08:05 1049639                    /bin/bash
5619243ba000-5619243f1000 r--p 000de000 08:05 1049639                    /bin/bash
5619243f1000-5619243f5000 r--p 00114000 08:05 1049639                    /bin/bash
5619243f5000-5619243fe000 rw-p 00118000 08:05 1049639                    /bin/bash

Если посмотреть в находящийся рядом smaps, видно для таких участков:

5619243f1000-5619243f5000 r--p 00114000 08:05 1049639                    /bin/bash
Size:                 16 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  16 kB
Pss:                   8 kB
Shared_Clean:          8 kB
Shared_Dirty:          8 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:           16 kB
Anonymous:            16 kB
...
VmFlags: rd mr mw me dw ac sd

Здесь несмотря на отсутствие разрешения записи видно ненулевое значение Dirty (rконкретно, Shared_Dirty) — область изменена по сравнению с тем, что в файле. Флаг dw показывает, что эти изменения не подлежат записи в файл. Это область санков для перехода на динамически линкованные имена, после настройки при загрузке она закрыта от изменения.

561924309000-5619243ba000 r-xp 0002d000 08:05 1049639                    /bin/bash
Size:                708 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                 708 kB
Pss:                  27 kB
Shared_Clean:        708 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:          708 kB

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

У области с r-wp появляется ещё и Private_Dirty — это персональные данные конкретного процесса (тут — модификация начальных значений глобальных переменных):

5619243f5000-5619243fe000 rw-p 00118000 08:05 1049639                    /bin/bash
Size:                 36 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  28 kB
Pss:                  18 kB
Shared_Clean:          4 kB
Shared_Dirty:         16 kB
Private_Clean:         0 kB
Private_Dirty:         8 kB

А здесь только память процесса, без базового файла:

5619243fe000-561924408000 rw-p 00000000 00:00 0 
Size:                 40 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                  28 kB
Pss:                  24 kB
Shared_Clean:          0 kB
Shared_Dirty:          8 kB
Private_Clean:         0 kB
Private_Dirty:        20 kB
Referenced:           28 kB
Anonymous:            28 kB

Часть таких областей явно помечена [heap] и [stack], но эта оказалась без такой пометки. Выделение памяти под нужды процесса может производиться через sbrk(), mmap("/dev/zero") или другие подходы.

Эта часть устройства схожа и с Windows (с поправкой на отсутствие forkʼа и отдельную операцию коммита). В случае fork(), все страницы двух порождённых процессов общие, и делятся только в случае изменения в одном из них (механизм copy-on-write, сокращённо COW). Последствия этого подхода см. ниже.

Что роднит эти два пункта — это то, что фактические затраты памяти процессом могут быть выражены как несколько совершенно разных цифр:

1) общий объём виртуальной памяти, известной процессу;
2) суммарный объём страниц, изменённых только в данном процессе и в случае сброса на диск уходящих в своп;
3а) суммарный объём страниц, изменённых в данном процессе или в группе процессов и в случае сброса на диск уходящих в своп, страница считается полностью в затратах процесса;
3б) суммарный объём страниц, изменённых в данном процессе или в группе процессов и в случае сброса на диск уходящих в своп, страница считается частично в затратах процесса (например, простейший подход — если она общая на 20 процессов, то как 1/20 страницы);
4) суммарный объём резидентных (т.е. находящихся в RAM) страниц;
и ни одна из них не является точным отражением затрат памяти данным процессом; фактически, каждая из них это какая-то температура пациента больницы, включая соседа, батарею отопления и открытую форточку, измеренная в воздухе рядом с пациентом, со слабо предсказуемыми весами каждого из них.

Всякие ps обычно показывают (1) как VSZ (Virtual Size) и (4) как RSS (Resident Set Size). Показатели (2) и (3) никак не отражаются напрямую (в ps/top/etc.), их надо явно вычислять. Для (3б) это ещё и усложняется задачей поиска, с какими другими процессами разделяется конкретная страница.

VSZ, чем дальше, тем больше, оказывается «ни о чём». Например, у процесса какого-нибудь WebExtensions из иерархии Firefox я сейчас вижу VSZ = 27.3G, при этом RSS = 697 116 KiB. Рядом присутствует skypeforlinux с VSZ = 38.0G, при этом RSS = 338 984 KiB; Slack с VSZ = 21 709 060 KiB. Зачем они отвели столько виртуальной памяти, под что? У меня RAM+своп меньше, чем эти три числа VSZ вместе (и даже одного числа для Skype!), а есть ещё много других процессов.

RSS — тоже «ни о чём», но с другой стороны: может содержать давно бесполезные страницы, но которые не отброшены пока, а может быть что-то важное и срочное за счёт пейджинга (привет, 12309 (NSFW link) — но эту тему мы тут сейчас не подымаем).

(Чуть в сторону и сразу в порядке хорошей практики: для мониторинга одного из своих компонентов я выставляю в центральный сервер мониторинга параметр — суммарный объём всех показателей Shared_Dirty и Private_Dirty по всем отображениям процесса. Это оказалось полезнее и VSZ, и RSS, которые зависят от массы других, в основном несущественных, факторов.)

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

1. Родились. В память отображён бинарник и библиотеки. VSZ может быть огромным (бинарник, библиотеки, начальное выделение адресов под динамическую память). RSS – только то, что изменилось (таблицы импорта в библиотеках плюс минимум данных и стека), код динамического загрузчика (ld.so) и данные, которые читал этот загрузчик (как segment headers в ELF).

2. Начали исполняться. В память загрузился код, RSS подрос на нужное количество, пусть это 10 MiB. Для дальнейшего описания я предполагаю, что объём бинарника и его данных ничтожен по сравнению с другими цифрами (которые 100M и выше); 1000 или 1024 мегабайта — тоже тут неважно.

3. Замапили (mmap() или MapViewOfFile()) гигабайтный файл. VSZ вырос на 1G. RSS не поменялся, потому что файл пока никак не использовался.

4. Прочитали этот файл в памяти (прошлись по памяти по области файла, прочитали каждый байт). VSZ не изменился. RSS – вырос на этот гигабайт (если не сильно тесно).

5. Процесс ничего не делал или обрабатывал данные из файла (не занимаясь активной работой с памятью). В это время другим процессам потребовалась память. Половину кэша файла в памяти продискарили, VSZ не изменился (остался 1G), RSS упал на 500M (стал 500M).

6. Форкнули из себя 10 копий (то есть процесс 10 раз вызвал fork()). Все копии получили одну и ту же память кроме параметров в стеке или вообще в регистрах (возвращаемый pid). Суммарный VSZ равен около 10G. Суммарный RSS стал 5G, при этом реально в памяти занято только 500M.

(Тут, кстати, интересное расхождение в деталях. Если эта память аллоцирована через mmap и не изменена, она не идёт в показанный подсчёт RSS потомка, в smaps этот объём не описан как резидентный. А если сделать malloc+memset или изменить — считается в потомках тоже. Ещё один источник бардака.)

7. Одна из копий аллоцировала 100M и записала их данными обработки. Её VSZ и RSS выросли на 100M. Такой же рост у суммы.

8. Эта копия форкнулась. Суммарные VSZ и RSS выросли на 1.1G, реальные затраты памяти не поменялись.

9. Новый форк (потомок результата в пункте 8) переделал все данные в 100M области. Суммарные и персональные для каждого процесса VSZ и RSS не поменялись, фактические затраты обоих выросли на 100M за счёт хранения копии.

((О ситуации с copy-on-write страницами следует добавить несколько слов углублённо. Есть два варианта, как они возникают: 1) в результате системного вызова fork(), который порождает копию вызвавшего процесса (далее называемую дочерним процессом) с другим идентификатором процесса (pid); 2) маппинг файла в режиме изменяемой копии с MAP_PRIVATE. В обоих случаях до модификации страницы хотя бы одним из участвующих процессов все они читают одну копию, но каждый (кроме последнего), кто меняет страницу, получает собственную копию, на что расходуется одна страница в фоне.))

10. Ещё кому-то потребовалась память и система продискардила остаток большого файла в памяти. Суммарный VSZ не изменился. Суммарный RSS упал на 6.0G. Фактические затраты в памяти сократились на 500M.

Думаю, понятно, что в этой каше ничего не ясно, если не заставить пересчитать все страницы с их свойствами. Какие из этого последствия?

1. Последствия для данного описания, в первую очередь, те, что фактическое исчерпание системной виртуальной памяти (RAM+своп) совершенно не обязательно вызвано какими-то явными действиями процесса по получению памяти. Процесс вызовет, в зависимости от уровня используемого API, sbrk(), mmap() (для /dev/zero или без файла), malloc(), new… и получит память. Потом он станет писать в ту же область или в другую, сработает commit или copy-on-write, а система память не даёт… приплыли. Процесс получает смертельный удар, оповещение не может быть доставлено потому, что процесс его не ждёт. Так как пути просигнализировать процессу в этом случае нет (штатно), в дело вступает OOM killer (название из Linux) и убивает (SIGKILL, то есть вообще ничего не дают сделать) или этот процесс, или другой — по своим критериям (достаточно сложным).

2. Адекватного средства оценки памяти, затраченной одним или несколькими процессами, нет. В зависимости от настроения измеряющего и погоды на Юпитере цифры могут расходиться в десятки раз. (И, повторюсь, стандартные VSZ и RSS — самые бесполезные среди всех возможных оценок.)

Всё это я описываю ради одного вывода:

Mach-like VM в принципе позволяет, чтобы процесс был убит в произвольный непредсказуемый момент за действия, которые формально никак не связаны с запросом ресурса (память).


К чему это приводит? Это приводит к тому, что конструкция VM резко усиливает аргументы в пользу того, что

1) не имеет смысла вообще предполагать нехватку памяти, потому что после этого уже ничего работать не будет;
2) следует делать lazy commit, потому что так проще, а если реально не хватит памяти — см. (1);

"Если нет разницы, зачем делать хорошо?"

причём эти пункты распространяются в том числе и на случаи, когда проблему выделения можно и нужно ловить (ограничение не VM в целом, а превышение явно известного процесса (как rlimit на процесс или на группу).

Фактически, все современные сложные системы под Unix пишутся таким образом — что отказ любого выделения памяти становится фатальным; в сети можно встретить множество рекомендаций «не обеспечивайте работоспособность при исчерпании памяти, это сложно и бесполезно». И это одна из тех вещей, что мне очень не нравятся в этом (Unix) мире. По слухам, Windows в этом плане более управляема, и раздельные флаги VM_RESERVE и VM_COMMIT намекают на это, а меньшее количество copy-on-write за счёт отсутствия fork() сокращает количество источников проблемы. Хотя, наверняка, специалисты по ней расскажут 100500 других проблем.

Некоторые флаворы имеют защиты против такого: например, HP-UX в случае исчерпания общей памяти конвертирует часть памяти процессов в файлы в /tmp. Но даже этот костыль, к сожалению, не является общим методом (ну и, к слову, где теперь тот HP-UX искать?)

Говоря более общим образом, виртуальная память здесь является одним из критических ресурсов.

NB

Критическими я называю те ресурсы, исчерпание которых препятствует даже аккуратному «свёртыванию» работы, потому что работы по корректному освобождению и завершению могут потребовать нового выделения ресурсов (даже если в минимальном объёме).



К критическим ресурсам относятся как минимум следующие — в порядке убывания ориентировочной важности:

  • (закоммиченная) виртуальная память
  • дескрипторы открытых файлов
  • процессы и нити


Память важна потому, что даже просто запустить свёртку может означать необходимость выделить ресурсы на: структуры для сброса состояния на диск; неожиданный copy-on-write; наконец, просто генерацию объекта исключения.

Дескрипторы нужны, если предполагается какое-то сохранение на диск, посылка сообщения в лог и т.п.; в Linux, могут выделяться на доп. ресурсы типа таймера, если это нужно процессу сохранения. Возможно, что-то ещё — у меня быстрой фантазии не хватило.

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

Сформулируем задачу: как обеспечить наличие критического ресурса в критический момент (критический момент — это когда критический ресурс реально потребовался)? Запаса ресурса может уже не быть в системе.

Текущие подходы и отзывы



Нельзя сказать, что проблема не осознаётся; (уже говорил:) про неё знает каждый, кто запускал хоть что-то реальное и тяжёлое. Как же она решается?

Так как 99% современного Unix это Linux, сконцентрируемся на нём. В Linux есть настройка vm.overcommit_memory:

/proc/sys/vm/overcommit_memory
This file contains the kernel virtual memory accounting mode. Values are:

0: heuristic overcommit (this is the default)
1: always overcommit, never check
2: always check, never overcommit


Чтобы обеспечить гарантию доступности памяти для любого применения в любом случае, включаем режим 2… и ой, оно начинает хотеть наличия места в свопе на каждый кусочек формально выделенного места. (Поправку overcommit_ratio не учитываем, она только утяжеляет требования.) Как уже показывал, процесс, которому нужно от силы пару сотен мегабайт, но аллоцировано десяток-другой гигабайт — сейчас норма. Где столько свопа напастись на них? И, главное, зачем, если реально та память будет использоваться 1) на очень малую долю от своего объёма, 2) постепенно? Ставить отдельный 4TB винчестер на сервер? А в облаке как выживать?

Хуже всего, конечно, режим 1: тебе ни в чём формально не отказывают… до тех пор, пока ресурс не кончается. Там же в мане:

In mode 1, the kernel pretends there is always enough memory, until memory actually runs out. One use case for this mode is scientific computing applications that employ large sparse arrays. In Linux kernel versions before 2.6.0, any nonzero value implies mode 1.


А ещё пишут, что так надо делать для Redis, потому что он аллоцирует память… слишком активно.

Или отзыв, один из первых найденных и очень показательный:

Перевыделение (overcommitting) оперативной памяти в Linux, особенно на рабочих серверах, является величайшим Злом, и это Зло в Linux разрешено по-умолчанию — vm.overcommit_memory=0
[...]
Более того, наличие возможности перевыделить памяти поощряет говнокодеров говнокодить кривые приложения в стиле "@#як-@#як и в продакшин" без реализации кода для надлежащей утилизации неиспользуемой приложением памяти.

(дальше много разговоров из попытки обеспечить этот резерв через системные настройки… в общем успешно через жуткие костыли и с жёсткими ограничениями).

Думаю, можно найти ещё 100500 приложений, для которых явно написаны такие рекомендации… и не до седмижды повторяю: просто загляните в свою систему, у какого процесса VSZ в разы больше вашей оперативной памяти.

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

Предложение умной реализации



Я не представляю себе хорошо работающего варианта, кроме как создать запас ресурса заранее и обеспечить его гарантированное наличие, даже если он до наступления критического момента не используется. Все знают про вариант «заначка 1000 талеров в сейфе» (со всеми вариациями) — это он.

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

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

Разумеется, этот запас должен учитываться в текущем потреблении процесса и сам выделяться согласно допустимым резервам (а как иначе?)

Как это могло бы быть реализовано? Это уже вопрос API, но попробуем предположить реализацию в стиле современного Linux на примере памяти.

1. Нам потребуются вызовы управления ресурсом.

int get_reserve(int reserve_type, unsigned long *reserved_size);
int set_reserve(int reserve_type, unsigned long reserved_size);

Можно делать в старом стиле (0/-1 плюс errno) или новом (0 — OK, >0 — код из errno).
reserve_type может быть: память (константа например RESERVE_MEMORY), дескрипторы, нити.
reserved_size считается в единицах ресурса (для памяти допустимы байты, килобайты или стандартные страницы, может округляться вверх к допустимой границе).

Отсутствие резерва проверяется немедленно (да, это коммит, хоть и в скрытую область).

2. Нам потребуется средство немедленного оповещения о затрате ресурса. Так как любое достаточно серьёзное приложение сейчас, скорее всего, многонитевое, можно по аналогии с signalfd/timerfd/etc. завести reservefd, для которого задаётся порог следующего оповещения и можно читать сообщения о сокращении запаса ниже этого порога:

int reservefd_create(); // flags? Сейчас лучше CLOEXEC делать по умолчанию
int reservefd_control(int reserve_type, unsigned long report_threshold);
struct reservefd_report {
  int reserve_type;
  unsigned long report_threshold;
  unsigned long current_level;
};


Если ядро задействует ресурс, то ставит в очередь сообщение (до 1 нечитанного на каждый тип) с указанием типа, перейденного граничного значения и текущего уровня. Если десктриптор оповещает про готовность к чтению, read() возвращает структуру отчёта.

Прочие методы (как посылка асинхронного сигнала) — по вкусу, в соответствии со стандартными практиками. В BSD-like системах (как MacOS) вместо reservefd предполагается новый вариант kevent filter.

Недостатки данного подхода:

1. Он требует явного обеспечения со стороны приложения. Это было бы не страшно, если бы подход был поддержан ядром лет 20 назад — за это время успели бы и в учебники вписать, и в большинстве приложений реализовать.
Но это не аргумент против реализации — когда-то всё-таки надо начать?

2. Заметная часть такой целевой обработки будет у самого толстого процесса, которое обычно есть основная цель OOM killer. Логика OOM killer требует притормаживания при наличии такой обработки в процессе. Это притормаживание требует серьёзного проектирования.

3. Он не поможет тем программам, которые не имеют такой защиты; для них должны работать более традиционные подходы. Я не предлагаю их отменять, данный метод должно работать как хорошее дополнение.

Сперва добейся?
Ожидаю вопрос «где твои патчи?» Увы, погружаться на 3-6 месяцев в устройство VM современного средства вроде Linux пока не было своих ресурсов. Но я ещё надеюсь.


Как расширение подхода, имеет смысл подумать про средство оповещения родительского процесса (или другого назначенного управляющего процесса) одновременно с целевым. (Расширение подписки на любой процесс, для которого есть права?)

Альтернативы?



1. Мне не нравится глобальная настройка overcommit_memory; непонятно, почему нельзя её настраивать раздельно для каждого процесса. Если разделить, в стиле Windows, действия резервирования и коммита, и резервирование делать отдельно только для последовательности размещения в памяти — получится, вместе с последующими мерами, уже более надёжное управление.

Цена: переделка многих подсистем, начиная с динамической памяти в libc.

2. «Отложенное» copy-on-write, которое может выстрелить при произвольной записи, может быть форсировано (ценой затраты памяти и, может быть, реального копирования) в единоличное владение. Требование этого может выставляться по отрезкам адресов. Это в помощь пункту 1: чем меньше потенциальных мест неявной аллокации, тем лучше.

Цена: таки больше затрат памяти (даже если виртуальной); чтобы не тратить физическую память — необходимость поддержки режима «эта физическая страница уже модифицирована для нескольких виртуальных».

Остаётся случай типа R/W MAP_PRIVATE. Если расширить mprotect() на опцию коммита…

Ничего не забыл? Как-то всё равно нет полного доверия к ситуации…

Выводы



1. Проблема есть и её надо решать.

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

Комментарии?

Дополнения



Обсуждение проблемы OOM в ещё более тяжёлом варианте — когда в занятую контейнером (cgroup) память учитывается и его очередь записи на диск, которой, тем не менее, процессы в контейнере не могут управлять :((
Это не совсем напрямую связано с темой статьи, но усиливает показ наплевательского отношения к обеспечению надёжности по памяти со стороны писателей ядра.
(Эта история и стала поводом вытащить давно забытые идеи на свет.)

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

Большое обсуждение этой темы с моим скромным едким участием было в 2001 году; по его результатам Vladimir Dozen нарисовал даже пробные патчи для выселения переполненных областей, по аналогии с HP-UX, в /tmp; раз, два. (Это не всё общение за тот период; чудо, что что-то выжило; основные архивы DejaNews до 2000 гугл потерял или неверно индексирует.)

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

Комментарии (16)


  1. ncr
    25.11.2021 13:50
    +1

    Каждый раз, когда где-то упоминается overcommit и OOM killer, мне становится страшно жить.
    Я не понимаю, как технология, не только потворствующая заметанию мусора под ковер, но и препятствующая нормальной обработке OOM в том коде, в котором она таки есть и таки уместна, могла появиться на свет и получить такую популярность.


    1. amarao
      25.11.2021 17:07

      Потому что оверкоммит - это эффективно. Более того, oom killer ведёт себя адекватнее, чем ENOMEM (если вы когда-то жили на системе с выключенным оверкоммитом, можно получить сегфолт баша просто по нажатию кнопки - "ну нет у аллокатора памяти"). Когда у вас есть микро процесс на 100кб, а рядом взбесившийся сервис, который жрёт память o(t), то вам будет очень обидно обнаружить, что ваш запрос на 4кб удовлетворить нельзя, потому что сервис сбоку съел последние двадцать тысяч свободных страниц памяти за 1мкс до вашего запроса. Съел и не использует (virt).

      Вообще, компьютер, в котором закончилась оперативная память, это уже не совсем область CS, это уже область "подфигачить хоть как-то".


      1. netch80 Автор
        25.11.2021 17:21
        +3

        > Потому что оверкоммит — это эффективно.

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

        > можно получить сегфолт баша просто по нажатию кнопки — «ну нет у аллокатора памяти»)

        Именно! Он уже написан так, что отказ в выделении может вызвать только сегфолт, потому что написан для систем, где отказ в выделении это конец света.

        > Когда у вас есть микро процесс на 100кб, а рядом взбесившийся сервис, который жрёт память o(t), то вам будет очень обидно обнаружить, что ваш запрос на 4кб удовлетворить нельзя, потому что сервис сбоку съел последние двадцать тысяч свободных страниц памяти за 1мкс до вашего запроса.

        На это есть система квот. Она есть даже сейчас в виде rlimits, хоть и примитивная. А как по-вашему это бы лечилось сейчас?
        Убийство того прожорливого процесса не предлагать — это уже регулярно проходили и неизвестно в общем случае, насколько это лучше.

        > Вообще, компьютер, в котором закончилась оперативная память, это уже не совсем область CS, это уже область «подфигачить хоть как-то».

        И это опять самосбывающееся пророчество.


      1. ncr
        25.11.2021 20:40
        +3

        можно получить сегфолт баша просто по нажатию кнопки — «ну нет у аллокатора памяти»)
        На системе с выключенным оверкоммитом можно получить сегфолт, только если результат malloc не проверять. Что, формально, г-нокод. Если в баше так — соболезную.

        А вот с включенным вам дают нечто, что выглядит/ходит/крякает как память, но при обращении к нему иногда случается сегфолт. Очень удобно и очень надежно.

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


        1. amarao
          25.11.2021 22:51

          До тех пор, пока у вас потребление памяти ниже доступной - всё ок. Если выше - какой-то софт идёт нафиг.

          Аналогия с картой совсем не подходит, потому что компьютер устройство не надёжное и в продакшене в любом случае требует резервирования.


          1. ncr
            26.11.2021 00:19

            До тех пор, пока у вас потребление памяти ниже доступной — всё ок.
            Пока потребление ниже — и без оверкоммита все ок.
            Если выше — какой-то софт идёт нафиг.
            Вот это «какой-то» и напрягает. Без него можно хотя бы останавливаться, некоторое время ждать и пробовать снова, пока память не появится, а так только смириться и перезапускать.


  1. amarao
    25.11.2021 13:51
    +1

    Большая простыня, которая даже не упоминает про существование oomd, cgroups и понятия pressure?

    Очень многие люди об этой проблеме задумались, и там есть комплекты решений, от уведомлений о предстоящем разгоне митинга, до userspace разгонятелей митинга и системы приоритетов для oom'а (oom_adj_score).

    Статья описывает состояние дел в линуксе circa 2005...

    И даже из состояния 2005 года не описывается радикальное решение - отключение оверкоммита.


    1. netch80 Автор
      25.11.2021 14:26
      +1

      > Большая простыня, которая даже не упоминает про существование oomd, cgroups и понятия pressure?

      Упомянутые вами oomd и прочие меры это лечение симптомов, а не проблемы.
      Вы говорите, предупреждения. Пусть есть предупреждения, а что в результате? Вот, например, мы генерируем исключение в C++ коде. На создание объекта требуется память. В результате, когда памяти и так мало, мы просим ещё. Вы считаете, это поможет решению?

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

      > И даже из состояния 2005 года не описывается радикальное решение — отключение оверкоммита.

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

      Собственно, переформулирую главный вопрос: почему вместо принципиального решения основной проблемы начинают лечиться симптомы, или даже симптомы симптомов?

      > Статья описывает состояние дел в линуксе circa 2005…

      И да, не с 2005. Считайте, что я описываю состояние, например, с 1995, и не только Linux (как минимум, все BSD), и утверждаю, что его никто не правит.
      Объясните — может, я неправ, например, потому что
      1) никто не будет этим пользоваться (с доказательством, почему так)
      2) описанный метод не сработает технически (опять же, с причинами)
      3) он недоступен (патент, запрет от рептилоидов)
      ?


      1. amarao
        25.11.2021 17:03

        Он совершенно тривиален, и ещё в старинные сишные времена до линуксов так делали.

        Однако, он же совершенно никак не защищает от oom'а, потому что если вы allocate память, но её не используете, то она virtual. А если хапнули и просто не отдаёте - кандидат на своп. А если хапнули много - кандидат на oom.

        Более того, наличие "нычки" с оперативной памятью совершенно вас не спасает от её нехватки в момент выполнения IO, например, причём какой именно памяти, вы даже понятия не имеете (jumbo frames >4k включен или нет?).

        Хотите детерминированного поведения? Отключайте оверкоммит. Хотите реакцию на нехватку памяти? Смотрите pressure. Хотите иметь микронычку и надеяться, что она поможет? Ну кто же вам мешает-то? Вон, java себе микронычку на 128Гб делает и ей хорошо.


        1. netch80 Автор
          25.11.2021 17:17
          +1

          > А если хапнули и просто не отдаёте — кандидат на своп. А если хапнули много — кандидат на oom.

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

          > Более того, наличие «нычки» с оперативной памятью совершенно вас не спасает от её нехватки в момент выполнения IO

          Имеется в виду, что ядро захотело памяти для какой-то операции, которая вообще не привязана ни к какому процессу?

          Тогда аналогичное резервирование надо делать и для самого ядра.

          > Хотите детерминированного поведения? Отключайте оверкоммит. Хотите реакцию на нехватку памяти? Смотрите pressure.

          Спасибо, но я объяснил, почему считаю этот подход обструктивным.

          > Вон, java себе микронычку на 128Гб делает и ей хорошо.

          Что за JVM и как именно это реализуется?

          > Он совершенно тривиален, и ещё в старинные сишные времена до линуксов так делали.

          Именно резерв, который перекидывается по потребности? Ссылочку можно?


          1. amarao
            25.11.2021 17:19
            +1

            Т.е. вы хотите оверкоммит, но не хотите oom? Окай... Покупайте больше памяти.

            У jvm это реализуется просто - Xms - Xmx и всё.


            1. netch80 Автор
              25.11.2021 17:22
              +1

              > Т.е. вы хотите оверкоммит, но не хотите oom?

              Из какого моего утверждения это по-вашему следует?

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


  1. MichaelBorisov
    26.11.2021 01:02

    Предлагаемое вами решение может, конечно, снизить остроту проблем, создаваемых принципом Overcommit. Но, боюсь, оно не решит их в корне.

    Если в старых системах, где malloc() при исчерпании памяти возвращает NULL, программа могла продолжить работу, хотя бы воздержавшись от дальнейшего выделения памяти — то в современных системах этого недостаточно. Надо не только прекратить запрашивать память, но активно и срочно освобождать память, выделенную ранее.

    Допустим, у вас многопоточное приложение. Один поток получает уведомление, что в системе заканчивается память. Что он может с этим сделать? Самому потоку, ждущему уведомлений, обычно память не нужна, и освобождать ему нечего. Нужно послать сигнал другим потокам, которые используют в данный момент память и могут ее освободить. Если потоки заняты в данный момент вычислениями, архивацией, шифрованием и т.д. — то во все эти алгоритмы необходимо вставить опрос флага, сигнализирующего о необходимости срочно прекратить работу.

    Если опрашивать этот флаг редко — то за время между опросами поток может потребить больше памяти, чем имеется в «сейфе на 1000 талеров». Если опрашивать часто — то придется глубоко лезть в сложные алгоритмы расчетов, архивации, шифрования, видеокодеки. Возможно, придется лезть в сторонние библиотеки. Придется разбивать большие операции чтения файлов на маленькие части, чтобы проверять между ними флаг. Риски и трудозатраты, связанные с такой доработкой алгоритмов и библиотек, представляются мне гигантскими.

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


    1. netch80 Автор
      26.11.2021 10:41

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

      Это зависит от реализации… и тут всплывает вопрос об ещё одном свойстве Unix подхода — уже не явной диверсии, но просто устарелости. В Windows есть structured exception handling, в юниксы — не завезли.
      Чем SEH тут было бы хорошо — оно штатно позволяет послать другой нити (как минимум) асинхронный сигнал что-то сделать, та его обработает, и это интегрировано в общий механизм обработки любых исключительных ситуаций (включая внутриязыковые). Если код той сложной обработки рассчитан на это, то он будет аккуратно производить все свёртки, включая освобождение памяти.

      Разумеется, код, который на это не рассчитан, типа стандартного сишного, зачистки не сделает. Может, поэтому его в Unix и не стали портировать. Ну и ещё его надо как-то сочетать со своими сигналами, это это ещё надо продумать (и решить универсально на нескольких поставщиков, лучше даже в POSIX стандарт). С setjmp/longjmp согласовать.
      Или просто форсировать всех на C++ уровня «C с классами + инкапсуляция + умные указатели»… Подождать лет 15, пока все библиотеки обновят…

      (Или есть альтернатива SEH, которая даёт то же, но лучше?)

      > Риски и трудозатраты, связанные с такой доработкой алгоритмов и библиотек, представляются мне гигантскими.

      Да.

      Но если будет 1) осознание проблем, 2) доступный работающий механизм, то предполагаю, что начнётся постепенная миграция в эту сторону.

      > И самое печальное, что все эти ухищрения нужны только для того, чтобы снизить (но не устранить) последствия порочного подхода, принятого в конкретной операционной системе.

      Слишком ярким оказался конкретный соблазн.


      1. MichaelBorisov
        26.11.2021 23:51

        В Windows есть structured exception handling, в юниксы — не завезли.

        Создать exception другому потоку не так просто. В юниксах есть сигналы — один поток может послать другому что-то типа аппаратного прерывания. В рамках потока-«жертвы» исполнение кода приостанавливается, поток исполняет процедуру обработки прерывания и возвращается к прерванному коду (если обработчик этому не помешает). Насчет межпоточных сигналов в юниксе не знаю, но межпроцессовые сигналы точно прерывают код.

        А вот в Windows этого нет. В User-mode нет никаких средств, с помощью которых один поток может прервать исполнение другого в произвольном месте. Можно прервать код с помощью Kernel-mode APC, но этот механизм доступен только из ядра и не документирован официально. User-mode APC код не прерывают (см. доку на QueueUserAPC). Они срабатывают только когда поток-жертва войдет в специальный режим ожидания.

        Кстати, интересно, как вы себе представляете всякие lock-free алгоритмы, адаптированные к тому, что в них в любой момент может произойти исключение? Не может ли от этого существенно снизиться производительность?
        Если код той сложной обработки рассчитан на это, то он будет аккуратно производить все свёртки

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

        Я бы все же предпочел иметь систему, где malloc возвращает NULL в случае нехватки памяти. Пусть даже придется проверять результат — это значительно проще, чем переделать все алгоритмы так, как вы предлагаете. Пусть даже программу, которой не хватило памяти, прибивают сразу. Но не та система, что есть сейчас — когда вместо одной программы может быть прибита другая, и не в момент malloc, а в произвольном месте.
        Слишком ярким оказался конкретный соблазн.

        Вот тут не понял. Кто чем соблазнился?


        1. netch80 Автор
          27.11.2021 10:28

          > В User-mode нет никаких средств, с помощью которых один поток может прервать исполнение другого в произвольном месте.

          Я помню, читал, что люди это делали. Средства уже не помню; может быть, и что-то очень кастомное. В любом случае, если уже сделан общий механизм (SEH), доработка такого оповещения уже мелкая проблема.

          > Кстати, интересно, как вы себе представляете всякие lock-free алгоритмы, адаптированные к тому, что в них в любой момент может произойти исключение? Не может ли от этого существенно снизиться производительность?

          1. Можно сделать что-то вроде EnterUninterruptible/ExitUninterruptible (такое уже давно делается в ядре). Где можно, сопровождать его через RAII. Забота автора кода — сделать время такой блокировки поменьше. В пределах процесса у нас всё равно, увы, зона полного взаимного доверия кода.
          2. Если и не делать, то предел затрат, мне кажется, одна переменная состояния (причём может быть, что и стек ей не нужен — если поместится в регистр; код обработки исключения, который таблично определён для данного участка основного кода, будет просто рассчитан на конкретное место хранения значения). Если в стеке — возможно, да, какой-нибудь write-release будет вытеснять и это значение в пространство памяти-кэша, и это будет чуть дороже. Но не думаю, что удорожание будет критичным.

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

          Предложенный в статье метод позволяет начать процесс перехода и делать его постепенно. Если на это уйдёт 10-20 лет — ничего страшного, лишь бы процесс пошёл.
          Ну а в достаточно современных средах и так можно будет пользоваться уже готовыми средствами с RAII (как в C++ векторы, умные указатели и прочее). Возможное усложнение кода (небольшое) будет в языках, где таких средств нет.

          > Я бы все же предпочел иметь систему, где malloc возвращает NULL в случае нехватки памяти.

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

          > Вот тут не понял. Кто чем соблазнился?

          Авторы Unix (как минимум) — вкусностями системы с объединением хранения данных памяти и copy-on-write, и, как следствие, lazy commit.