понедельник, 19 июля 2021 г.

[prog.experience] Несколько слов о работе с исключениями в arataga

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

Особенности arataga и их влияние на работу с исключениями

Во-первых, изначально проект arataga разрабатывался в сжатые сроки и требовалось как можно быстрее получить работающий прототип чтобы оценить перспективность всей затеи. Т.е. ключевым фактором являлся "time to market". Писать на C++ в условиях горящих сроков -- то еще занятие. Поэтому для нас важно было использовать практики, которые бы обеспечивали нам и достаточно высокую скорость разработки кода, и достаточную степень его надежности и корректности.

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

В-третьих, архитектура arataga подразумевала, что будет всего один процесс, который будет обслуживать все подключения. Откуда следует то, что принцип fail-fast в смысле "если возникла непредвиденная неприятность, то просто грохаем весь процесс" здесь был бы не очень уместен. Подключений десятки тысяч, с каким-то из них точно что-то пойдет не так. Ронять все приложение вместе со всеми остальными подключениями не есть хорошо. Как следствие, нам пришлось разбираться с тем, чтобы неожиданные исключения, с которыми мы не знаем что делать, не всегда вели к краху всего приложения, а в каких-то местах просто игнорировались бы.

В-четвертых, хотя arataga и должен был работать в режиме 24/7, но жестких требований к тому, чтобы arataga не падал в любых условиях, у нас не было. Эпизодические падения в непредвиденных ситуациях, скажем, раз в неделю, никого бы не напрягали. Поэтому мы могли не заморачиваться на тотальный контроль исключений и в каких-то местах могли позволить себе смотреть на ситуацию так: "ну, если уж здесь что-то вылетит, то пусть уж лучше все просто навернется и рестартует".

В-пятых, в arataga активно используются сторонние библиотеки, которые либо не очень приветствуют выброс исключений в callback-ах (как Asio, например), либо же вообще этого не приемлют (как, например, чисто C-шная http-parser, которая про C++ные исключения ни сном, ни духом). Но callback-и в эти сторонние библиотеки передаются C++ные, в которых активно используется C++ный код, бросающий исключения. И это нужно было как-то брать под контроль.

Несколько слов о важности темы исключений

Признаюсь, что я уже больше 20 лет не сталкивался с условиями, в которых исключения были бы под полным запретом. Более того, на мой рабоче-крестьянский взгляд, "современная разработка" на C++ без исключений -- это что-то из категории фантастики.

Что я подразумеваю под "современной разработкой"? (мне этот "термин" самому не нравится, но это лучшее из того, что удалось придумать)

Во-первых, это отсутствие необходимости контролировать каждую аллокацию в коде (будь то malloc/calloc или new). Грубо говоря, если мы свободно используем STL (особенно контейнеры из STL со стандартными аллокаторами) и не паримся на счет динамической памяти, а выделяем ее тогда, когда нам этого захочется. В том числе даже не подозревая об этом.

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

write_whole(
      can_throw,
      m_connection,
      m_response,
      [this, uname = std::move(username), passwd = std::move(password)]
      ( delete_protector_t delete_protector, can_throw_t can_throw )
      { 
         replace_handler(
               delete_protector,
               can_throw,
               [&]( can_throw_t )
               {
                  return make_command_stage_handler(
                        m_ctx,
                        m_id,
                        std::move(m_connection),
                        make_first_chunk_for_next_handler(
                                 std::move(m_first_chunk),
                                 m_auth_pdu.read_position(),
                                 m_auth_pdu.size() ),
                           std::move(uname),
                           std::move(passwd),
                           m_created_at );
               } );
      } );

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

Грубо говоря, если мы попытаемся переписать что-то вроде:

m_running_acls.emplace_back(
      acl_conf,
      io_thread_index,
      ::arataga::acl_handler::introduce_acl_handler(
            so_environment(),
            io_thread_info.m_timer_provider_coop,
            io_thread_info.m_disp.binder(),
            m_app_ctx,
            ::arataga::acl_handler::params_t{
                  io_thread_info.m_disp.io_context(),
                  acl_conf,
                  io_thread_info.m_dns_mbox,
                  io_thread_info.m_auth_mbox,
                  *(io_thread_info.m_timer_provider),
                  fmt::format( "{}-{}-{}-io_thr_{}-v{}",
                        acl_conf.m_protocol,
                        acl_conf.m_port,
                        acl_conf.m_in_addr,
                        io_thread_index,
                        m_config_update_counter ),
                  acl_id_seed,
                  config.m_common_acl_params
            }
      )
);

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

