Если вы сталкивались хотя бы раз, что важная задача была убита OOM killerʼом…
Заготовки к этой статье очень старые, но проблема ещё старее. Такое впечатление, что с 1980-х никто не заинтересован в её осмысленном решении, хотя жалобы на последствия, похоже, не писал только тот, кто вообще не работал с компьютером. Здесь я попытаюсь сформулировать общую картину и тот метод решения, который мне кажется способствующим хоть какому-то конструктивному решению.
(ходит птичка весело по тропинке бедствий, не предвидя от сего никаких последствий)
Начнём с азов (но без ненужных тут глубинных подробностей) и будем продвигаться к деталям.
Никакая современная система уровнем выше условного «компактного встроенного (embedded)», как AVR или младшие ARM, не обходится без виртуальной памяти.
Виртуальная память с точки зрения процессора актуального типа — это сопоставление адресу в виртуальной памяти адреса в физической памяти или признака отсутствия доступа по адресу, и прав доступа к физической памяти (что можно — читать, писать, исполнять), и это сопоставление для каждого процесса своё. Обычно этот доступ гранулирован страницами (4096 байт базовый размер на x86).
Но кроме реализации в процессоре, есть ещё реализация поддержки в ОС, и тут начинается много интересного.
Первые реализации работы пользовательских процессов в виртуальной памяти были примитивными и не всегда эффективными. По сравнению с современными реализациями, не использовались, или мало использовались:
Ситуация радикально изменилась с принятием в большинство современных систем механизмов, отработанных в экспериментальной разработке 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 в одной копии, что резко сокращает затраты памяти. В некоторых случаях она может объединять и изменённые, если они идентичны, но это уже очень дорогая проверка.
Для иллюстрации:
Эта часть устройства схожа и с 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:
Чтобы обеспечить гарантию доступности памяти для любого применения в любом случае, включаем режим 2… и ой, оно начинает хотеть наличия места в свопе на каждый кусочек формально выделенного места. (Поправку overcommit_ratio не учитываем, она только утяжеляет требования.) Как уже показывал, процесс, которому нужно от силы пару сотен мегабайт, но аллоцировано десяток-другой гигабайт — сейчас норма. Где столько свопа напастись на них? И, главное, зачем, если реально та память будет использоваться 1) на очень малую долю от своего объёма, 2) постепенно? Ставить отдельный 4TB винчестер на сервер? А в облаке как выживать?
Хуже всего, конечно, режим 1: тебе ни в чём формально не отказывают… до тех пор, пока ресурс не кончается. Там же в мане:
А ещё пишут, что так надо делать для Redis, потому что он аллоцирует память… слишком активно.
Или отзыв, один из первых найденных и очень показательный:
(дальше много разговоров из попытки обеспечить этот резерв через системные настройки… в общем успешно через жуткие костыли и с жёсткими ограничениями).
Думаю, можно найти ещё 100500 приложений, для которых явно написаны такие рекомендации… и не до седмижды повторяю: просто загляните в свою систему, у какого процесса VSZ в разы больше вашей оперативной памяти.
Другие ОС реализуют примерно похожие, но часто более мутные подходы. И, надо заметить, известные меры обеспечивают только память; я не видел такого обеспечения для файловых дескрипторов, процессов или нитей.
Я не представляю себе хорошо работающего варианта, кроме как создать запас ресурса заранее и обеспечить его гарантированное наличие, даже если он до наступления критического момента не используется. Все знают про вариант «заначка 1000 талеров в сейфе» (со всеми вариациями) — это он.
Отсюда вывод: Правильно спроектированная система должна позволять процессу держать запас такого ресурса, который далее может быть применён для операций свёртывания, автоматически задействовать этот запас, когда обычные источники ресурса кончаются, и немедленно оповещать о задействовании ресурса.
По той же аналогии, мы при старте «процесса» формируем заначку в, говорилось, 1000 талеров. Пока всё нормально, мы её не трогаем. Если деньги кончаются, мы берём запас из сейфа и тут же подымаем тревогу «мы что-то делаем не так! надо сокращать расходы».
Разумеется, этот запас должен учитываться в текущем потреблении процесса и сам выделяться согласно допустимым резервам (а как иначе?)
Как это могло бы быть реализовано? Это уже вопрос API, но попробуем предположить реализацию в стиле современного Linux на примере памяти.
1. Нам потребуются вызовы управления ресурсом.
Можно делать в старом стиле (0/-1 плюс errno) или новом (0 — OK, >0 — код из errno).
reserve_type может быть: память (константа например RESERVE_MEMORY), дескрипторы, нити.
reserved_size считается в единицах ресурса (для памяти допустимы байты, килобайты или стандартные страницы, может округляться вверх к допустимой границе).
Отсутствие резерва проверяется немедленно (да, это коммит, хоть и в скрытую область).
2. Нам потребуется средство немедленного оповещения о затрате ресурса. Так как любое достаточно серьёзное приложение сейчас, скорее всего, многонитевое, можно по аналогии с signalfd/timerfd/etc. завести reservefd, для которого задаётся порог следующего оповещения и можно читать сообщения о сокращении запаса ниже этого порога:
Если ядро задействует ресурс, то ставит в очередь сообщение (до 1 нечитанного на каждый тип) с указанием типа, перейденного граничного значения и текущего уровня. Если десктриптор оповещает про готовность к чтению, read() возвращает структуру отчёта.
Прочие методы (как посылка асинхронного сигнала) — по вкусу, в соответствии со стандартными практиками. В BSD-like системах (как MacOS) вместо reservefd предполагается новый вариант kevent filter.
Недостатки данного подхода:
1. Он требует явного обеспечения со стороны приложения. Это было бы не страшно, если бы подход был поддержан ядром лет 20 назад — за это время успели бы и в учебники вписать, и в большинстве приложений реализовать.
Но это не аргумент против реализации — когда-то всё-таки надо начать?
2. Заметная часть такой целевой обработки будет у самого толстого процесса, которое обычно есть основная цель OOM killer. Логика OOM killer требует притормаживания при наличии такой обработки в процессе. Это притормаживание требует серьёзного проектирования.
3. Он не поможет тем программам, которые не имеют такой защиты; для них должны работать более традиционные подходы. Я не предлагаю их отменять, данный метод должно работать как хорошее дополнение.
Как расширение подхода, имеет смысл подумать про средство оповещения родительского процесса (или другого назначенного управляющего процесса) одновременно с целевым. (Расширение подписки на любой процесс, для которого есть права?)
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 гугл потерял или неверно индексирует.)
Если хорошо что-то прокомментировать диаграммой — пишите про это, а то у меня пока нет фидбэка на это.
Заготовки к этой статье очень старые, но проблема ещё старее. Такое впечатление, что с 1980-х никто не заинтересован в её осмысленном решении, хотя жалобы на последствия, похоже, не писал только тот, кто вообще не работал с компьютером. Здесь я попытаюсь сформулировать общую картину и тот метод решения, который мне кажется способствующим хоть какому-то конструктивному решению.
(ходит птичка весело по тропинке бедствий, не предвидя от сего никаких последствий)
Проблема
Начнём с азов (но без ненужных тут глубинных подробностей) и будем продвигаться к деталям.
Никакая современная система уровнем выше условного «компактного встроенного (embedded)», как AVR или младшие ARM, не обходится без виртуальной памяти.
Виртуальная память с точки зрения процессора актуального типа — это сопоставление адресу в виртуальной памяти адреса в физической памяти или признака отсутствия доступа по адресу, и прав доступа к физической памяти (что можно — читать, писать, исполнять), и это сопоставление для каждого процесса своё. Обычно этот доступ гранулирован страницами (4096 байт базовый размер на x86).
Но кроме реализации в процессоре, есть ещё реализация поддержки в ОС, и тут начинается много интересного.
Первые реализации работы пользовательских процессов в виртуальной памяти были примитивными и не всегда эффективными. По сравнению с современными реализациями, не использовались, или мало использовались:
- Возможность совмещения в одной странице физической памяти разных страниц виртуальной памяти (одного процесса или нескольких). Самое простое применение: если у нас один бинарник запущен в нескольких экземплярах, и много процессов использует одну и ту же библиотеку, зачем держать много копий, если можно одну?
- Возможность подгрузки отдельных страниц только по необходимости. Симметрично, возможность выгрузки из памяти отдельных страниц, а не процесса целиком; собственно, отсюда поэтому в некоторых традициях разделяют 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 строк:
Если посмотреть в находящийся рядом smaps, видно для таких участков:
Здесь несмотря на отсутствие разрешения записи видно ненулевое значение Dirty (rконкретно, Shared_Dirty) — область изменена по сравнению с тем, что в файле. Флаг dw показывает, что эти изменения не подлежат записи в файл. Это область санков для перехода на динамически линкованные имена, после настройки при загрузке она закрыта от изменения.
А тут изменённых данных нет — при необходимости страницы будут просто освобождены, а когда потребуются снова — будут загружены из исходного файла. (Что bash вряд ли будет выгружен в обычной работе Linux — это другой вопрос, связанный с плотностью использования страниц.)
У области с r-wp появляется ещё и Private_Dirty — это персональные данные конкретного процесса (тут — модификация начальных значений глобальных переменных):
А здесь только память процесса, без базового файла:
Часть таких областей явно помечена [heap] и [stack], но эта оказалась без такой пометки. Выделение памяти под нужды процесса может производиться через sbrk(), mmap("/dev/zero") или другие подходы.
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 гугл потерял или неверно индексирует.)
Если хорошо что-то прокомментировать диаграммой — пишите про это, а то у меня пока нет фидбэка на это.