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

    Познакомив читателей с фреймворком 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-а.




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

    • +13
    • 2,7k
    • 6
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 6

      +1
      Странно, что к вашим статьям так мало комментариев.
      Во многих их нет вообще. Пожалуй, оживлю немного ситуацию, хоть это и некрофильство.

      В ходе прочтения вашего цикла статей у меня возник вопрос: вы говорите, что IO-агент из предыдущих статей является stage-агентом, потому как он занимается только IO операциями. Следуя этой логике можно было бы заметить, что остальные агенты, для обработки частей email'а тоже занимаются только своим делом. Но почему же тогда они не являются stage-агентами?
        +1
        Странно, что к вашим статьям так мало комментариев.
        Видимо, скучные и не формат.
        Но почему же тогда они не являются stage-агентами?
        Если я правильно понимаю staging из SEDA, то stage-компонент (агент) выполняет одну и ту же операцию для разных транзакций. Соответственно, stage-компонент живет дольше, чем одна конкретная транзакция.
        У меня же в предыдущей статье агенты вроде email_headers_checker и email_body_checker выполняют всего одну операцию всего для одной транзакции. Соответственно, когда конкретный агент email_headers_checker свою работу для своей транзакции завершает, он уничтожается, а эту же операцию для другой транзакции будет делать другой экземпляр email_header_checker.
        Если бы email_header_checker был stage-агентом, он бы выполнял свою операцию для разных транзакций и не исчезал бы после обслуживания очередной транзакции.
          0
          Наоборот, статьи довольно интересные. Затрагиваются реальные проблемы, доходчиво объясняются их причины и решения на итерационных примерах улучшения кода. Это очень круто и почитать статьи интересно даже в отрыве от SObjectizer. Хочется надеяться, что комментариев нет только потому, что людям нечего добавить, а просто писать «спасибо за статью» на хабре не принято. :)

          Итого, выходит что разница между обычным агентом и stage-агентам только во времени жизни. Эдакий синглтон.
          Теперь стало понятнее. Спасибо за пояснение!
            +1
            Наоборот, статьи довольно интересные.
            Если хоть кому-то нравится, то значит не напрасно все было. Мы, кстати, открыты: есть интересно о чем-то еще узнать, то скажите о чем, постараемся выбрать время и рассказать.
            Итого, выходит что разница между обычным агентом и stage-агентам только во времени жизни.
            На самом деле не только. Разница в сроке жизни это уже следствие. Принципиальный момент в том, что stage-агент должен уметь обрабатывать операции сразу для N транзакций. Это усложняет его реализацию.

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

            Хотя email_body_checker — это не самый хороший здесь пример. Можно взять пример агента, который что-то с СУБД делает. Например, пусть в каждом email-е есть уникальный message-id и нам нужно сохранять эти message-id в БД для истории. Если агент message_id_saver рассчитан только на один email, то у него все просто: ему дали message-id, он получил коннект к БД, выполнил сохранение, отчитался о выполнении.

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

            А вот если мы сделаем stage-агента mass_message_id_saver, который может принять кучу message-id и все их вставить в БД одной bulk-операцией, то масштабирование у нас будет лучше. Но сама логика mass_message_id_saver станет сложнее, т.к. он должен быть накапливать у себя группу message-id, затем делать bulk-операцию, затем должен уметь обрабатывать негативные результаты buld-операции (например, что делать если из M message-id один все-таки оказался неуникальным?), должен уметь раздавать результаты разным агентам-инициаторам и т.д. и т.п.

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

            Эдакий синглтон.
            На самом деле для балансировки нагрузки можно сделать сразу несколько однотипных stage-агентов для одной и той же операции. Но это уже совсем другая история ;)
              +1
              Тема stage-агентов оказалась гораздо глубже, чем можно было подумать на первый взгляд. :) Кстати, это довольно не плохая тема для статьи — плюсы и минусы, практика использования, предпосылки к применению, особенности реализации поверх SObjectizer.
                0
                Кстати, это довольно не плохая тема для статьи
                Кстати да, наверное, вы правы.

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

      Самое читаемое