Со времени общения с Григорием Демченко в комментариях к заметке про "асинхронность без телепортации" не оставляет вопрос о том, как провести хороший водораздел между подходами на основе конечных автоматов и на основе сопрограмм. С одной стороны, сопрограммы нонче -- это модно и молодежно. Причем сейчас я совершенно серьезен. Примеры того, как сопрограммы сокращают объем кода можно увидеть как в статье Григория Демченко на Хабре, так и, например, в документации к 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 { while( true ) { 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 >() ); } } catch( const 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 строк в пользу класса с кучей атрибутов и несколькими внутренними методами (а то несколькими десятками методов). Так почему бы не начинать с этого сразу же? ;)
Комментариев нет:
Отправить комментарий