Да, я наслышан, что есть области, где исключения под запретом. Но это специфические области и в некоторых из них вообще нет проблемы с обработкой возникающих ошибок: тупо вызывается std::abort когда что-то идет не так. Однако, arataga под такие условия не попадает. Поэтому в arataga мы имеем обычный более-менее современный код на C++, в котором исключение может вылететь почти что отовсюду.

Что следует из того, что исключения могут вылететь откуда угодно?

Из того, что исключения могут вылетать откуда угодно следует два взаимоувязанных и противоречащих друг другу пункта:

  • с одной стороны, программировать становится проще, т.к. если исключения могут вылетать отовсюду, то нет смысла особо париться по поводу исключений -- ну летят себе, ну и пусть себе летят;
  • с другой стороны, ощущается тяжелый груз ответственности: при написании очередной строчки кода нужно задумываться о том, сможет ли программа восстановиться после возникновения исключения (т.е. будут ли соблюдены инварианты и обеспечена exception safety). Забавно, что такая же ровно проблема есть и при использовании кодов ошибок, но она воспринимается гораздо легче, может быть за счет иллюзии о том, что работать с кодами ошибок проще.

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

Деструкторы и swap

В C++ и раньше не приветствовался выброс исключений из деструктора, а после C++11, где деструкторы по умолчанию noexcept, это правило уже практически зафиксировано на уровне стандарта языка. Если мне не изменяет склероз, то деструктор своего класса можно пометить как noexcept(false) и получить деструктор, которому разрешено бросать исключения, но мы такими извращениями не увлекаемся.

Кроме деструкторов при разработке на C++ могут быть фрагменты, которые не должны бросать исключения. Например, функция swap. Писать код в условиях бросающих исключения swap... Ну такое себе.

Итак, что-то в коде должно быть noexcept. Либо явно (как swap), либо по умолчанию (как в случае деструкторов, хотя в каких-то случаях деструкторы следует явно помечать как noexcept override).

Но проблема в том, что где-то в noexcept-коде нам приходится вызывать бросающий исключения код. Например, сделать запись в лог в деструкторе. И в таких случаях было бы хорошо предпринимать какие-то действия, чтобы возникновение исключения в noexcept-коде не грохнуло все приложение (а ведь грохнет, т.к. при попытке выпуска исключения из noexcept-метода произойдет вызов std::terminate).

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

Отличный пример -- это логирование. Если логирование бросает исключение, то во многих случаях это неприятно, но вовсе не смертельно.

Скажем, в arataga может быть очередь запросов на резолвинг какого-то доменного имени. Резолвинг завершается и мы начинаем отдавать ответы проходя по этой очереди. А перед отсылкой ответа на очередной запрос логировать сей факт (мол, вот шлем ответ на запрос такой-то).

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

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

Обработчики ошибок

Местами приходится писать код по обработке ошибок. Скажем, в блоке catch.

Тут получается что-то среднее между двумя предыдущими пунктами. С одной стороны, зачастую код по обработке ошибок пишется так, чтобы исключения из него не выбрасывались (и здесь прямая аналогия с деструкторами или swap). Действительно, если при обработке ошибки нам нужно сделать, скажем, три действия для восстановления инвариантов, но на втором действии у нас вылетает исключение и третье действие мы не выполняем, то полноценной обработкой ошибки это вряд ли можно считать.

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

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

Callback-и

Речь о функциях (функторах-лямбдах), которые отдаются куда-то в сторонние библиотеки.

Тут мы зачастую оказываемся в ситуациях, когда callback-и вообще не должны выпускать наружу исключения. Либо потому, что callback отдается в C-шную библиотеку, которая про C++ные исключения не сном, ни духом. Либо потому, что callback отдается в библиотеку, которая не имеет средств для обработки вылетевшего из callback-а исключения (либо же эти средства крайне примитивные и сводятся к тому, чтобы вызвать std::abort сразу или же что-то перед этим еще и залогировать).

