Нежная дружба агентов и исключений в SObjectizer

в 14:29, , рубрики: actor model, c++, c++11, concurrency, error handling, message-passing, multithreading, open source, Программирование

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

В небольшом однопоточном приложении довольно просто: можно прервать всю работу и рестартовать. Это один из факторов, благодаря которому Erlang снискал себе заслуженную популярность, ведь идеология fail fast является одним из краеугольных камней Erlang-а с его легковесными процессами. Если же приложение большое, сложное и многопоточное, то не разумно рестартовать все приложение, если лишь одна из его нитей столкнулась с проблемами. Еще хуже в ситуации с реализациями Модели Акторов, в которых сотни тысяч акторов могут работать на десятках рабочих нитей. Проблема одного актора вряд ли должна сказываться на всех остальных акторах.

В данной статье мы расскажем, как мы подошли к обработке ошибок в своем фреймворке SObjectizer.

Исключениям – да, кодам возврата – нет!

Когда SObjectizer-4 появился в 2002-ом году, мы сделали большую ошибку – предпочли использовать коды возврата исключениям. И весь последующий опыт разработки на SObjectizer-4 снова и снова убеждал в одной простой истине: если ошибка может быть прогнорирована разработчиком, то она будет им проигнорирована. Поэтому при создании SObjectizer-5 мы решили использовать исключения для информирования об ошибках.

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

Итак, SObjectizer-5 выбрасывает исключение, если не может выполнить ту или иную операцию. Чаще всего эти операции выполняются уже зарегистрированными в SObjectizer-е агентами. Что же делать агенту, если он столкнулся с исключением?

Нормальный агент не должен выпускать исключения наружу!

Это главное правило, которое существует для агентов касательно исключений. Если агент при обработке своего события получает исключение (не важно, выбросил ли исключение SObjectizer или кто-то другой), то агент не должен выпускать это исключение наружу.

Объяснение простое. Агент в SObjectizer не владеет собственным рабочим контекстом. Грубо говоря, агент не владеет рабочей нитью, на которой он работает. Рабочий контекст предоставляется диспетчером, к которому привязан агент, на время обработки очередного события, а затем может быть предоставлен другому агенту. Когда некий агент выпускает исключение наружу, то это исключение попадет к диспечеру, выделившему рабочий контекст. Если приложение не хочет, чтобы диспетчер решал, убить ли приложение или позволить продолжить работу, то агентам этого приложения стоит самим озаботится обработкой исключений.

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

Как же быть в том неидеальном мире, в котором мы живем?

SObjectizer-у можно подсказать, как реагировать на вылетевшее из агента исключение

Так как shit таки happens время от времени, то даже когда мы беремся обеспечивать no exception guarantee для агентов, то можем ошибиться и исключение все-таки уйдет наружу. Его поймает диспетчер и будет решать, что же делать дальше.

Для этого диспетчер вызовет у проблемного агента виртуальный метод so_exception_reaction(). Этот метод должен возвращать одно из следующих значений:

  • so_5::abort_on_exception. Что приведет к вызову std::abort() и прерыванию работы всего приложения;
  • so_5::shutdown_sobjectizer_on_exception. Это значение означает, что агент обеспечивает basic exception guarantee (т.е. отсутствие утечек ресурсов и/или порчи чего-либо), но продолжать дальше работу смысла нет. Поэтому агент переводится в специальное состояние, в котором агент не может обрабатывать никаких событий, а работа SObjectizer Environment завершается нормальным образом, без вызова std::abort(). При этом должным образом дерегистрируются все зарегистрированные кооперации, что дает возможность остальным агентам нормально завершить свою работу и почистить ресурсы. Отметим, что в приложении одновременно может работать несколько SObjectizer Environment. В случае shutdown_sobjectizer_on_exception завершается работа только того SObjectizer Environment, в котором исключение было перехвачено;
  • so_5::deregister_coop_on_exception. Это значение означает, что агент обеспечивает basic exception guarantee и приложение может продолжить свою работу без этого агента и его кооперации. Поэтому агент переводится в специальное состояние, а его кооперация обычным образом дерегистрируется (что дает возможность остальным агентам кооперации нормально завершить свою работу);
  • so_5::ignore_exception. Это значение означает, что агент обеспечивает strong exception guarantee (т.е. нет утечек ресурсов и/или порчи чего-либо + агент остается в корректном состоянии) и может продолжать свою работу. Поэтому диспетчер просто игнорирует исключение, как будто его и не было.

