Pull to refresh

SObjectizer-5.6.0: режем по живому, чтобы расти дальше

Reading time14 min
Views1.8K


Третьего дня стала доступна новая версия SObjectizer-а: 5.6.0. Ее главная особенность — это отказ от совместимости с предыдущей стабильной веткой 5.5, поступательно развивавшейся на протяжении четырех с половиной лет.


Основные принципы работы SObjectizer-5 остались прежними. Сообщения, агенты, кооперации и диспетчеры все еще с нами. Но что-то серьезно изменилось, что-то вообще было выброшено. Поэтому просто взять SO-5.6.0 и перекомпилировать свой код уже не получится. Что-то потребуется переписать. Что-то, возможно, придется перепроектировать.


Почему мы несколько лет заботились о совместимости, а потом решились взять и все поломать? И что поломали наиболее основательно?


Об этом я и попробую рассказать в данной статье.


Зачем вообще потребовалось что-то ломать?


Тут как раз все просто.


SObjectizer-5.5 за время своего развития вобрал в себя столько всякого разного и разнообразного, чего изначально не планировалось, что в итоге у него внутри образовался слишком уж большой набор костылей и подпорок. С каждой новой версией добавлять что-то новое в SO-5.5 становилось тяжелее и тяжелее. И в конце-концов на вопрос "А зачем нам все это нужно?" подходящего ответа уже не нашлось.


Так что первая причина — это переусложнение потрохов SObjectizer-а.


Вторая причина — это то, что нам тупо надоело ориентироваться на старые С++ компиляторы. Ветка 5.5 началась в 2014, когда в нашем распоряжении были, если не ошибаюсь, gcc-4.8 и MSVS2013. И на этом уровне мы до сих пор продолжали держать планку требований к уровню поддержки стандарта C++.


Изначально у нас был в этом, скажем так, "шкурный интерес". Кроме того, какое-то время мы рассматривали невысокие требования к качеству поддержки стандарта C++ как свое "конкурентное преимущество".


Но время идет, "шкурный интерес" закончился. Каких-то выгод от такого "конкурентного преимущества" не видно. Может они и были бы, если бы мы работали вообще с C++98, тогда бы нами мог бы заинтересоваться кровавый энтерпрайз. Но кровавый энтерпрайз в таких как мы в принципе не заинтересован. Поэтому было решено перестать себя ограничивать и взять что-нибудь посвежее. Вот мы и взяли самое свежее из стабильного на данный момент: С++17.


Очевидно, что не всем такое решение понравится, все-таки для многих C++17 — это сейчас недостижимый передний край, до которого еще очень и очень далеко.


Тем не менее, мы решились на такой риск. Все равно процесс популяризации SObjectizer-а идет не быстро, так что когда SObjectizer станет более-менее широко востребованным, C++17 уже не будет "передним краем". Скорее к нему будут относиться так же, как сейчас к C++11.


В общем, вместо того, чтобы продолжать строить костыли с использованием подмножества C++11, мы решились на серьезную переделку внутренностей SObjectizer-а с применением C++17. Чтобы построить базу, на основе которой SObjectizer сможет поступательно развиваться в течении последующих четырех или пяти лет.


Что серьезно поменялось в SObjectizer-5.6?


А сейчас давайте бегло пройдемся по некоторым, наиболее ярким изменениям.


У коопераций агентов больше нет строковых имен


Проблема


С самого начала SObjectizer-5 требовал, чтобы у каждой кооперации было свое уникальное строковое имя. Эта особенность досталась пятому SObjectizer-у в наследство от предыдущего, четвертого SObjectizer-а.


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


Начиная с самых первых версий в SObjectzer-5 использовалась простая схема: единый словарь зарегистрированных коопераций, защищенных mutex-ом. При регистрации кооперации mutex захватывается, проверяется уникальность имени кооперации, наличие родителя и т.д. После выполнения проверок словарь модифицируется, после чего mutex отпускается. Это означает, что если одновременно началась регистрация/дерегистрация сразу нескольких коопераций, то в какие-то моменты они будут приостанавливаться и ждать, пока одна из операций не завершит работу со словарем коопераций. Из-за этого операции с кооперациями плохо масштабировались.