С одной стороны с callback-ами все просто: достаточно поместить код callback-а внутрь try+catch(...). С другой, внутри callback-а так же приходится разбираться с тем, какой именно кусок кода бросил исключение -- критически важный или же вспомогательный.

Операции, которые должны иметь транзакционный статус

Классический случай -- это когда нам нужно внести информацию в несколько контейнеров.

Например, приходит очередной запрос на резолвинг доменного имени и мы включаем информацию об этом запросе в:

  • очередь ожидающих своей очереди запросов;
  • словарь активных запросов с доменным именем в качестве ключа (он может потребоваться для избежания дублирования одинаковых запросов к name server-ам);
  • словарь ждущих запросов с текущим временем в качестве ключа (он может потребоваться для быстрого определения запросов с истекшим временем жизни).

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

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

Если бы в arataga возник вопрос обеспечения транзакционности составных операций то, вероятно, do_with_rollback_on_exception из SObjectizer-а был бы переиспользован в коде arataga.

Существующий в C++ noexcept -- это только часть того, что хотелось бы иметь

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

Главная польза от спецификатора noexcept -- это указание на то, что можно не беспокоится о выбросе исключения из noexcept функции/метода. Т.е., если я пишу фрагмент кода, который не должен бросать исключения (вроде деструктора), то я смело могу вызывать noexcept функции/методы.

Что, безусловно, хорошо.

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

Спецификатор noexcept -- это вовсе не о принципиальном отсутствии исключений

Первая и, возможно, самая важная штука -- это тот факт, что noexcept в описании функции/метода говорит не о том, что исключение не может вылететь и что функция/метод всегда возвращает управление без выброса исключения. Исключение-то как раз вылететь может и это приведет к прерыванию работы всего приложения через std::terminate. Т.е. исключение вылететь может, просто ваше приложение это уже не будет беспокоить в связи со смертью этого самого приложения.

Почему это важно?

Ну вот arataga предназначен для обработки десятков тысяч параллельных соединений. С какими-то из этих соединений что-то обязательно пойдет не так, будет диагностирована какая-то ошибка, которую нужно как-то обработать. В процессе обработки могут быть вызваны noexcept функции/методы с расчетом на то, что уж тут-то мы исключений не ждем. Ведь кажется же, что если при обработке ошибки внутри catch-а я вызываю noexcept функцию, то ждать неприятностей неоткуда.

Но вот если внутри noexcept функции/метода исключение все-таки выскакивает, то медным тазом накрывается вообще все. Включая десятки тысяч соединений, с которыми все было нормально.

Так что первый момент, который нужно осознать при использовании спецификатора noexcept -- это то, что noexcept не гарантирует отсутствия исключений. Он гарантирует нечто другое: вам не придется ловить исключения в этом месте. Т.к. их либо нет от слова совсем. Либо уже нет вашего приложения.

К счастью, осознание этого факта пришло ко мне задолго до начала разработки arataga. Но при работе над arataga в этом удалось еще раз убедится. Посредством очередных набитых шишек :)

В C++ нет удобных штатных средств проверки того, что в каком-то куске кода вызываются только noexcept функции/методы

Предположим, что вы пишете что-то вроде:

try {
   ...
}
catch(...) {
   restore_invariant_one();
   restore_invariant_two();
   throw;
}

Ваша задача -- обеспечить завершение работы обоих вызовов restore_invariant_* в блоке catch. А это уже зависит от того, помечены ли restore_invariant_* как noexcept или нет.

Если они noexcept, то показанный выше код можно считать приемлемым.

Но где гарантии, что они noexcept?

А гарантии эти на уровне "мамой клянусь!"

Т.е. в момент написания кода вы могли добросовестно посмотреть на сигнатуры restore_invariant_* и убедиться, что там есть спецификатор noexcept. Но если кто-то решит, что restore_invariant_one() не должен быть noexcept и уберет этот спецификатор, то написанный вами catch() перестанет решать поставленную перед ним задачу.

