понедельник, 18 февраля 2019 г.

[prog.c++] bad_alloc == приговор?

В принципе, свое мнение по поводу контроля или не контроля успешности выделения памяти в C++ программах, я в концентрированном виде высказал когда-то в комментариях на Хабре:

По опыту обсуждения подобной темы складывается ощущение, что в Интернетах есть две секты. Приверженцы первой свято уверены в том, что под Linux-ом malloc никогда не возвращает NULL. Приверженцы второй свято уверены в том, что если память в программе выделить не удалось, но ничего уже в принципе сделать нельзя, нужно только падать.

Переубедить их никак нельзя. Особенно когда эти две секты пересекаются. Можно только принять это как данность. Причем не суть важно, reddit это, Хабр, LOR или еще какой профильный ресурс.

Но в последние несколько недель регулярно приходится сталкиваться с навязыванием мнения, что как-либо бороться с нехваткой памяти бесполезно, что bad_alloc, по сути, является приговором. Причем происходит это так часто, что имеет смысл вновь вернуться к данной теме. Кому интересно, милости прошу под кат.

Итак, сторонники точки зрения, что bad_alloc == окончательный смертный приговор для приложения, как правило, оперируют двумя серьезными аргументами (чаще всего поотдельности, но самые продвинутые индивиды могут использовать сразу оба):

1. Наличие overcommit-а в Linux-е. Т.е. приложение может запросить у ОС больше памяти, чем есть на самом деле, а ОС вернет приложению лишь диапазон адресов в адресном пространстве, которые пока что не будут отображены на страницы физической памяти. Но в процессе отображения адресов на актуальную память может выяснится, что памяти на самом деле нет и тогда приложение будет просто убито. Ну не повезло, что поделать.

2. Разработка кода, который должен уметь продолжать работать даже когда памяти оказывается недостаточно, сложна. Такой код должен быть правильно спроектирован с самого начала, должен быть должным образом протестирован (что требует соответствующей тестовой среды, которую еще нужно создать). Все это сложно, долго и дорого. Поэтому дешевле и проще исходить из того, что приложение не сможет корректно продолжить свою работу при нехватке памяти.

Может показаться, что это очень веские аргументы, которым сложно что-то противопоставить. Но все не так уж драматично, как кажется.

По поводу overcommit-а можно сказать следующее:

Во-первых, Linux-ом мир не ограничивается. Сегодня вы пишете код, который работает на Linux-е, а завтра его уже без вас запустят на Windows или каком-нибудь QNX или VxWorks. Кроме того даже на Linux-е этот overcommit отключается в конфиге. Так что использовать аргумент "не нужно заботиться о нехватке памяти потому, что в Linux-е overcommit" -- это всего лишь признак пионерского красноглазия. С возрастом и опытом проходит. У большинства.

Во-вторых, даже если вы расчитываете на условия Linux-а с overcommit-ом, то нет смысла делать какую-то специфическую обработку bad_alloc-а, чтобы самостоятельно принудительно вызвать std::terminate или std::abort при возникновении bad_alloc-а. Вы просто не получите этого самого bad_alloc-а. Вам либо дадут память, либо убьют ваше приложение извне (на самом деле все не так просто, но чрезмерно усложнять не будем).

В-третьих, bad_alloc вы можете получить и в Linux-е с overcommit-ом, если в вашем приложении используется кастомный аллокатор (например, на базе размещенного на стеке пула), который сам бросает bad_alloc при неудачных вызовах allocate. Причем вы можете даже не подозревать о наличии этого самого кастомного allocator-а, т.к. он будет спрятан в потрохах сторонней библиотеки. Или даже не в сторонней библиотеке, а в куске вашего приложения, но написанного вашей коллегой, который не счел нужным рассказать вам про наличие такого аллокатора (либо вы забыли про это).

Гораздо интереснее со вторым аргументом, про разработку устойчивого к нехватке памяти кода.

Разработка такого кода, действительно, задача непростая. Но к этому вопросу лучше подойти как бы "с обратной стороны".

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

Я навскидку могу вспомнить два типа приложений, для которых нехватка памяти должна приводить к их немедленному останову и это нормально.

Во-первых, это различное специализированное ПО для работы в режиме жесткого реального времени. В таких приложениях динамическая память во время основной работы не используется вообще (т.к. new/delete с жестким реальным временем не дружат в принципе). В таких приложениях вся необходимая память выделяется при старте. Если в начале работы выясняется, что памяти недостаточно, то приложение просто завершается, т.к. работать оно не сможет в принципе. И это нормально, т.к. для таких приложений используются специальные контролируемые окружения, в которых ресурсов для приложения должно хватать.

Во-вторых, это различные утилиты/приложения, которые выполняют идемпотентные операции. Вроде утилиты grep или даже вашего C++ компилятора. Такие приложения могут пытаться захватить столько памяти, сколько нужно для их работы. А если памяти недостаточно, то они могут просто прекратить свою работу. И это нормально, т.к. a) результат все равно получен не будет в текущих условиях, и b) никакой порчи исходных данных не произойдет.