Вот от этого всего и хотелось избавиться дабы улучшить ситуацию со скоростью регистрации коопераций.


Решение


Рассматривалось два основных способа решения этой проблемы.


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


Во-вторых, полный отказ от строковых имен и применение каких-то назначаемых SObjectizer-ом идентификаторов.


В результаты мы выбрали второй способ и полностью отказались от именования коопераций. Теперь в SObjectizer-е есть такое понятие, как coop_handle, т.е. некий хэндл, содержимое которого скрыто от пользователя, но который можно сравнить с std::weak_ptr.


SObjectizer возвращает coop_handle при регистрации кооперации:


auto coop = env.make_coop();
... // Наполняем новую кооперацию агентами.
auto coop_id = env.register_coop(std::move(coop)); // Регистрируем.
// Теперь в coop_id лежит хэндл новой кооперации.

Этот хэндл должен использоваться при дерегистрации кооперации:


auto coop = env.make_coop();
... // Наполняем новую кооперацию агентами.
auto coop_id = env.register_coop(std::move(coop)); // Регистрируем.
// Теперь в coop_id лежит хэндл новой кооперации.
... // Что-то делаем.
// Решаем, что кооперация больше не нужна.
// Удаляем ее через имеющийся у нас хэндл.
env.deregister_coop(coop_id, ...);

Также этот хэндл должен использоваться при установке взаимоотношений родитель-потомок:


// Создаем родительскую кооперацию.
auto parent = env.make_coop();
... // Наполняем parent агентами.
auto parent_id = env.register_coop(std::move(parent)); // Регистрируем родителя.
...
// Создаем дочернюю кооперацию и сразу говорим, кто у нее родитель.
auto child = env.make_coop(parent_id);
...

Структура хранилища для коопераций внутри SObjectizer Environment также кардинальным образом поменялась. Если до версии 5.5 включительно это был один общий словарь, то сейчас каждая кооперация является хранилищем ссылок на дочерние кооперации. Т.е. кооперации образуют дерево с корнем в специальной скрытой от пользователя root-кооперации.


Такая структура позволяет масштабировать операции register_coop и deregister_coop гораздо лучше: взаимная блокировка параллельных операций происходит только если они обе относятся к одной и той же родительской кооперации. Для наглядности, вот результат запуска специального бенчмарка, который замеряет производительность операций с кооперациями, на моем стареньком ноутбуке с Ubuntu 16.04 и GCC-7.3:


_test.bench.so_5.parallel_parent_child -r 4 -l 7 -s 5
Configuration: roots: 4, levels: 7, level-size: 5
parallel_parent_child: 15.69s
488280
488280
488280
488280
Total: 1953120

Т.е. версия 5.6.0 справилась практически с 2M коопераций за ~15.5 секунд.


А вот версия 5.5.24.4, последняя из ветки 5.5 на данный момент:


_test.bench.so_5.parallel_parent_child -r 4 -l 7 -s 5
Configuration: roots: 4, levels: 7, level-size: 5
parallel_parent_child: 46.856s
488280
488280
488280
488280
Total: 1953120

Тот же самый сценарий, но результат в три раза хуже.


Остался всего один вид диспетчеров


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


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


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


so_5::launch(
    []( so_5::environment_t & env ) { /* какие-то начальные действия */ },
    // Определение параметров для SObjectizer-а.
    []( so_5::environment_params_t & params ) {
        p.add_named_dispatcher("active_obj", so_5::disp::active_obj::create_disp());
        p.add_named_dispatcher("shutdowner", so_5::disp::active_obj::create_disp());
        p.add_named_dispatcher("groups", so_5::disp::active_group::create_disp());
        ...
    } );

Не создал перед стартом нужный диспетчер — все, сам виноват, ничего уже не изменишь.


Понятное дело, что это неудобно и по мере расширения сценариев использования SObjectizer-а потребовалось эту проблему решить. Поэтому появился метод add_dispatcher_if_not_exists, который проверял наличие диспетчера и, если такового не было, позволял создать новый экземпляр:


so_5::launch(
    []( so_5::environment_t & env ) {
        ... // Что-то делаем.
        // Тут нам потребовался новый диспетчер.
        env.add_dispatcher_if_not_exists( "extra_dispatcher",
            []{ return so_5::disp::active_obj::create_disp(); } );
    },
    // Определение параметров для SObjectizer-а.
    []( so_5::environment_params_t & params ) {...} );

Такие диспетчеры назывались публичными. У публичных диспетчеров были уникальные имена. И с помощью этих имен производилась привязка агентов к диспетчерам:


so_5::launch(
    []( so_5::environment_t & env ) {
        ... // Что-то делаем.
        // Тут нам потребовался новый диспетчер.
        env.add_dispatcher_if_not_exists( "extra_dispatcher",
            []{ return so_5::disp::active_obj::create_disp(); } );
        // И сейчас мы задействуем новый диспетчер для привязки
        // агентов из новой кооперации.
        auto coop = env.create_coop( "ping_pong",
            // Все агенты привязываются к extra_dispatcher.
            so_5::disp::active_obj::create_disp_binder( "extra_dispatcher" ) );
        coop->make_agent< a_pinger_t >(...);
        coop->make_agent< a_ponger_t >(...);
        ...
    },
    // Определение параметров для SObjectizer-а.
    []( so_5::environment_params_t & params ) {...} );

Но у публичных диспетчеров была одна неприятная особенность. Они начинали работать сразу после своего добавления в SObjectizer Environment и продолжали работать до тех пор, пока SObjectizer Environment не завершит свою работу.


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


Так появились "приватные" диспетчеры. Эти диспетчеры не имели имен и жили до тех пор, пока существовали ссылки на них. Создавать приватные диспетчеры можно было в любой момент после запуска SObjectizer Environment, уничтожались они автоматически.


В общем-то, приватные диспетчеры оказались очень удачным звеном в эволюции диспетчеров, но вот работа с ними сильно отличалась от работы с публичными диспетчерами:


so_5::launch(
    []( so_5::environment_t & env ) {
        ... // Что-то делаем.
        // Тут нам потребовался новый диспетчер.
        auto disp = so_5::disp::active_obj::create_private_disp(env);
        // И сейчас мы задействуем новый диспетчер для привязки
        // агентов из новой кооперации.
        auto coop = env.create_coop( "ping_pong",
            // Все агенты привязываются к новому диспетчеру.
            disp->binder() );
        coop->make_agent< a_pinger_t >(...);
        coop->make_agent< a_ponger_t >(...);
        ...
    },
    // Определение параметров для SObjectizer-а.
    []( so_5::environment_params_t & params ) {...} );

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


В итоге сопровождать все это многообразие надоело и в SObjectizer-5.6 остался всего один вид диспетчеров. По сути, это аналог приватных диспетчеров. Но только без явного упоминания слова "private". Так что сейчас показанный выше фрагмент запишется как:


so_5::launch(
    []( so_5::environment_t & env ) {
        ... // Что-то делаем.
        // Тут нам потребовался новый диспетчер.
        auto disp = so_5::disp::active_obj::make_dispatcher(env);
        // И сейчас мы задействуем новый диспетчер для привязки
        // агентов из новой кооперации.
        auto coop = env.create_coop( "ping_pong",
            // Все агенты привязываются к новому диспетчеру.
            disp.binder() );
        coop->make_agent< a_pinger_t >(...);
        coop->make_agent< a_ponger_t >(...);
        ...
    },
    // Определение параметров для SObjectizer-а.
    []( so_5::environment_params_t & params ) {...} );

Остались только свободные функции send, send_delayed и send_periodic


Развитие API для отсылки сообщений в SObjectizer-е — это вообще, наверное, самый яркий пример того, как SObjectizer менялся по мере улучшения поддержки C++11 в доступных нам компиляторах.


Сначала сообщения отсылались так:


mbox->deliver_message(new my_message(...));

Или, если следовать "рекомендациям лучших собаководов" (с):


std::unique_ptr<my_message> msg(new my_message(...));
mbox->deliver_message(std::move(msg));

Однако, потом мы заполучили в свое распоряжение компиляторы с поддержкой variadic templates и появились send-функции. Стало можно писать так:


send<my_message>(target, ...);

Правда, потребовалось еще какое-то время, чтобы из простого send-а выросло целое семейство, включающее в себя send_to_agent, send_delayed_to_agent и т.д. А потом чтобы это семейство ужалось до ставшего привычным набора из send, send_delayed и send_periodic.


Но, не смотря на то, что семейство send-функций сформировалось довольно давно и уже несколько лет является рекомендуемым способом отсылки сообщений, старые методы, вроде deliver_message, schedule_timer и single_timer, все еще оставались доступны пользователю.


А вот в версии 5.6.0 в публичном API SObjectizer-а сохранились только свободные функции send, send_delayed и send_periodic. Все остальное либо было удалено вообще, либо перенесено во внутренние SObjectizer-овские пространства имен.


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


Единый формат send_delayed и send_periodic


С функциями send_delayed и send_periodic в прошлых версиях SObjectizer произошел еще и вот какой казус.


Для работы с таймером нужно иметь доступ к SObjectizer Environment. Внутри агента есть ссылка на SObjectizer Environment. И внутри mchain-а такая ссылка есть. А вот внутри mbox-а ее не было. Поэтому, если отложенное сообщение отсылалось агенту или в mchain, то вызов send_delayed имел вид:


send_delayed<my_message>(target_agent, pause, ...);
send_delayed<my_message>(target_mchain, pause, ...);

Для случая mbox-а приходилось еще откуда-то брать ссылку на SObjectizer Environment:


send_delayed<my_message>(this->so_environment(), target_mbox, pause, ...);

Такая особенность send_delayed и send_periodic была мелкой занозой. Которая не то, чтобы сильно мешала, но раздражала изрядно. А все потому, что изначально мы не стали хранить в mbox-ах ссылку на SObjectizer Environment.


Нарушение совместимости с предыдущими версиями стало хорошим поводом, чтобы от этой занозы избавиться.


Теперь у mbox-а можно узнать, для какого SObjectizer Environment он создан. А это дало возможность задействовать единый формат send_delayed и send_periodic для любого типа получателя таймерного сообщения:


send_delayed<my_message>(target_agent, pause, ...);
send_delayed<my_message>(target_mchain, pause, ...);
send_delayed<my_message>(target_mbox, pause, ...);

В прямом смысле "мелочь, а приятно".


Нет больше ad-hoc агентов