Поэтому лично мне очень не хватает в C++ отдельного блока noexcept, код внутри которого проверялся бы компилятором на предмет наличия не-noexcept вызовов. Так, я бы очень хотел иметь возможность писать что-то вроде:

try {
   ...
}
catch(...) {
   noexcept {
      restore_invariant_one();
      restore_invariant_two();
   }
   throw;
}

и получать по рукам от компилятора если какой-то из вызовов внутри noexcept-блока не является noexcept.

При наличии такого noexcept-блока изменение сигнатуры restore_invariant_one() привело бы к появлению должной диагностики во время компиляции. Что дало бы возможность поправить код и вновь привести его к корректному виду.

Отчасти проблему отсутствия noexcept-блока в C++ можно решить самодельными средствами. Но тогда получается что-то вроде:

try {
   ...
}
catch(...) {
   NOEXCEPT_CTCHECK_ENSURE_NOEXCEPT_STATEMENT(restore_invariant_one());
   NOEXCEPT_CTCHECK_ENSURE_NOEXCEPT_STATEMENT(restore_invariant_two());
   throw;
}

Выглядит коряво. Но хотя бы работает.

noexcept в одном компиляторе не обязательно будет noexcept в другом

Суровая правда жизни, с которой довелось столкнуться. Какие-то методы в стандартной библиотеке C++ из одного компилятора могут быть помечены как noexcept (скажем, конструкторы std::string_view или operator* для итератора), а в другой стандартной библиотеке C++ от другого компилятора эти же методы уже не будут иметь такой отметки.

Из-за этого вы можете написать кусок кода с самодельными проверками на noexcept-вызовы, который будет успешно собираться gcc в Linux-е. Но получите ошибки компиляции clang-ом во FreeBSD или macOS.

Подходы для работы с исключениями в arataga

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

Использование спецификатора noexcept

Прежде всего активно начал использоваться спецификатор noexcept.

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

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

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

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

Тут нужно отметить, что на начальной фазе разработки arataga спецификаторы noexcept иногда ставились исходя из соображений "если отсюда полетит исключение, то проще слить воду, т.к. нет времени на поиск нормального решения". Поэтому где-то noexcept играл роль последней линии защиты: лучшее, что можно было сделать в тех условиях, -- это прервать работу приложения.

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

Поэтому можно сказать и так: на начальных этапах разработки arataga спецификатор использовался и в качестве обозначения контракта для функции/метода, и для предотвращения распространения ошибок, с которыми мы не можем справится, посредством прерывания работы всего приложения. В последствии мы постарались сделать так, чтобы noexcept в качестве "последней линии обороны" использовался как можно реже.

Использование штатных политик реагирования на исключения SObjectizer-а

Проект arataga написан с использованием SObjectizer-а и какая-то часть операций в arataga выполняется SObjectizer-овскими агентами.

В SObjectizer есть политики реакции на исключения, которые вылетают из обработчиков событий агентов. По умолчанию SObjectizer логирует факт вылета исключения и прерывает работу приложения посредством std::abort(). Можно сказать, что по умолчанию обработчики событий у агентов как будто бы помечены спецификатором noexcept.

Агенты в arataga написаны с учетом этого фактора. Поэтому зачастую в обработчиках событий агентов arataga нет перехвата и обработки потенциально возможных исключений (можно посмотреть, например, код агента startup_manager::a_manager_t). Логика работы таких агентов просто не предусматривает восстановления после исключения: действия внутри обработчика события должны нормально завершиться, либо же продолжать работать нет смысла.

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

Маркер can_throw

Первая серьезная проблема касательно исключений обнаружилась по мере увеличения количества callback-ов, передаваемых в Asio и http-parser.

Если объяснять на пальцах, то суть в том, что есть разнообразные объекты под условным названием connection_handler. У этих объектов есть какие-то собственные методы. Часть этих методов может быть вызвана как из кода, в котором исключения могут свободно выбрасываться, так и из callback-ов, в которых исключения под запретом.

Когда connection_handler-ов мало и они небольшие по объему, то контролировать безопасность вызова того или иного метода в том или ином контексте можно тупо посредством code review.

