Релиз акторного фреймворка rotor v0.09 (c++)

    actor system


    rotorненавязчивый С++ акторный микрофремворк, похожий на своих старших братьев — caf и sobjectizer. В новом релизе внутреннее ядро полностью было переделано с помощью механизмов плагинов, так что это затронуло жизненный цикл акторов.


    Связывание акторов


    Всякая система акторов базируется на взаимодействии между ними, т. е. в отправлении сообщений друг другу (а также в возможных побочных эффектах в качестве реакции на эти сообщения или в создании новых сообщений, появляющихся в качестве реакции на события внешнего мира). Однако, чтобы сообщение было доставлено целевому актору, он должен оставаться активным (1); другими словами, если актор A собирается отправить сообщение М актору B, он должен быть уверен, что актор B онлайн и не будет выключен в процессе пересылки сообщения M.


    До версии v0.09 подобная гарантия была только для отношений родитель/потомок, между супервайзером и дочерним актором, т. к. для последнего выполняется гарантия того, сообщение будет доставлено до его супервайзера в силу того, что супервайзер владеет дочерним актором, и его время жизни покрывает времена жизни всех своих дочерних акторов. Начиная с версии v0.09 появилась возможность связывания двух произвольных акторов A и B, так что после подтверждения связи (link), можно быть уверенным, что все последующие сообщения будут доставлены.


    Для связывания акторов можно использовать такой код:


    namespace r = rotor;
    
    void some_actor_t::on_start() noexcept override {
        request<payload::link_request_t>(b_address).send(timeout);
    }
    
    void some_actor_t::on_link_response(r::message::link_response_t &response) noexcept {
        auto& ec = message.payload.ec;
        if (!ec) {
            // успех связывания
        }
    }

    Однако, данный код не рекомендуется использовать напрямую… потому что он не очень удобен. Это становится очевидным при попытке связать актор A с двумя и более акторами, т. к. some_actor_t должен будет вести внутренний счётчик успешных связываний. В данном случае помогает система плагинов, и код будет уже выглядеть так:


    namespace r = rotor;
    
    void some_actor_t::configure(r::plugin::plugin_base_t &plugin) noexcept override {
        plugin.with_casted<r::plugin::link_client_plugin_t>(
            [&](auto &p) {
                p.link(B1_address);
                p.link(B2_address);
            }
        );
    }

    Это более удобно в виду того, что плагин link_client_plugin_t поставляется в базовом классе всех акторов actor_base_t. Тем не менее, это скорей всего не всё, что хотелось бы иметь, т. к. остаются не отвеченными важные вопросы: 1) Когда происходит связывание акторов (и обратный вопрос — когда происходит их разъединение)? 2) Что случится, если целевой актор ("сервер") не существует или откажет в связывании? 3) Что случится если целевой актор решит выключиться, в то время как есть связанные с ним акторы-клиенты?


    Чтобы ответить на этот вопрос нужно рассмотреть жизненный цикл актора.


    Асинхронная инициализация и выключение актора


    Упрощённо жизненный цикл актора (состояние, state) выглядит следующим образом: new (ctor) -> initializing -> initialized -> operational -> shutting down -> shut down.



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


    Во время фазы инициализации (I-фазы, т. е. initializing -> initialized), актор подготавливает себя для будущей работы: находит и связывается с другими акторами, устанавливает соединение с БД, получает необходимые ему ресурсы для полноценной работы. Ключевая особенность rotor'а, что I-фаза асинхронна, т. е. актор сообщает супервайзеру, когда он готов (2).


    Фаза выключения (S-фаза, т. е. shutting down -> shut down ) комплиментарна I-фазе, т. е. актора просят выключится, а когда он готов, он сообщает об этом своему супервайзеру.


    Несмотря на кажущуюся простоту, основная сложность лежит здесь в масштабируемости (composability) подхода, при котором акторы формируют эрланго-подобные иерархии ответственностей (см. мою статью Trees of Supervisors). Перефразируя, можно сказать, что любой актор может дать сбой во время I- или S-фазы, что может повлечь за собой безопасный и ожидаемый коллапс всей иерархии независимо от местоположения актора в ней. Конечная цель в итоге — это либо вся иерархия приходит в рабочее состояние (operational), либо она в конце концов становится выключенной (shut down).



    (Пояснение к картинке. Сплошная линия обозначение отношение владения, пунктирная — отношения связи).


    rotor уникален в этом отношении. Ничего подобного нет в caf. Может создаться ошибочное представление, что в sobjectizer'е присутствует shutdown helper, предназначение которого аналогично S-фазе выше; однако, после публичных дискуссий с автором в англоязчыной версии статьи, выяснилось, что данные вспомогательные классы нужны для "длительного" (гарантированного) выключения акторов, даже если в Environment'е был вызван метод stop. С точки зрения sobjectizer'а отложенная инициализация (и выключение), аналогичные I- и S-фазам rotor'a, могут быть смоделированы с помощью встроенного механизма поддержки состояний и это обязанность пользователя фрейворка, если такова потребность его бизнес-логики. В rotor'е же это встроено в сам фреймворк.


    При использовании rotor'а было обнаружено, что во время I-фазы (S-фазы) потенциально множество ресурсов должны быть получены (освобождены) асинхронно, что обозначает, что нет единого компонента, способного решить, что текущая фаза завершена. Вместо этого это решение есть плод совместных усилий, приложенных в определённом порядке. И здесь в игру вступают плагины, представляющие из себя атомарные кусочки, каждый из которых ответственен за собственную работу по инициализации или выключению.


    Что же такое плагин в контексте rotor'а? Плагин — это некий аспект поведения актора, определяющий реакцию актора на некоторые сообщения или группу сообщений. Лучше пояснить на примерах. Плагин init_shutdown, ответственен за инициализацию (выключение) актора, т. е. после опроса о готовности всех плагины, генерируется ответ на запрос о готовности инициализации (выключения); или, например, плагин child_manager, доступный только для супервайзеров, и ответственный за порождение дочерних акторов и всю машинерию связанную с этим, как то генерация запросов дочерним акторам на инициализацию, выключение и т. п. Несмотря на то, что существует возможность создавать свои плагины, на текущий момент я не вижу необходимости в этом, поэтому она остаётся недокументированной.


    Таким образом, обещанные ответы, относящиеся к link_client_plugin_t:


    • В: когда происходит связывание (отвязывание) актров? О: когда актор в состоянии initializing (shutting down).
    • В: что случится, если целевой актор не существует или отказывает в связывании? О: т. к. это случается во время инициализации актора, то плагин обнаружит это условие и начнёт выключение актора-клиента; также, возможно, это вызовет каскадный эффект, т. е. его супервайзер тоже выключится и так далее вверх по иерархии владения.
    • В: что случится, если целевой актор решит выключиться, при том, что с ним связаны активные акторы-клиенты? О: актор-сервер попросит клиентов отвязаться, и только когда все связанные клиенты подтвердят это, актор-сервер продолжит процедуру выключения (3).

    Упрощённый пример


    Будем предполагать, что имеется драйвер базы данных с асинхронным интерфейсом для одного из движков событий (event loop), доступных для rotor'а, а также что имеются TCP-клиенты, подключающиеся к нашему сервису. За обслуживание базы данных будет отвечать актор db_actor_t, а принимать клиентов будет acceptor_t. Начнём с первого:


    namespace r = rotor;
    
    struct db_actor_t: r::actor_base_t {
    
        struct resource {
            static const constexpr r::plugin::resource_id_t db_connection = 0;
        }
    
        void configure(r::plugin::plugin_base_t &plugin) noexcept override {
            plugin.with_casted<r::plugin::registry_plugin_t>([this](auto &p) {
                p.register_name("service::database", this->get_address())
            });
            plugin.with_casted<r::plugin::resources_plugin_t>([this](auto &) {
                resources->acquire(resource::db_connection);
                // инициировать асинхронное соединение с базой данных
            });
        }
    
        void on_db_connection_success() {
            resources->release(resource::db_connection);
            ...
        }
    
        void on_db_disconnected() {
            resources->release(resource::db_connection);
        }
    
        void shutdown_start() noexcept override {
            r::actor_base_t::shutdown_start();
            resources->acquire(resource::db_connection);
            // асинхронное закрытие соединения с базой данных и сброс данных
        }
    };

    Внутреннее пространство имён resource используется для идентификации соединения с БД как ресурсом. Это общепринятая практика, чтобы не использовать в коде магические цифры вроде 0. Во время конфигурации, которая является частью инициализации, когда плагин registry_plugin_t готов, он асинхронно зарегистрирует адрес актора в регистре (о нём будет рассказано позже). Затем с помощью resources_plugin_t захватывается "ресурс" подключения к БД, чтобы блокировать дальнейшую инициализацию актора и начинается соединение с БД. Когда будет подтверждено соединение с БД, ресурс будет освобождён и актор db_actor_t перейдёт в рабочее состояние. S-фаза аналогична: блокируется выключение до тех пор, пока все данные не будут сброшены в БД и пока соединение не будет закрыто; после этого процедура выключения актора завершается (4).


    Код актора, который будет принимать клиентов, выглядит приблизительно так:


    namespace r = rotor;
    struct acceptor_actor_t: r::actor_base_t {
        r::address_ptr_t db_addr;
    
        void configure(r::plugin::plugin_base_t &plugin) noexcept override {
            plugin.with_casted<r::plugin::registry_plugin_t>([](auto &p) {
                p.discover_name("service::database", db_addr, true).link();
            });
        }
    
        void on_start() noexcept override {
            r::actor_base_t::on_start();
            // начать приём клиентов, например:
            // asio::ip::tcp::acceptor.async_accept(...);
        }
    
        void on_new_client(client_t& client) {
            // send<message::log_client_t>(db_addr, client)
        }
    };

    Основное в данном случае, это метод configure. Когда плагин registry_plugin_t готов, он будет сконфигурирован на обнаружение сервиса service::database, а когда адрес db_actor_t будет найден и сохранён в члене класса db_addr, то тогда с ним будет произведено связывание. Если же адрес актора service::database не будет обнаружен, то актор acceptor_actor_t начнёт выключаться (т. е. on_start не будет вызван). Если всё будет успешно проинициализировано, то актор начнёт принимать новых клиентов.


    Собственно, сама операционная часть была опущена ради краткости, т. к. она не изменилась в новой версии rotor'а. Как обычно нужно определить полезную нагрузку (payload), сообщения, методы для работы с этими сообщениями и подписаться на них.


    Скомпонуем всё вместе в файле main.cpp; будем считать, что используется boost::asio в качестве цикла событий.


    namespace asio = boost::asio;
    namespace r = rotor;
    ...
    asio::io_context io_context;
    auto system_context = rotor::asio::system_context_asio_t(io_context);
    auto strand = std::make_shared<asio::io_context::strand>(io_context);
    auto timeout = r::pt::milliseconds(100);
    auto sup = system_context->create_supervisor<r::asio::supervisor_asio_t>()
                   .timeout(timeout)
                   .strand(strand)
                   .create_registry()
                   .finish();
    
    sup->create_actor<db_actor_t>().timeout(timeout).finish();
    sup->create_actor<acceptor_actor_t>().timeout(timeout).finish();
    
    sup->start();
    io_context.run();

    Как видно, в новом rotor'е активно используется шаблон builder. С помощью него создаётся корневой супервайзер sup, а уже он в свою очередь порождает 3 актора: два пользовательских (db_actor_t и acceptor_actor_t) и неявно созданный актор-регистр. Как обычно для акторных систем, все акторы слабо связаны друг с другом, т. к. они разделяют только общий интерфейс сообщений (опущено в статье).


    Акторы "просто создаются" в данном месте, без знания о том, как они связаны между собой. Это следствие слабой связанности между акторами, которые с версии v0.09 стали более автономными.


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


    Итоги


    Наиболее существенным изменением в новой версии rotor'а является разбиение на плагины его ядра. Наиболее важными для пользователя фрейморка являются плагины: link_client_plugin_t, позволяющий установить виртуальное соединение между акторами, плагин registry_plugin_t, дающий возможность регистрации и обнаружения адресов акторов по символическим именам, а также плагин resources_plugin_t, с помощью которого можно приостановить процедуру инициализации (выключения) до появления некоторого асинхронного внешнего события.


    Так же присутствуют менее важные изменения в новом релизе, такие как система доступа к непубличным
    свойствам и шаблон builder для интанцирования акторов.


    Любая обратная связь приветствуется.


    P.S. Я хотел бы поблагодарить Crazy Panda за поддержку в моих начинаниях по развитию данного проекта, а также автора sobjectizer'а за высказанные им критические замечания.


    Примечания


    (1) В настоящее время попытка доставки сообщения актору, супервайзер которого уже отработал и был удалён, ведёт к краху программы (UB).


    (2) Если актор не подтвердит успешную инициализацию супервайзеру, сработает таймер инициализации, и супервайзер сделает запрос на выключение актора, т. е. состояние operational будет пропущено.


    (3) Может возникнуть вопрос, что произойдёт, если актор не подтвердит отвязывание вовремя? Это нарушение контракта, и будет вызван метод system_context_t::on_error(const std::error_code&), который распечатает ошибку на консоль и вызовет std::terminate(). Для избегания нарушений контрактов, нужно настраивать таймеры, чтобы позволить акторам-клиентам во время отвязаться.


    (4) Во время процедуры выключения плагин registry_plugin_t проинструктирует регистр, чтобы все зарегистрированные имена текущего актора были удалены из регистра.


    (5) Исключение составляет, когда используются различные циклы событий. Если актор использует API цикла событий, то, очевидно, что смена цикла событий повлечёт переписывание внутренностей актора. Тем не менее, это никак не затронет использование API rotor'а.

    Crazy Panda
    Crazy about games since 2010

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

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

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