Как говорится, «У каждой аварии есть имя, отчество и фамилия». В случае с ad-hoc агентами это мои имя, отчество и фамилия :(


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


Одним из источников многословности является необходимость наследования агентов от специального базового типа agent_t. И от этого, казалось бы, никуда не деться. Или нет?


Так появились ad-hoc агенты, т.е. агенты, для определения которых не нужно было выписывать отдельный класс, достаточно было только задать реакцию на сообщения в виде лямбда-функций. Например, классический пример ping-pong на ad-hoc агентах можно было записать вот так:


auto pinger = coop->define_agent();
auto ponger = coop->define_agent();

pinger
    .on_start( [ponger]{ so_5::send< msg_ping >( ponger ); } )
    .event< msg_pong >(
        pinger,
        [ponger]{ so_5::send< msg_ping >( ponger ); } );

ponger
    .event< msg_ping >(
        ponger,
        [pinger]{ so_5::send< msg_pong >( pinger ); } );

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


Так в SObjectizer-5 появилось разделение на обычных и ad-hoc агентов.


Которое не принесло никаких видимых бонусов, только лишние трудозатраты на сопровождение такого разделения. И со временем стало понятно, что ad-hoc агенты — это как чемодан без ручки: и нести тяжело, и бросить жалко. Но при работе над SObjectizer-5.6 было решено все-таки бросить.


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


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


К сожалению, навыка "фильтрации" сказанного "доброжелателями" в Интернете у меня лет пять назад было сильно меньше, чем сейчас. Отсюда и такой специфический эксперимент, как ad-hoc агенты в SObjectizer-е.


SObjectizer-5.6 больше не поддерживает синхронного взаимодействия агентов


Тема синхронного взаимодействия между агентами очень старая и больная.


Началась она еще во времена SObjectizer-4. А в SObjectizer-5 продолжилась. Пока, наконец, не были реализованы т.н. service request-ы. Которые изначально, нужно признать, были страшны как смерть. Но потом удалось придать им более-менее приличный вид.


Но это оказался тот самый случай, когда первый блин вышел комом :(


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


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


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


И вот в SObjectizer-5.6 агенты вновь могут взаимодействовать лишь посредством асинхронных сообщений.


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


// Определяем тип пары "запрос-ответ".
using my_request_reply = so_5::extra::sync::request_reply_t<my_request, my_reply>;
...
// Тип агента, который будет обрабатывать запросы.
class request_handler final : public so_5::agent_t {
   ...
   // Обработчик запроса. Сам запрос приходит как обычное сообщение.
   void on_request(typename my_request_reply::request_mhood_t cmd) {
      ... // Обрабатываем запрос.
          // Содержимое запроса доступно через метод cmd->request().
      // Отвечаем на запрос.
      cmd->make_reply(...); // Все аргументы идут в конструктор my_reply.
   }
   ...
   void so_define_agent() override {
      // Подписка на запрос как на обычное сообщение.
      so_subscribe_self().event(&request_handler::on_request);
   }
};
...
// Это почтовый ящик обработчика запросов.
so_5::mbox_t handler_mbox = ...;
// Отсылаем запрос и ждем ответа не более 15s.
// Если ответ не получен, то будет брошено исключение.
my_reply reply = my_request_reply::ask_value(handler_mbox, 15s,
   ...); // Все оставшиеся аргументы идут в конструктор my_request.

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


Новая реализация хороша тем, что в ней и запрос, и ответ отсылаются внутри SObjectizer-а как обычные асинхронные сообщения. По сути, make_reply — это чуть-чуть более специфическая реализация send-а.


И, что важно, новая реализация позволила получить функциональность, которая была недостижима ранее:


  • синхронные запросы (т.е. объекты request_reply_t<Request, Reply>) теперь можно сохранять и/или пересылать другим обработчикам. Что делает возможным реализацию различных схем балансировки нагрузки;
  • можно сделать так, чтобы ответ на запрос пришел в обычный mbox агента-инициатора запроса. И агент-инициатор обработает ответ обычным порядком, как любое другое сообщение;
  • можно отослать сразу несколько запросов разным получателям, а затем разбирать ответы от них в порядке получения:

using first_dialog = so_5::extra::sync::request_reply_t<first_request, first_reply>;
using second_dialog = so_5::extra::sync::request_reply_t<second_request, second_reply>;

// Этот канал будет использоваться для получения ответов на запросы.
auto reply_ch = create_mchain(env);

// Выдача двух запросов разным обработчикам.
first_dialog::initiate_with_custom_reply_to(
   one_service, reply_ch, so_5::extra::sync::do_not_close_reply_chain,
   ...);
second_dialog::initiate_with_custom_reply_to(
   another_service, reply_ch, so_5::extra::sync::do_not_close_reply_chain,
   ...);

// Ожидание и обработка ответов.
receive(from(reply_ch).handle_n(2).empty_timeout(15s),
   [](typename first_dialog::reply_mhood_t cmd) {...},
   [](typename second_dialog::reply_mhood_t cmd) {...});

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


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

Сделали работу над собственными ошибками, в общем.


Заключение


В этой статье было, довольно кратко, рассказано о нескольких изменениях в SObjectizer-5.6.0 и причинах, стоящих за этими изменениями.


Более полный список изменений можно найти здесь.


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


Ко всем конструктивным замечаниям/предложениям мы внимательно прислушиваемся. Более того, в последние годы в SObjectizer включается только то, что кому-то понадобилось. Так что если вы нам не расскажите, что вам хотелось бы иметь в SObjectizer-е, то это и не появится. А если расскажите, то кто знает… ;)


Проект теперь живет и развивается здесь.


PS. За новостями, связанными с SObjectizer, можно следить в этой Google-группе. Там же можно и поднимать связанные с SObjectizer-ом вопросы.

Tags:
Hubs:
+8
Comments3

Articles