Но количество connection_handler-ов растет, как и их объем вместе со сложностью. И уже хочется иметь автоматизированный контроль за тем, вызывается ли метод там, где исключения разрешены или нет.

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

В деталях это решение описывалось в отдельной статье на Хабре, поэтому повторяться не буду. Здесь же лишь озвучу общие впечатления от использования данной практики:

  • во-первых, появляется некоторая многословность и что-то вроде церемониальности в коде. Если раньше можно было писать как вздумается, то сейчас нужно не забывать про can_throw и прокидывать этот маркер в вызываемые методы. Все это немного напрягает и отвлекает. По крайней мере до тех пор, пока не сформируется должный навык написания/чтения подобного кода;
  • во-вторых, "коэффициент спокойного сна" вырос настолько, что мы просто перестали беспокоится об исключениях внутри callback-ов.

Так что, не смотря на свои недостатки, трюк с can_throw в коде arataga пока себя оправдывает.

Набор макросов под условным названием nothrow_block

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

История банальная: вначале мы делали arataga ну в очень резвом темпе, поэтому времени на проработку некоторых деталей не было. Пишешь какую-то функцию и ловишь себя на том, что если внутри вылетит исключение, то никакой exception safety здесь не обеспечивается. Но время есть только на то, чтобы реализовать happy path, не заморачиваясь на обработку ошибок.

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

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

Результатом стал набор макросов под условным названием nothrow_block.

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

forauto it = m_ongoing_requests.begin();
      it != m_ongoing_requests.end(); )
{
   if( it->second.m_start_time + m_dns_resolving_timeout < now )
   {
      // This item has to be deleted due to timeout.

      // Ignore exceptions from logger.
      ARATAGA_NOTHROW_BLOCK_BEGIN()
         ARATAGA_NOTHROW_BLOCK_STAGE(log_timeout)

         ::arataga::logging::direct_mode::debug(
               [&]( auto & logger, auto level )
               {
                  logger.log(
                        level,
                        "{}: request timed out, "
                              "id={}",
                        m_params.m_name,
                        it->first );
               } );
      ARATAGA_NOTHROW_BLOCK_END(JUST_IGNORE)

      ARATAGA_NOTHROW_BLOCK_BEGIN()
         ARATAGA_NOTHROW_BLOCK_STAGE(send_negative_response)

         so_5::send< lookup_response_t >(
               it->second.m_reply_to,
               failed_lookup_t{ "request timed out" },
               it->second.m_result_processor );
      ARATAGA_NOTHROW_BLOCK_END(LOG_THEN_IGNORE)

      // Item is no more needed. In the case of an exception
      // we can't trust m_ongoing_requests anymore.
      ARATAGA_NOTHROW_BLOCK_BEGIN()
         ARATAGA_NOTHROW_BLOCK_STAGE(remove_timed_out_req_from_ongoing_requests)
         auto it_to_erase = it++; // Hope it doesn't throw.
         m_ongoing_requests.erase( it_to_erase );
      ARATAGA_NOTHROW_BLOCK_END(LOG_THEN_ABORT)
   }
   else
      ++it;
}

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

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

Именно это и делается внутри первого блока из ARATAGA_NOTHROW_BLOCK_BEGIN и ARATAGA_NOTHROW_BLOCK_END.

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

Именно это и происходит внутри второго блока из ARATAGA_NOTHROW_BLOCK_BEGIN и ARATAGA_NOTHROW_BLOCK_END. Причем если при логировании факта возникшего исключения в ARATAGA_NOTHROW_BLOCK_END вылетит еще одно исключение, то оно будет проигнорировано.

Ну а дальше нам остается только удалить описание просроченного запроса. Что и делается внутри третьего блока ARATAGA_NOTHROW_BLOCK_BEGIN и ARATAGA_NOTHROW_BLOCK_END.

В принципе, здесь исключений быть-то и не должно.

Не должно-то оно не должно. Но где хоть какие-то гарантии для этого? Ведь метод std::map::erase спецификатором noexcept не помечен.

А теперь подумаем: если мы вызвали std::map::erase, а тот бросил исключение, то в состоянии ли мы продолжать работу? Откуда мы знаем, в каком именно состоянии остался std::map и можем ли мы продолжать работать с этим экземпляром std::map?

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

