четверг, 18 декабря 2014 г.

[prog.thoughts] FiniteStateMachines vs Coroutines

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

Но, с другой стороны, уже лет двадцать как использую подход на основе конечных автоматов и получаю вполне себе нормальные результаты. Конечно же, никто не признает себя плохим программистом, поэтому и мне кажется, что в моих проектах все было нормально, а как оно на самом-то деле... Ряд LOR-овских экспертов твердо убежден, что программировать я не умею от слова совсем ;) Тем не менее, проекты выполнялись, программы работали, прибыль приносили. Посему далее считается, что конечные автоматы могут успешно использоваться в concurrent programming.

Поэтому-то и интересен вопрос: действительно ли FiniteStateMachines и Coroutines объективно имеют разные области применения? Или же я из-за старперства застрял на конечных автоматах и мне просто уже не хватает гибкости ума для того, чтобы оценить и перейти на использование более передового подхода на основе сопрограмм (хотя самим сопрограммам уже сто лет в обед, т.к. впервые о них заговорили в 1958 году).

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

Этот критерий -- линейность действий, которую нужно выразить посредством автономного актора/агента (будь то конечный автомат или сопрограмма).

Т.е. если действия актора выражаются простой последовательностью, вроде инициировал действие #1, дождался результата, инициировал действие #2, дождался результата, в зависимости от результата инициировал действие #3 или #4, дождался результата и завершил работу, то актор выгоднее представлять в виде сопрограммы.

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

go([key] {
   // timeout для всех операций: 1с=1000 мс
   Timeout t(1000);
   std::string val;
   // получить результат из кешей параллельно
   boost::optional<std::string> result = goAnyResult<std::string>({
       [&key] {
           return portal<DiskCache>()->get(key);
       }, [&key] {
           return portal<MemCache>()->get(key);
       }
   });

   if (result)
   {
       // результат найден
       val = std::move(*result);
       JLOG("cache val: " << val);
   }
   else
   {
       // кеши не содержат результата
       // получаем объект по сети
       {
           // таймаут на сетевую обработку: 0.5с=500 мс
           Timeout tNet(500);
           val = portal<Network>()->get(key);
       }
       JLOG("net val: " << val);
       // начиная с этого момента и до конца блока
       // отмена (и таймауты) отключены
       EventsGuard guard;
       // параллельно записываем в оба кеша
       goWait({
           [&key, &val] {
               portal<DiskCache>()->set(key, val);
           }, [&key, &val] {
               portal<MemCache>()->set(key, val);
           }
       });

       JLOG("cache updated");
   }
   // переходим в UI и обрабатываем результат
   portal<UI>()->handleResult(key, val);
});

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

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

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

Простейшую демонстрацию этого можно увидеть в примере решения задачи Producer-Consumer средствами SObjectizer-5.5.1. Там есть агент a_receiver_t, который в каждый момент времени может получить одну из двух совершенно разных команд: во-первых, это команда принять новый запрос, во-вторых, это команда отдать на обработку все ранее собранные запросы. В подходе на основе конечного автомата это выражается объектом, у которого отдельные методы связаны с конкретными внешними воздействиями:

class a_receiver_t : public so_5::rt::agent_t
{
public :
   ...

   virtual void
   so_define_agent() override
   {
      this >>= st_not_full;

      // Когда находимся в нормальном состоянии...
      st_not_full
         // Сохраняем новый запрос обычным образом...
         .event( &a_receiver_t::evt_store_request )
         // Возвращаем вектор запросов обработчику.
         .event< msg_take_requests >( &a_receiver_t::evt_take_requests );

      // Но когда перегружены...
      st_overload
         // Отказываемся принимать новый запрос...
         .event( &a_receiver_t::evt_reject_request )
         // Однако возвращаем вектор запросов обычным образом.
         .event< msg_take_requests >( &a_receiver_t::evt_take_requests );
   }

private :
   ...

   bool
   evt_store_request( const application_request & what ) { ... }

   bool
   evt_reject_request( const application_request & what ) { ... }

   std::vector< application_request >
   evt_take_requests() { ... }
};

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

void
receiver_actor( message_queue & queue )
{
   ...
   try
   {
      whiletrue )
      {
         auto msg = queue.pop();
         if( STORE_REQUEST == msg.type() )
         {
            if( not_full )
               store_request( msg.cast_to< application_request >() );
            else
               reject_request( msg.cast_to< application_request >() );
         }
         else if( TAKE_REQUEST == msg.type() )
            take_requests( msg.cast_to< take_requests >() );
      }
   }
   catchconst queue_closed_exception & )
   {}
}

void
store_request( const application_request & ) { ... }

void
reject_request( const application_request & ) { ... }

void
take_requests( const take_requests & ) { ... }

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

И ведь это еще только два типа воздействия. В моей практике для акторов вроде показанного выше a_receiver_t воздействий было больше. Так, актор, выступивший прообразом a_receiver_t, если не ошибаюсь, реагировал на:

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

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

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


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

Так вот, опыт использования SObjectizer в довольно долгоживущих проектах (т.е. от 5-10+ лет) показывает, что акторы в виде C++ классов, реализующих конечные автоматы, довольно легко расширяются новой функциональностью со временем. Бывает любопытно наблюдать за эволюцией какого-нибудь класса агента. Сначала там всего несколько строк прикладного кода, на которые приходится пара десятков строк инфраструктурной обвязки (т.е. конструкторы/деструкторы, связанные с SObjectizer-атрибуты-состояния, методы so_define_agent и so_evt_start/so_evt_finish). Проходит буквально несколько недель разработки, прикладная логика усложняется и объем прикладного кода уже сравним по объему с инфраструктурным. Затем в агента добавляется нормальное логирование операций, реакции на переконфигурирование, обработка некоторых нештатных ситуаций, средства для мониторинга его состояния и инфраструктурного кода уже и не видно.

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

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