Из опыта использования SObjectizer: акторы в виде конечных автоматов – это плохо или хорошо?

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

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

Итак, в трех предыдущих статьях (раз, два и три) мы наблюдали за тем, как агент email_analyzer развивался от очень простого до более-менее сложного класса. Думаю, что у многих, кто посмотрел на финальный вариант email_analyzer-а, возник вопрос: «Но ведь это же очень сложно, неужели нельзя было проще?»

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

void some_agent::some_event() {
  ...
  // Отсылаем запрос.
  send< request >(receiver, reply_to, params…);
  // И сразу же ждем результ.
  auto resp = wait_reply< response >(reply_to);
  ... // Обрабатываем ответ.
}

Приходится писать вот так:

void some_agent::some_event() {
  ...
  // Чтобы получить результат нужно на него подписаться.
  so_subscribe(reply_to).event(&some_agent::on_response);
  // Отсылаем запрос.
  send< request >(receiver, reply_to, params...);
  // Больше оставаться в some_event нет смысла. 
  // Нужно вернуть управление диспетчеру дабы он смог вызвать
  // нас потом, когда придет ответ.
}
void some_agent::on_response(const response & resp) {
  ... // Обрабатываем ответ.
}

Отсюда и такой объем, и такая сложность у получившегося агента email_analyzer.

Возможно, и в таком подходе найдутся какие-то ухищрения, которые бы сократили объем писанины на 20-30%, но принципиально ситуация не изменится.

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

void email_analyzer(context_t ctx, string email_file, mbox_t reply_to) {
  try {
    // Выполняем запрос синхронно.
    auto raw_content = request_value< load_email_succeed, load_email_request >(
        ctx.environment().create_mbox( "io_agent" ),
        1500ms, // Ждать результата не более 1.5s
        email_file ).content_;

    auto parsed_data = parse_email( raw_content );

    // Запускаем агентов-checker-ов, которые будут отсылать результаты
    // в отдельный message chain, специально созданный для этих целей.
    auto check_results = create_mchain( ctx.environment() );

    introduce_child_coop( ctx,
      disp::thread_pool::create_disp_binder( "checkers",
        disp::thread_pool::bind_params_t{} ),
      [&]( coop_t & coop ) {
          coop.make_agent< email_headers_checker >(
              check_results, parsed_data->headers() );
          coop.make_agent< email_body_checker >(
              check_results, parsed_data->body() );
          coop.make_agent< email_attach_checker >(
              check_results, parsed_data->attachments() );
      } );

    // Т.к. все обработчики результатов будут очень похожи, то вынесем
    // их логику в отдельную локальную функцию.
    auto check_handler = [&]( const auto & result ) {
        if( check_status::safe != result.status )
          throw runtime_error( "check failed: " + result );
      } );

    // Ждем результатов не более 0.75s и прерываем ожидание, если
    // хотя бы один результат оказался неудачным.
    auto r = receive( from( check_results ).total_time( 750ms ),
      [&]( const email_headers_check_result & msg ) { check_handler( msg ); },
      [&]( const email_body_check_result & msg ) { check_handler( msg ); },
      [&]( const email_attach_check_result & msg ) { check_handler( msg ); } );

    // Если собраны не все ответы, значит истекло время ожидания.
    if( 3 != r.handled() )
      throw runtime_error( "check timedout" );

    // Ну а раз уж добрались сюда, значит проверка прошла успешно.
    send< check_result >( reply_to, email_file, check_status::safe );
  }
  catch( const exception & ) {
    send< check_result >( reply_to, email_file, check_status::check_failure );
  }
}

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

Наш опыт говорит о том, что в ситуациях, когда агент выполняет какой-то линейный набор операций вида «отослал запрос, тут же стал ждать единственный ответ», его реализация в виде конечного автомата будет невыгодной с точки зрения объема и сложности кода. Вместо простого ожидания ответа и непосредственного продолжения работы после его получения, агенту нужно завершать свой текущий обработчик события, а все остальные действия приходится выносить в другой обработчик. Если агент в течении своей жизни выполняет N последовательно идущих асинхронных операций, то у этого агента, скорее всего будет (N+1) обработчик. Что не есть хорошо, т.к. разработка и сопровождение такого агента будет отнимать много сил и времени.

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