Вообще-то говоря, третий фрагмент кода можно было бы переписать и без макросов, просто сделав вот так:

[&]() noexcept {
   auto it_to_erase = it++; // Hope it doesn't throw.
   m_ongoing_requests.erase( it_to_erase );
}();

Т.е. создается "локальная функция" в виде noexcept-лямбды, которая тут же и вызывается. Если внутри такой лямбды что-то выбрасывается, то работа приложения автоматически прерывается посредством std::terminate.

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

Общие впечатления от использования исключений в коде arataga

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

Создание недостающего вам из подручных средств

Первый момент состоит в том, чтобы вовремя приостанавливаться, переосмысливать рутину, которой приходится заниматься, и придумывать упрощающие жизнь трюки/инструменты, вроде описанных выше can_throw и nothrow_block.

Тут есть два фактора, которые мешают это делать.

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

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

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

Мое мнение в том, что решаться на такие вещи все-таки нужно. Зачастую принцип "лучше день потерять, зато потом за полчаса долететь" таки работает. В основном.

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

Привычка видеть вылетающие отовсюду исключения

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

Вначале был вот такой фрагмент:

catchconst std::exception & x )
{
   // We have to catch and suppress exceptions from here.
   try
   {
      ::arataga::utils::exception_handling_context_t ctx;

      log_and_remove_connection(
            delete_protector,
            ctx.make_can_throw_marker(),
            remove_reason_t::unhandled_exception,
            spdlog::level::err,
            fmt::format( "exception caught: {}", x.what() )
         );
   }
   catch( ... ) {}
}

Со временем этот фрагмент стал вот таким:

catchconst std::exception & x )
{
   // We have to catch and suppress exceptions from here.
   ARATAGA_NOTHROW_BLOCK_BEGIN()
      ARATAGA_NOTHROW_BLOCK_STAGE(log_and_remove_connection)

      ::arataga::utils::exception_handling_context_t ctx;

      //FIXME: what if fmt::format throws?
      log_and_remove_connection(
            delete_protector,
            ctx.make_can_throw_marker(),
            remove_reason_t::unhandled_exception,
            spdlog::level::err,
            fmt::format( "exception caught: {}", x.what() )
         );
   ARATAGA_NOTHROW_BLOCK_END(LOG_THEN_IGNORE)
}

Тут потенциальная проблема была диагностирована, но еще не исправлена.

А проблема серьезная. Т.к. fmt::format вполне себе может бросить исключение. Но, даже если такое исключение выскочило, то сам вызов log_and_remove_connection все равно должен быть выполнен.

В итоге этот фрагмент был переписан следующим образом:

catchconst std::exception & x )
{
   // We have to catch and suppress exceptions from here.
   ARATAGA_NOTHROW_BLOCK_BEGIN()
      ARATAGA_NOTHROW_BLOCK_STAGE(log_and_remove_connection)

      connection_remover_t remover{
            *this,
            delete_protector,
            remove_reason_t::unhandled_exception
      };

      ::arataga::utils::exception_handling_context_t ctx;

      easy_log_for_connection(
            ctx.make_can_throw_marker(),
            spdlog::level::err,
            format_string{ "exception caught: {}" },
            x.what() );
   ARATAGA_NOTHROW_BLOCK_END(LOG_THEN_IGNORE)
}

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

Так что, пожалуй, самое сложное при использовании исключений в C++ (да и не только в C++) -- это постоянный контроль за тем, в каком состоянии мы окажемся, если исключение вылетит, и сделаем ли мы то, что должны были бы сделать несмотря на исключение.

Приобретение такого навыка требует времени и опыта.

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

Вместо заключения

Изначально намеревался в этот пост включить и описание использованных в arataga подходах для работы с исключениями, и свое отношение к механизму исключений в C++. Но текста получалось ну очень уж много и работать с ним становилось все тяжелее, поэтому решил разбить все на два поста. В этом, в основном, говорил об опыте arataga. В следующем, который надеюсь опубликовать через день-два-три, уже буду говорить о том, что в существующем C++ мне не нравится и что хотелось бы видеть.

Комментариев нет: