Шесть лет назад, в июне 2016-го года, вышла первая статья об инструменте, с разработкой которого я связан уже много лет. Шестилетней давности публикация дала толчок интереса к SObjectizer-у и, как я понимаю, кто-то сумел попробовать этот инструмент в деле (или собрался попробовать) именно благодаря той статье. Поскольку за прошедшее время SObjectizer несколько изменился, то я подумал, что не помешало бы выпустить обновленную версию статьи. Исправленную и дополненную. С учетом не только того, что изменилось/появилось/исчезло, но и отталкиваясь от критических отзывов на предыдущие обзорные статьи.
Итак, вашему вниманию предоставляется актуальный взгляд на то, что же это за инструмент, для чего он создавался и почему получился именно таким.
Введение
Разработка многопоточных программ на C++ — это не просто. Разработка больших многопоточных программ на C++ — это очень не просто. Но, как это обычно бывает в C++, жизнь сильно упрощается, если удается подобрать или сделать «заточенный» под конкретную задачу инструмент. Двадцать (да, да, двадцать) лет назад выбирать было особенно не из чего, поэтому мы сами для себя сделали такой «заточенный» инструмент и назвали его SObjectizer. Опыт повседневного использования SObjectizer-а в коммерческом софтостроении все еще не позволяет жалеть о содеянном. А раз так, то почему бы не попробовать рассказать о том, что это, для чего это и почему у нас получилось именно так, а не иначе…
Что это?
SObjectizer — это небольшой OpenSource инструмент, свободно распространяющийся под 3-пунктной BSD-лицензией. Основная идея, лежащая в основе SObjectizer, — это построение приложения из мелких сущностей-агентов, которые взаимодействуют между собой через обмен сообщениями. SObjectizer при этом берет на себя ответственность за:
-
доставку сообщений агентам-получателям внутри одного процесса;
-
управление рабочими нитями, на которых агенты обрабатывают адресованные им сообщения;
-
механизм таймеров (в виде отложенных и периодических сообщений);
-
возможности настройки параметров работы перечисленных выше механизмов.
Можно сказать, что SObjectizer является одной из реализаций Actor Model. Однако, главное отличие SObjectizer от других подобных разработок, — это сочетание элементов из Actor Model с элементами из других моделей, в частности, Publish-Subscribe и CSP.
Сообщения и почтовые ящики как первый краеугольный камень
В «классической» Actor Model каждый актор и есть непосредственный адресат в операции send. Т.е. если мы хотим отослать сообщение какому-то актору, мы должны иметь ссылку на актора-получателя или идентификатор этого актора. Операция send просто добавляет сообщение в очередь сообщений актора-получателя.
В SObjectizer операция send получает ссылку не на актора, а на такую штуку, как mbox (message box, почтовый ящик). Mbox можно рассматривать как некий прокси, скрывающий реализацию процедуры доставки сообщения до получателей. Таких реализаций может быть несколько, и они зависят от типа mbox-а. Если это multi-producer/single-consumer mbox, то, как и в «классической» Actor Model, сообщение будет доставлено единственному получателю, владельцу mbox-а.
![SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 1 SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 1](https://www.pvsm.ru/images/2022/06/15/SObjectizer-chto-eto-dlya-chego-eto-i-pochemu-eto-vyglyadit-imenno-tak-vzglyad-iz-2022-go.jpg)
А вот если это multi-producer/multi-consumer mbox, то сообщение будет доставлено всем получателям, которые подписались на данный mbox.
![SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 2 SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 2](https://www.pvsm.ru/images/2022/06/15/SObjectizer-chto-eto-dlya-chego-eto-i-pochemu-eto-vyglyadit-imenno-tak-vzglyad-iz-2022-go-2.jpg)
Т.е. операция send в SObjectizer больше похожа на операцию publish из модели Publish-Subscribe, нежели на send из Actor Model. Следствием чего является наличие такой полезной на практике возможности, как широковещательная рассылка сообщений.
Механизм доставки сообщений в SObjectizer похож на модель Publish-Subscribe еще и процедурой подписки. Если агент хочет получать сообщения типа A, то он должен подписаться на сообщения типа A из соответствующего mbox-а. Если хочет получать сообщения типа B — должен подписаться на сообщения типа B. И т.д. При этом тип сообщения играет ту же самую роль, как и название топика в модели Publish-Subscribe. Ну и как в модели Publish-Subscribe, где получатель может подписаться на любое количество топиков, агент в SObjectizer может быть подписан на любое количество типов сообщений из разных mbox-ов:
class example final : public so_5::agent_t {
...
public :
void so_define_agent() override {
// Подписка разных обработчиков событий на разные сообщения
// из одного mbox-а.
// Тип сообщения автоматически выводится из типа аргумента
// обработчика события.
so_subscribe(some_mbox)
.event( &example::first_handler )
.event( &example::second_handler )
// Обработчик может быть задан и в виде лямбда-функции.
.event( []( const third_message & msg ){...} );
// Подписка одного и того же события на сообщения
// из разных mbox-ов.
so_subscribe(another_mbox).event( &example::first_handler );
so_subscribe(yet_another_mbox).event( &example::first_handler );
...
}
...
private :
void first_handler( const first_message & msg ) {...}
void second_handler( const second_message & msg ) {...}
};
Диспетчеры как второй краеугольный камень
Следующим важным отличием SObjectizer от других реализаций «классической» Actor Model является то, что в SObjectizer у агента нет своей очереди сообщений. Очередь сообщений в SObjectizer принадлежит рабочему контексту, на котором обслуживается агент. А рабочий контекст определяется диспетчером, к которому привязан агент.
Диспетчер — это одно из краеугольных понятий в SObjectizer. Диспетчеры определяют где и когда агенты будут обрабатывать свои сообщения.
Самый простой диспетчер владеет всего одной рабочей нитью. Все привязанные к такому диспетчеру агенты работают на этой общей нити. Эта нить владеет одной единственной очередью сообщений, и сообщения для всех агентов, привязанных к диспетчеру, помещаются в эту единственную очередь. Рабочая нить берет сообщение из очереди, вызывает обработчик сообщения у соответствующего агента-получателя, после чего переходит к следующему сообщению и т.д.
![SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 3 SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 3](https://www.pvsm.ru/images/2022/06/15/SObjectizer-chto-eto-dlya-chego-eto-i-pochemu-eto-vyglyadit-imenno-tak-vzglyad-iz-2022-go-3.jpg)
Есть и другие типы диспетчеров. Например, диспетчеры с пулами рабочих потоков, диспетчеры с поддержкой приоритетов агентов и разными политиками обработки этих приоритетов, с отдельным рабочим потоком для каждого агента и т.д.
![SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 4 SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 4](https://www.pvsm.ru/images/2022/06/15/SObjectizer-chto-eto-dlya-chego-eto-i-pochemu-eto-vyglyadit-imenno-tak-vzglyad-iz-2022-go-4.jpg)
Во всех случаях рабочий контекст и очередь сообщений для агента назначается диспетчером, к которому привязан агент.
Кооперации агентов как третий краеугольный камень
Следующей отличительной чертой SObjectizer является наличие такого понятия, как "кооперация агентов". Кооперация — это группа агентов, которая совместно выполняет какую-то прикладную задачу. И эти агенты должны начинать и завершать свою работу единовременно. Т.е. если какой-то агент не может стартовать, то не стартуют и все остальные агенты кооперации. Если какой-то агент не может продолжать работу, то не могут продолжать свою работу и все остальные агенты кооперации.
![SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 5 SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 5](https://www.pvsm.ru/images/2022/06/15/SObjectizer-chto-eto-dlya-chego-eto-i-pochemu-eto-vyglyadit-imenno-tak-vzglyad-iz-2022-go-5.png)
Кооперации появились в SObjectizer потому, что в изрядном количестве случаев для выполнения более-менее сложных действий приходится создавать не одного, а сразу нескольких взаимосвязанных агентов. Если создавать их по одному, то нужно решить, например:
-
кто стартует первым и в каком порядке он создает остальных;
-
как уже стартовавшие агенты определят, что все остальные агенты уже созданы и можно начинать свою работу;
-
что делать, если при старте очередного агента возникает какая-то проблема.
В случае с кооперациями все проще: взаимосвязанные агенты создаются все сразу, включаются в кооперацию и кооперация регистрируется в SObjectizer. А уже сам SObjectizer следит, чтобы регистрация кооперации выполнялась транзакционно (т.е. чтобы все агенты кооперации стартовали, либо чтобы не стартовал ни один):
// Создаем и регистрируем кооперацию из 3-х агентов:
// - первый обслуживает TCP-подключение к MQTT-брокеру и реализует MQTT-протокол;
// - второй выполняет взаимодействие с СУБД.
// - третий выполняет обработку прикладных сообщений от брокера
// (используя при этом функциональность первых двух агентов);
so_5::environment_t & env = ...;
// Сам экземпляр кооперации, в котором будут храниться агенты.
auto coop = env.make_coop();
// Наполняем кооперацию...
coop.make_agent< mqtt_client >(...);
coop.make_agent< db_worker >(...);
coop.make_agent< message_processor >(...);
// Регистрируем кооперацию.
// Дальнейшей ее судьбой будет заниматься сам SObjectizer.
auto coop_handle = env.register_coop( std::move(coop) );
Отчасти кооперации решают ту же проблему, что и система супервизоров в Erlang: входящие в кооперацию агенты как бы находятся под контролем супервизора all-for-one. Т.е. сбой одного из агентов приводит к дерегистрации всех остальных агентов кооперации.
Агенты -- это конечные автоматы
Следующей важной чертой SObjectizer является то, что агенты в SObjectizer — это конечные автоматы. Агент может иметь произвольное количество состояний, одно из которых в конкретный момент времени является текущим состоянием. Реакция агента на внешнее воздействие зависит как от входящего сообщения, так и от текущего состояния. Агент может обрабатывать одно и то же сообщение по-разному в разных состояниях, для чего он подписывает разные обработчики сообщений в каждом из состояний.
Агенты в SObjectizer могут представлять из себя довольно сложные конечные автоматы: поддерживается вложенность состояний, временные ограничения на пребывания агента в состоянии, состояния с deep- и shallow-history, а также обработчики входа и выхода в состояние.
class device_handler final : public so_5::agent_t {
// Перечень состояний, в которых может находиться агент.
// Есть два состояния верхнего уровня...
state_t st_idle{ this }, st_activated{ this },
// ... и три состояния, которые являются подсостояниями
// состояния st_activated.
st_cmd_sent{ initial_substate_of{ st_activated } },
st_cmd_accepted{ substate_of{ st_activated } },
st_failure{ substate_of{ st_activated } };
...
public :
void so_define_agent() override {
// Начинаем работать в состоянии st_idle.
st_idle.activate();
// При получении команды в состоянии st_idle просто меняем
// состояние и делегируем обработку команды состоянию st_activated.
st_idle.transfer_to_state< command >( st_activated );
// В состоянии st_activated реагируем всего на одно сообщение:
// по приходу turn_off возвращаемся в состояние st_idle.
// При этом реакция на сообщение turn_off наследуется
// всеми дочерними состояниями.
st_activated
.event( [this](const turn_off & msg) {
turn_device_off();
st_idle.activate();
} );
// В состояние st_cmd_sent "проваливаемся" сразу после входа
// в состояние st_activated, т.к. st_cmd_sent является начальным
// подсостоянием.
st_cmd_sent
.event( [this](const command & msg) {
send_command_to_device(msg);
// Проверим, что произошло с устройством через 150ms.
send_delayed<check_status>(*this, 150ms);
} )
.event( [this](const check_status &) {
if(command_accepted())
st_cmd_accepted.activate();
else
st_failure.activate();
} );
...
// В состоянии st_failure находимся не более 50ms,
// после чего возвращаемся в st_idle.
// При входе в это состояние принудительно сбрасываем
// настройки устройства.
st_failure
.on_enter( [this]{ reset_device(); } )
.time_limit( 50ms, st_idle );
}
...
};
Данный фрагмент кода описывает конечный автомат вида:
![SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 6 SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 6](https://www.pvsm.ru/images/2022/06/15/SObjectizer-chto-eto-dlya-chego-eto-i-pochemu-eto-vyglyadit-imenno-tak-vzglyad-iz-2022-go-6.png)
Каналы (message chains)
Из CSP-модели SObjectizer позаимствовал такую штуку, как каналы, которые в SObjectizer называются message chains. CSP-ные каналы были добавлены в SObjectizer как инструмент для решения одной специфической проблемы: взаимодействие между агентами строится через обмен сообщениями, поэтому очень просто дать какую-то команду агенту или передать какую-то информацию агенту из любой части приложения — достаточно отсылать сообщение посредством send. Однако, как агенты могут воздействовать на не-SObjectizer частью приложения?
Эту проблему решают message chains (mchains). Message chain может выглядеть совсем как mbox: отсылать сообщения в mchain нужно посредством все того же send-а. А вот извлекаются сообщения из mchain функциями receive и select, для работы с которыми не требуется создавать SObjectizer-овских агентов.
Работа с message chain в SObjectizer похожа на работу с каналами в языке Go. Хотя есть и серьезные различия:
-
mchains в SObjectizer могут одновременно хранить сообщения любых типов, тогда как Go-шные каналы типизированы;
-
mchains в SObjectizer могут иметь, а могут и не иметь ограничений на размер очереди сообщений. Тогда как в Go размер канала ограничен всегда. Для mchain-а с ограниченным размером SObjectizer заставляет выбрать подходящее поведение для попытки поместить новое сообщение в полный mchain (например, подождать какое-то время и выбросить самое старое сообщение из mchain или ничего не ждать и сразу породить исключение).
Неожиданным следствием добавления mchains в SObjectizer стало то, что некоторые многопоточные приложения на SObjectizer стало возможно разрабатывать даже без применения агентов:
void parallel_sum_demo()
{
using namespace std;
using namespace so_5;
// Тип сообщения, которое отошлет каждая рабочая нить в конце своей работы.
struct consumer_result
{
thread::id m_id;
size_t m_values_received;
uint64_t m_sum;
};
wrapped_env_t sobj;
// Канал для отсылки сообщений рабочим нитям.
auto values_ch = create_mchain( sobj,
// Канал имеет ограничение на размер, поэтому назначаем
// паузу в 5м на попытку добавить сообщение в полный канал.
chrono::minutes{5},
// Не более 300 сообщений в канале.
300u,
// Память под внутреннюю очередь канала выделяется заранее.
mchain_props::memory_usage_t::preallocated,
// Если место в канале не появилось даже после 5м ожидания,
// то просто прерываем все приложение.
mchain_props::overflow_reaction_t::abort_app );
// Простой канал для ответных сообщений от рабочих потоков.
auto results_ch = create_mchain( sobj );
// Рабочие потоки.
vector< thread > workers;
for( size_t i = 0; i != thread::hardware_concurrency(); ++i )
workers.emplace_back( thread{ [&values_ch, &results_ch] {
// Последовательно читаем значения из входного
// канала, подсчитываем количество значений и
// их сумму.
size_t received = 0u;
uint64_t sum = 0u;
receive( from( values_ch ).handle_all(),
[&sum, &received]( unsigned int v ) {
++received;
sum += v;
} );
// Отсылаем результирующие значения назад.
send< consumer_result >( results_ch,
this_thread::get_id(), received, sum );
} } );
// Отсылаем несколько значений во входной канал. Эти значения будут
// распределятся между рабочими потоками, висящими на чтении данных
// из входного канала.
for( unsigned int i = 0; i != 10000; ++i )
send< unsigned int >( values_ch, i );
// Закрываем входной канал и даем возможность дочитать его содержимое
// до самого конца.
close_retain_content( values_ch );
// Получаем результирующие значения от всех рабочих нитей.
receive(
// Мы точно знаем, сколько значений должно быть прочитано.
from( results_ch ).handle_n( workers.size() ),
[]( const consumer_result & r ) {
cout << "Thread: " << r.m_id
<< ", values: " << r.m_values_received
<< ", sum: " << r.m_sum
<< endl;
} );
for_each( begin(workers), end(workers), []( thread & t ) { t.join(); } );
}
Когда mchains только появились в SObjectizer, то операция select могла использоваться только для чтения сообщений из нескольких каналов. Однако, начиная с версии 5.7 (а это 2020-й год) select может не только читать, но и отсылать сообщения в каналы, которые доступны для записи. Вот так, например, выглядит пример fibonacci из документации к языку Go в текущей версии SObjectzier:
#include <so_5/all.hpp>
#include <chrono>
using namespace std;
using namespace std::chrono_literals;
using namespace so_5;
struct quit {};
void fibonacci( mchain_t values_ch, mchain_t quit_ch )
{
int x = 0, y = 1;
mchain_select_result_t r;
do
{
r = select(
from_all().handle_n(1),
// Отсылаем новое сообщение типа 'int' со значением 'x' внутри
// когда values_ch оказывается готовым к записи.
send_case( values_ch, message_holder_t<int>::make(x),
[&x, &y] { // Эта лябмда будет вызвана после завершения send-а.
auto old_x = x;
x = y; y = old_x + y;
} ),
// Если в quit_ch пришло сообщение 'quit', то забираем его.
receive_case( quit_ch, [](quit){} ) );
}
// Продолжаем цикл пока мы хоть что-то отсылаем и ничего не пролучили.
while( r.was_sent() && !r.was_handled() );
}
int main()
{
wrapped_env_t sobj;
thread fibonacci_thr;
auto thr_joiner = auto_join( fibonacci_thr );
// Канал для чисел Фибоначчи будет иметь ограниченную ёмкость.
auto values_ch = create_mchain( sobj, 1s, 1,
mchain_props::memory_usage_t::preallocated,
mchain_props::overflow_reaction_t::abort_app );
auto quit_ch = create_mchain( sobj );
auto ch_closer = auto_close_drop_content( values_ch, quit_ch );
fibonacci_thr = thread{ fibonacci, values_ch, quit_ch };
// Читаем первые 10 значений из values_ch.
receive( from( values_ch ).handle_n( 10 ),
// И отображаем очередное прочитанное значение на stdout.
[]( int v ) { cout << v << endl; } );
send< quit >( quit_ch );
}
Зачем это?
Наверняка у читателей, которые никогда раньше не использовали Actor Model и Publish-Subscribe, уже возник вопрос: «И что, все вышеперечисленное действительно упрощает разработку многопоточных приложений на C++?»
Да. Упрощает. Проверенно на людях. Многократно.
И уже далеко не только на тех людях, которым (не)повезло работать под моим началом и у которых не было выбора. Кто-то просто находит информацию о SObjectizer в Интернете и делает на SObjectizer-е проект даже не обращаясь к нам с вопросами, не говоря уже о просьбах о помощи.
Понятное дело, упрощает не для всех типов приложений. Ведь многопоточность — это инструмент, который используется в двух очень разных направлениях. Первое направление, называемое parallel computing, использует потоки для загрузки всех имеющихся вычислительных ресурсов и сокращения общего времени расчета вычислительных задач. Например, ускорение перекодирования видео за счет загрузки всех вычислительных ядер, при этом каждое ядро выполняет одну и ту же задачу, но на своем наборе данных. Это не то направление, для которого создавался SObjectizer. Для упрощения решения такого класса задач предназначены другие инструменты: OpenMP, Intel Threading Building Blocks, HPX и т.д.
Второе направление, называемое concurrent computing, использует многопоточность для обеспечения параллельного выполнения множества (почти) независимых активностей. Например, почтовый клиент в одном потоке может отправлять исходящую почту, во втором — загружать входящую, в третьем — редактировать новое письмо, в четвертом — выполнять фоновую проверку орфографии в новом письме, в пятом — проводить полнотекстовый поиск по почтовому архиву и т.д.
SObjectizer создавался как раз для направления concurrent computing, а перечисленные выше возможности SObjectizer позволяют уменьшить объем головной боли у разработчика.
Ключевой момент №1: асинхронный обмен сообщениями
Прежде всего уменьшение объема головной боли происходит за счет выстраивания взаимодействия между агентами через асинхронный обмен сообщениями.
Взаимодействие независимых рабочих нитей через очереди сообщений гораздо проще, чем через ручную работу с разделяемыми данными, защищенными семафорами или мутексами. Причем тем проще, чем больше рабочих потоков в приложении и чем чаще и разнообразнее их взаимодействие.
Запутаться в мутексах и условных переменных несложно даже на десятке рабочих потоков. А уж когда счет идет на сотни рабочих нитей, то ручная возня с низкоуровневыми примитивами синхронизации вообще оказывается за гранью возможностей даже опытных разработчиков. Тогда как сотня нитей, взаимодействующих через очереди сообщений, как показала практика, совершенно не проблема.
Так что главное, что дает разработчику SObjectizer (как и любая другая реализация Actor Model) — это возможность представления независимых активностей внутри приложения в виде агентов, общающихся с окружающим миром только через сообщения.
Ключевой момент №2: привязка агентов к рабочему контексту
Здравый смыл подсказывает (и практика это подтверждает), что выдать всем агентам по собственной рабочей нити не есть хорошо. Приложению может потребоваться десять тысяч независимых агентов. Или сто тысяч. Или даже миллион. Очевидно, что наличие такого количества рабочих нитей в системе ни к чему хорошему не приведет. Даже если ОС и будет способна создать их (смотря какая ОС и на каком оборудовании), то накладные расходы на обеспечение их работы все равно окажутся слишком большими, чтобы построенное таким образом приложение работало с приемлемой производительностью и отзывчивостью.
Противоположность, когда все агенты привязываются к одной общей нити или к одному единственному пулу нитей, также не является идеальным решением для всех случаев. Например, в приложении может оказаться десяток агентов, которым приходится работать со сторонним синхронным API (делать запросы к БД, общаться с подключенным к компьютеру устройствами, выполнять тяжелые вычислительные операции и т.д.). Каждый такой агент способен затормозить работу всех остальных агентов, которые окажутся с ним на одной рабочей нити. Несколько таких агентов запросто могут затормозить все приложение, если оно использует в работе единственный пул рабочих потоков: просто каждый из агентов займет один из потоков пула…
Как раз для решения этих проблем в SObjectizer есть диспетчеры и такая архиважная операция, как привязка агентов к соответствующим диспетчерам. Все вместе это дает должную свободу и гибкость разработчику, при этом избавляя разработчика от забот по управлению этими потоками.
Программист может создать столько диспетчеров, сколько ему нужно, и так распределить своих агентов между этими диспетчеры, как ему представляется правильным. Например, в приложении могут быть:
-
один диспетчер типа one_thread, на котором некий агент работает MQTT-клиентом;
-
один диспетчер типа thread_pool, на котором работают агенты, отвечающие за обработку сообщений из MQTT-шных топиков;
-
один диспетчер типа active_obj, к которому привязываются агенты для взаимодействия с СУБД;
-
еще один диспетчер типа active_obj, на котором будут работать агенты, общающиеся с подключенными к компьютеру HSM-ами;
-
и еще один thread_pool-диспетчер для агентов, которые следят и управляют всей описанной выше кухней.
![SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 7 SObjectizer: что это, для чего это и почему это выглядит именно так? Взгляд из 2022-го - 7](https://www.pvsm.ru/images/2022/06/15/SObjectizer-chto-eto-dlya-chego-eto-i-pochemu-eto-vyglyadit-imenno-tak-vzglyad-iz-2022-go-7.jpg)
Ключевой момент №3: таймеры "из коробки"
Еще одной штукой, которую сами пользователи SObjectizer отмечают как одну из важнейших, является поддержка отложенных и периодических сообщений. Т.е. работа с таймерами.
Очень часто бывает нужно выполнить какое-то действие через N миллисекунд. А затем через M миллисекунд проверить наличие результата. И, если результата нет, выждать K миллисекунд и повторить все заново. Ничего сложного: есть send_delayed, которая делает отложенную на указанное время отсылку сообщения.
Зачастую агенты работают на тактовой основе. Скажем, раз в секунду агент просыпается, выполняет пачку накопившихся за последнюю секунду операций, после чего засыпает до наступления очередного такта. Опять ничего сложного: есть send_periodic, которая повторяет доставку одного и того же сообщения с заданным темпом.
Почему SObjectizer именно такой?
SObjectizer никогда не был экспериментальным проектом, он всегда применялся для упрощения повседневной работы с C++. Каждая новая версия SObjectizer сразу шла в работу, SObjectizer постоянно использовался в разработке коммерческих проектов (в частности, в нескольких business-critical проектах компании Интервэйл, но не только). Это накладывало свой отпечаток на его развитие.
Работы над последним вариантом SObjectizer (мы его называем SObjectizer-5), начались в 2010-ом, когда стандарт C++11 еще не был принят, какие-то вещи C++11 кое-где уже поддерживались, а каких-то пришлось ждать более пяти лет.
В таких условиях не все получалось сделать удобно и лаконично с первого раза. Местами не хватало опыта использования C++11. Очень часто нас ограничивали возможности компиляторов, с которыми приходилось иметь дело. Можно сказать, что движение вперед шло методом проб и ошибок.
При этом нам требовалось еще и заботиться о совместимости: когда SObjectizer лежит в основе business-critical приложений, нельзя просто выбросить какой-то кусок из SObjectizer или каким-то кардинальным образом поменять часть его API. Поэтому даже если время показывало, что где-то мы ошиблись и что-то можно делать проще и удобнее, то возможности «взять и переписать» не было. Мы двигались и двигаемся эволюционным путем, постепенно добавляя новые возможности, но не выбрасывая в одночасье старые куски. В качестве небольшой иллюстрации: осенью 2014-го года состоялся релиз версии 5.5.0 после чего ветка 5.5 развивалась без серьезных ломающих изменений до середины 2019-го года (т.е. больше четырех лет), при этом состоялось больше двадцати обновлений в рамках ветки 5.5.
На серьезное обновление SObjectizer-5 мы решились лишь в 2019-ом году. И с тех пор опять самым серьезным образом относимся к сохранению совместимости.
SObjectizer приобрел свои уникальные черты в результате многолетнего использования SObjectizer в реальных проектах. К сожалению, эта уникальность «вылазит боком» при попытках рассказать о SObjectizer широкой публике. Слишком уж SObjectizer не похож на Erlang и другие проекты, созданные по образу и подобию Erlang-а (например, C++ Actor Framework или Akka).
Распределенности "из коробки" нет
Есть одна важная вещь, которая проистекает из опыта использования SObjectizer, которая нам кажется правильной и естественной, но которая вызывает нездоровую реакцию публики: отсутствие поддержки в SObjectizer-5 встроенных средств для распределенности. Мы часто слышим что-то вроде «Ну как же так? Вот в Erlang-е есть, в Akka — есть, в CAF — есть, а у вас нет?!!»
Нет. По очень простой причине: в SObjectizer-4 такая поддержка была, но со временем выяснилось, что не бывает транспорта, который бы идеально подходил под разные условия. Если узлы распределенного приложения гоняют друг-другу большие куски видеофайлов — это одно. Если обмениваются сотнями тысяч мелких пакетов — совсем другое. Если C++ приложение должно общаться с Java приложениями — это третье. И т.д.
Поэтому мы решили не добавлять в SObjectizer-5 универсальный транспорт, который мог бы оказаться весьма посредственным в каждом из реальных сценариев использования, а задействовать те коммуникационные возможности, которые нужны под задачу (подробнее об этом говорилось здесь). Где-то это AMQP, где-то MQTT, где-то REST. Просто все это реализуется сторонними инструментами. Что в итоге обходится проще, дешевле и эффективнее.
А если в SObjectizer чего-то не хватает?
Мы стараемся сохранить SObjectizer как минималистичное ядро без внешних зависимостей. Поэтому в самом SObjectizer можно найти только необходимый для работы минимум.
Кроме того, с 2017-го года мы развиваем проект-компаньон so5extra в котором собрано некоторое количество дополнительных полезных штуковин. Например, пять дополнительных типов mbox-ов (и еще три на подходе), средства интеграции с Asio, средства для поддержки синхронного взаимодействия между агентами и т.д.
Так что если чего-то не нашлось в SObjectizer, то можно заглянуть в so5extra, вдруг там уже есть то, что вам нужно.
Если не SObjectizer, то что?
Коль скоро речь зашла о других подобных разработках, то можно сказать, что живых и развивающихся проектов подобного рода для C++ не так уж и много:
-
прежде всего это CAF, который является настолько близкой реализацией Erlang-а средствами C++, насколько это возможно;
-
старый, промышленного качества проект QP, сильно заточенный под разработку встраиваемых систем;
-
actor-zeta от известного в российских около-C++ных кругах Александра Боргадта;
-
rotor, относительно молодой фреймворк, созданный под влиянием The Reactive Manifesto и SObjectizer.
У каждого из этих проектов есть свои сильные и слабые стороны. Но, вообще говоря, на их фоне SObjectizer со своими возможностями, кросс-платформенностью, стабильностью и совместимостью между версиями, производительностью, количеством тестов, примеров и объемом документации выглядит отнюдь не бедным родственником. Что, опять же, является следствием того, что SObjectizer всегда был инструментом для решения практических задач.
Что можно выделить за прошедшие шесть лет?
Если в двух словах, то SObjectizer-5 за прошедшие с момента первой публикации на Хабре годы оброс "жирком", заматерел, в чем-то упростился, в чем-то стал более логичным, в чем-то стал сложнее.
Показательным для меня оказалось то, что приступая к переделке статьи я думал, что мне придется многое в тексте поменять, т.к. SObjectizer-5 довольно сильно изменился в 2019-ом, когда мы открыли ветку 5.6. Однако, пришлось лишь подправить вызовы API в паре примеров и добавить всего один новый пример для select-а с send_case.
Так что принципиально ничего не изменилось, все ключевые идеи продолжают работать.
Очень важной вехой стало появление проекта-компаньона so5extra. И не только потому, что он сейчас является полигоном для обкатки новой функциональности для ядра SObjectizer. so5extra был еще и попыткой заработать на своем OpenSource за счет двойного лицензирования. Очень полезный для нас оказался опыт, хоть и горький.
Было написано множество статьей с рассказом о SObjectizer. Наиболее важными, наверное, являются статьи, в которых мы рассказывали о том, как SObjectizer может применяться:
И, конечно же, публикация "Если проект «Театр», используй акторов…" от ув.тов.PavelVainerman с рассказом о применении SObjectizer для управления сценическим оборудованием. Обычно никто не говорит об использовании нашего инструмента в своих проектах, о некоторых внедрениях мы узнаем совершенно случайно. А тут человек взял и написал целую статью.
В общем, ничего революционного, простое поступательное движение вперед.
Послесловие из 2022-го
Главное, что хочется сказать в качестве послесловия спустя шесть лет после публикации здесь первой статьи про SObjectizer: проект все еще живет и все еще развивается. Не смотря ни на что.
Что будет дальше? А будем посмотреть. Если спустя еще шесть лет доведется делать очередную ревизию этой обзорной статьи, то это будет очень даже не плохо.
Автор: Евгений Охотников