Поскольку SObjectizer на данный момент поддерживает агентов только в виде конечных автоматов, то нужно тщательно оценить, насколько хорошо логика прикладных агентов ложится на конечные автоматы. Если не очень хорошо, то SObjectizer может быть не лучшим выбором и имеет смысл посмотреть на решения, которые используют сопрограммы. Например, boost.fiber или Synca (о последнем были интересные статьи на Хабре: №1 и №2).

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

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

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

Вылилось это в несколько негативных последствий.

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

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

Что еще плохо, так это увеличение объема информации, которая нужна для понимания происходящего в приложении. Возьмем наш пример с email_analyzer-ами. Один-единственный агент analyzer_manager может поставлять такие сведения, как общее количество ожидающих своей очереди запросов, общее количество живых агентов email_analyzer, минимальное, максимальное и среднее время ожидания запроса в очереди (аналогично и по временам обработки запросов). Поэтому контроль за деятельностью analyzer_manager не представляет из себя проблемы. А вот сбор, агрегация и обработка сведений от отдельных email_analyzer-ов – это уже сложнее. Причем, тем сложнее, чем больше этих агентов и чем короче их время жизни.

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

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

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

В результате хождения по этим граблям в самом начале использования SObjectizer в своих проектах мы пришли к выводу, что возможность создать миллион агентов – это в большей степени маркетинговый булшит, нежели востребованная в нашей практике* штука. И что подход, который стал известен под названием SEDA-way, позволяет строить приложения, которые намного проще контролировать и которые ведут себя намного предсказуемее.

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

Принципиальный момент в том, что в показанных ранее реализациях email_analyzer сам инициирует все операции, но только для одного конкретного email-а. А в SEDA-подходе у нас было бы по одному агенту на каждую операцию, но каждый агент мог бы делать ее сразу для нескольких email-ов. Кстати говоря, следы этого SEDA-подхода видны даже в наших примерах в виде IO-агента, который есть ни что иное, как stage-агент из SEDA.

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

Кстати говоря, можно отметить еще один момент, на который часто обращают внимание те, кто впервые знакомится с SObjectizer-ом: многословность агентов. Действительно, как правило, агент в SObjectizer – это отдельный C++ класс, у которого, как минимум, будет конструктор, будут какие-то поля, которые должны быть проинициализированы в конструкторе, будет переопределен метод so_define_agent(), будет несколько обработчиков событий в виде отдельных методов… Понятное дело, что для простых случаев все это приводит к изрядному синтаксическому оверхеду (с). Например, в Just::Thread Pro простой актор-логгер может выглядеть так:

ofstream log_file("...");
actor logger_actor( [&log_file] {
    for(;;) {
      actor::receive().match<std::string>([&](std::string s) {
          log_file << s << endl;
        } );
    }
  } );

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

class logger_actor : public agent_t {
public :
  logger_actor( context_t ctx, ostream & stream ) : agent_t{ctx}, stream_{stream} {}
  virtual void so_define_agent() override {
    so_subscribe_self().event( &logger_actor::on_message );
  }
private :
  ostream & stream_;
  void on_message( const std::string & s ) {
    stream_ << s << endl;
  }
};
...
ofstream log_file("...");
env.introduce_coop( [&log_file]( coop_t & coop ) {
  coop.make_agent< logger_actor >( log_file );
} );

Очевидно, что в SObjectizer писанины больше. Однако, парадокс в том, что если придерживаться SEDA-подхода, когда агентов не очень много, но они могут обрабатывать разные типы сообщений, код агентов довольно быстро распухает. Отчасти из-за самой логики работы агентов (как правило, более сложной), отчасти из-за того, что агенты наполняются дополнительными вещами, вроде логгирования и мониторинга. И тут-то и оказывается, что когда основной прикладной код агента имеет объем в несколько сотен строк, а то и больше, то размер синтаксического оверхеда со стороны SObjectizer-а совершенно незначителен. Более того, чем больше и сложнее агент, тем выгоднее оказывается его представление в виде отдельного C++ класса. На игрушечных примерах этого не видно, но в «боевом» коде ощущается довольно сильно (вот, скажем, маленький пример не самого сложного реального агента).

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

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


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

Автор: eao197

Источник

Поделиться новостью

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