Наличие такого варианта, как ignore_exception может показаться странным после того, как было заявлено, что нормальные агенты не должны выпускать исключения наружу. Однако, на практике наличие такого значения удобно для агентов с очень простыми обработчиками событий. Например, агент получает сообщение типа M1 и преобразует его в сообщения типа M2. При преобразовании может возникнуть исключение, но оно мало на что влияет: состояние агента не нарушено, сообщение M2 потерялось, ну так сообщения могут теряться по тем или иным причинам. В таких случаях проще позволить исключениям вылетать из простых агентов дабы диспетчер проигнорировал их, нежели включать в каждый обработчик событий блок try-catch.

Таким образом, программист может сам решить, какой вариант лучше всего подходит для его агента, переопределить метод so_exception_reaction() и тем самым проинформировать SObjectizer о том, как быть после перехвата исключения:

using namespace so_5;
// Простой агент, который получает сообщение M1 из mbox-а src и преобразует его
// в сообщение M2 для mbox-а dest. Исключения, которые могут быть выпущены
// наружу, игнорируются.
class my_simple_message_translator final : public agent_t {
public :
  my_simple_message_translator( context_t ctx, mbox_t src, mbox_t dest )
    : agent_t( ctx ) {
    so_subscribe( src ).event( [dest]( const M1 & msg ){  send< M2 >( dest, ... );} );
  }
  // Указываем SO-5, что исключения могут быть проигнорированы.
  virtual exception_reaction_t so_exception_reaction() const override {
    return ignore_exception;
  }
};

Реакция на исключения на уровне кооперации

Штатная реализация agent_t::so_exception_reaction() дергает метод exception_reaction() у кооперации, в которую входит агент. Т.е. по умолчанию агент наследует реакцию на исключение у своей кооперации. А эту реакцию можно задать при регистрации кооперации. Например:


// Регистрируем кооперацию и указываем, что при вылете исключения
// из любого из агентов нужно дерегистрировать всю кооперацию.
env.introduce_coop( []( coop_t & coop ) {
  coop.set_exception_reaction( deregister_coop_on_exception );
  coop.make_agent< some_agent >(...);
  ...
} );

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

Но что происходит, если при создании кооперации метод set_exception_reaction() не вызывается (а в большинстве случаев он не вызывается)?

Если программист не вызывал coop_t::set_exception_reaction() явно, то coop_t::exception_reaction() вернет специальное значение – so_5::inherit_exception_reaction. Это значение указывает, что кооперация наследует реакцию на исключение у своей родительской кооперации. Если эта родительская кооперация есть, то SObjectizer вызовет exception_reaction() для нее. Если и родительская кооперация возвратит значение so_5::inherit_exception_reaction, то SObjectizer вызовет exception_reaction() для родителя родительской кооперации и т.д.

В конце-концов может оказаться, что нет очередной родительской кооперации. В этом случае SObjectizer вызовет exception_reaction() для всего environment_t. А уже environment_t::exception_reaction() вернет значение so_5::abort_on_exception. Что и приведет к краху всего приложения через вызов std::abort().

Нежная дружба агентов и исключений в SObjectizer - 1

Однако, программист может задать реакцию на исключения для всего SObjectizer Environment. Это делается через настройки свойств SObjectizer-а при запуске:

so_5::launch( []( environment_t & env ) {...},
  []( environment_params_t & params ) {
    params.exception_reaction( shutdown_sobjectizer_on_exception );
    ...
  } );

Небольшое промежуточное резюме

Итак, если агент выпускает наружу исключение, то SObjectizer перехватывает его и спрашивает у агента, что делать с исключением через вызов agent_t::so_exception_reaction(). Если программист не переопределял so_exception_reaction(), то реакцию на исключение определяет кооперация, в которую входит агент.

Обычно кооперация говорит SObjectizer-у, что она наследует реакцию на исключение от своего родителя. И SObjectizer будет спрашивать родительскую кооперацию. Затем родителя родительской кооперации и т.д. А когда родители закончатся, SObjectizer спросит реакцию на исключение у environment_t, внутри которого работает проблемный агент. По умолчанию environment_t скажет, что работу приложения нужно прервать через вызов std::abort().

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

  • в самом агенте, перехватывая все исключения внутри событий агента или же переопределяя so_exception_reaction();
  • в кооперации агента или в родительских кооперациях;
  • в SObjectizer Environment, внутри которого работают агенты и кооперации.