Я думаю, что для вменяемого читателя очевидно, что этими двумя типами приложений многообразие разработки на C++ не ограничивается. Вспоминается еще, как минимум, три типа приложений, для которых доведенный до абсолюта принцип fail-fast (т.е. "если чо, то сразу abort") не сильно применим:

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

2. Приложения, которые могут хранить часть важных пользовательских данных в оперативной памяти. Крах приложения приводит к потере этих данных. Например, CAD-система или графический редактор. Если пользователь не сохранил результаты своей работы, а приложение "грохнулось", то пользователю это не понравится.

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

Кстати говоря, тип №1 зачастую пересекается с типом №3, но это сейчас не суть важно.

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

Скажем, для СУБД мы должны будем использовать Write-Ahead-Log для того, что суметь докатить или откатить изменения в БД, если по каким-то причинам сделать это сразу не удалось. Причем здесь нам нужно будет заботится не только о таких вещах, как отсутствие памяти, но и сбой питания при фиксации транзакции.

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

Если мы делаем web-сервер, то мы должны будем позаботиться об изолированности обработки запросов. Чтобы проблемы с обработкой запросов от одного клиента не сказывались трагическим образом на параллельных запросах от других клиентов. Причем проблемы с обработкой запросов могут быть вызваны не только нехваткой памятью. Может не хватать и других ресурсов: недостаточное количество подключений к БД или потеря подключения к какому-то стороннему сервису.

В общем, если мы погружаемся в детали реализации более-менее отказоустойчивого приложения какого-то типа, то выясняется, что для обеспечения этой отказоустойчивости нужно применять целый комплекс мер. И для этого комплекса может быть не суть важно, возникает ли проблема нехватки памяти, отсутствия подключения к БД или отсутствия прав на чтение/запись файла в файловой системе. А раз так, то нет и смысла выводить обработку bad_alloc-а в какую-то особую категорию.

И тут мы плавно приходим к тому, а что мы можем сделать, чтобы вылет bad_alloc-а не был критичным для нашего кода?

С очень простым ответом: обычное обеспечение exception-safety и обычное разделение кода на логические слои.

Обеспечение exception-safety гарантирует нам отсутствие утечек и порчи ресурсов. В каких-то случаях мы даже сможем обеспечить strong exception-safety, что даст нам консистентное состояние и возможность нормально продолжать работу дальше. Причем все это работает вне зависимости от того, какой тип исключения вылетел: bad_alloc, range_error или еще что-нибудь.

Разделение кода на логические слои позволяет перехватывать и обрабатывать проблемы там, где это имеет смысл. Скажем, при обработке запроса в web-сервере у нас может быть вложенность функций в несколько уровней. Где-то глубоко внизу возникает ошибка невозможности открыть запрошенный пользователем файл. Эта ошибка нормальным образом обрабатывается двумя уровнями выше. Но остальные ошибки, включая bad_alloc идут дальше. Какие-то из них могут быть обработаны еще выше. Какие-то дойдут до самого верхнего уровня, где будет принято решение завершить обслуживание этого клиента. А если и здесь произойдет какая-то ошибка, которая не может быть обработана на данном уровне (например, мы хотели залогировать проблему и отослать клиенту какой-то специфический код, но не смогли сделать и этого), то тогда уже мы придем к тому же самому std::terminate вполне естественным образом. Но при этом мы по ходу попробовали сделать все, что могли.


Так что в сухом остатке: bad_alloc -- это не приговор. Относитесь к bad_alloc как к любому другому исключению: если можете его обработать -- обрабатывайте, не можете -- пробрасывайте наверх, но при этом обеспечивая exception-safety. А там, наверху, bad_alloc либо будет обработан, либо же приведет к std::terminate. Посему нет смысла переводить bad_alloc в std::terminate преждевременно.

PS. Если в C++программе не используются исключения, а применяется что-то вроде folly::Expected или Boost.Outcome, то ситуация принципиально не меняется. Все будет точно так же: exception-safety и прокидывание наверх кодов ошибок, которые невозможно обработать на текущем логическом уровне.

PPS. Проблемы разработчиков на plain old С меня не волнуют. Простите. Программисты на чистой сишечке должны страдать.

4 комментария:

Mikhail Glushenkov комментирует...

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

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

Анонимный комментирует...

> Проблемы разработчиков на plain old С меня не волнуют.

Ну, для разработчиков на C std::bad_alloc() ваще не проблема.

Dmitry Popov комментирует...

Когда делал свой Video Enhancer, который работал в винде и был 32-битным (ибо надо было 32-битные плагины от другого софта уметь поддерживать), там внутре был набор очередей с несжатыми видеокадрами, коих не так-то много в памяти 32-битной программы помещается, там исчерпание свободной памяти была штатная ситуация совершенно. Не получилось выделить память для нового большого кадра - ок, не будем пока, пусть предыдущие обработаются. При этом для мелочей память еще оставалась, так уже совсем везде-везде о ее нехватке можно было не заботиться.

eao197 комментирует...

@Dmitry Popov
Вот, кстати, да. В свое время в 32-х битах попасть на bad_alloc было вообще не сложно. Особенно при работе с растровой графикой.