Как среагировать на дерегистрацию кооперации?

Как показано выше, SObjectizer может реагировать на выпущенные из агента исключения по-разному. Может, например, дерегистрировать только проблемную кооперацию. Но какой смысл в этой реакции? Ведь кооперация решала какую-то прикладную задачу в приложении, а если бы не решала, то ее бы и не было. И тут эта кооперация внезапно исчезает… Как об этом узнать и как на это среагировать?

SObjectizer позволяет получить уведомление о том, что какая-то кооперация оказалась дерегистрирована. Чем-то этот механизм напоминает возможность мониторинга процессов в Erlang: например, можно вызывать erlang:monitor(process, Pid) и, если процесс Pid завершается, то приходит сообщение {‘DOWN’,...}.

В SObjectizer есть возможность «повесить» нотификатор на событие дерегистрации. Нотификатор – это функтор, который SObjectizer вызовет автоматически, когда завершит дерегистрацию кооперации. В этот функтор SObjectizer передаст и имя дерегистрированной кооперации, и причину ее дерегистрации. Этот функтор может делать то, что нужно приложению. Например, можно отослать какому-то заинтересованному агенту сообщение об исчезновении кооперации. А можно просто перерегистрировать кооперацию:

// Простой пример демонстрирующий автоматическую перерегистрацию кооперации
// если она была дерегистрирована из-за возникновения исключения.
#include <iostream>

#include <so_5/all.hpp>

void start_coop( so_5::environment_t & env )
{
   env.introduce_coop( [&]( so_5::coop_t & coop ) {
      struct raise_exception : public so_5::signal_t {};

      // Единственный агент в данной кооперации.
      // Будет выпускать наружу исключение через секунду после старта.
      auto agent = coop.define_agent();
      agent.on_start( [agent] {
            so_5::send_delayed< raise_exception >( agent, std::chrono::seconds(1) );
        } )
        .event< raise_exception >( agent, [] {
            throw std::runtime_error( "Just a test exception" );
        } );

      // Предписываем SObjectizer-у дерегистрировать кооперацию в случае исключения.
      coop.set_exception_reaction( so_5::deregister_coop_on_exception );

      // Вешаем нотификатор, который проверит причину дерегистрации кооперации
      // и, если было исключение, создает эту же кооперацию заново.
      coop.add_dereg_notificator(
        []( so_5::environment_t & env,
            const std::string & coop_name,
            const so_5::coop_dereg_reason_t & why )
        {
            std::cout << "Deregistered: " << coop_name << ", reason: "
               << why.reason() << std::endl;

            if( so_5::dereg_reason::unhandled_exception == why.reason() )
               start_coop( env );
        } );
   } );
}

int main()
{
   so_5::launch( []( so_5::environment_t & env ) {
        // Создаем кооперацию в первый раз. 
        start_coop( env );
        // Даем некоторое время для работы.
        std::this_thread::sleep_for( std::chrono::seconds( 5 ) );
        // И останавливаемся.
        env.stop();
      } );
}

Готовой системы супервизоров, как в Erlang-е, в SObjectizer-е нет. Как-то получалось обходиться без нее. Но, если для прикладной задачи она нужна, то можно собрать что-то похожее на базе нотификаторов.

Небольшое философское замечание напоследок

C++ – это небезопасный язык. И написание кода, который обеспечивает хотя бы базовые гарантии безопасности исключений, требует определенных усилий от разработчика. Посему, при реализации акторов в C++, нужно осмотрительно относиться к использованию принципа fail fast. Это в Erlang-е хорошо – если в процессе обнаружили какую-то проблему, то просто убили процесс, после чего Erlang VM почистила все за ним, а соответствующий супервизор выполнил запуск нового процесса вместо сбойного.

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

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

Заключение

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

Заодно, пользуясь случаем, приглашаем посетить конференцию Corehard C++ Autumn 2016, которая пройдет 22-го октября в Минске. И на которой будет доклад о Модели Акторов применительно к C++. В том числе и про SObjectizer.

Автор: eao197

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js