Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
SObjectizer — это относительно небольшой C++17 фреймворк, который позволяет использовать в С++ программах такие подходы, как Actor Model, Publish-Subscribe и Communicating Sequential Processes (CSP). Что существенно упрощает разработку сложных многопоточных приложений на C++. Если читатель в первый раз слышит о SObjectizer-е, то составить впечатление о нем можно по этой презентации, или из этой уже достаточно старой статьи.
Вообще говоря, подобных открытых, все еще живых и все еще развивающихся инструментов для C++ не так уж и много. Можно вспомнить разве что QP/C++, CAF: C++ Actor Framework, actor-zeta и совсем молодой еще проект rotor. Выбор есть, но не сказать, что большой.
Недавно стала доступна очередная "мажорная" версия SObjectizer-а, где наконец-то появилась штука, о которой разговоры ходили давно, и к реализации которой я несколько раз безуспешно подступался. Можно сказать, что достигнута знаковая веха. Это также повод, чтобы рассказать о том, что ждет SObjectizer после релиза версии 5.7.0.
Поддержка send_case в select()
Итак, самое важное нововведение, которое появилось в v.5.7.0 и ради которого даже сломана совместимость с вышедшей в прошлом году v.5.6 (а совместимость мы просто так не ломаем) — это поддержка send_case в функции select(). Что сделало SObjectizer-овский select() гораздо более похожим на select из языка Go. Теперь посредством select() можно не только читать сообщения из нескольких CSP-шных каналов, но и отсылать исходящие сообщения в те каналы, которые оказались готовы для записи.
Но для того, чтобы раскрыть эту тему, нужно начать издалека.
Появление элементов CSP в SObjectizer-5
Элементы CSP, а именно аналоги CSP-шных каналов, появились в SObjectizer-5 вовсе не для того, чтобы поставить галочку напротив пункта "поддержка CSP", а для решения вполне себе практической задачи.
Дело было в том, что когда все приложение целиком базируется на SObjectizer-е, то обмен информацией между различными сущностями (частями) программы реализуется единственным очевидным способом. Все в приложении представлено в виде агентов (акторов) и агенты просто отсылают друг другу сообщения стандартным образом.
Но когда в приложении только часть функциональности реализуется на SObjectizer-е...
Например, GUI-приложение на Qt или wxWidgets, у которого основная часть кода — это GUI, а SObjectizer нужен чтобы выполнять какие-то фоновые задачи. Или часть приложения написана с использованием голых нитей и Asio, а прочитанные посредством Asio из сети данные отсылаются SObjectizer-овским агентам на обработку.
Когда в приложении есть SObjectizer-часть и не-SObjectizer-часть, то возникает вопрос: а как передать информацию из SObjectizer-части приложения в не-SObjectizer-часть?
Решение было найдено в виде т.н. message chains (mchains), т.е. цепочек сообщений. Которые, так уж вышло, оказались суть CSP-шные каналы. SObjectizer-часть приложения отсылает сообщения в mchain обычным образом, посредством штатной функции send().
Читать же сообщения из не-SObjectizer-части можно было с помощью новой функции receive(), для использования которой не нужно было ни создавать агентов, ни погружаться в какие-то другие дебри SObjectizer-а.
Получилась вполне себе понятная и рабочая схема.
Начало использования mchains не по назначению
Более того, схема получилась настолько понятная и рабочая, что довольно быстро некоторые приложения на SObjectizer-е начали писаться вообще без агентов, только на mchain-ах. Т.е. используя CSP подход, а не Actor Model. Об этом уже были статьи здесь, на Хабре: раз и два.
Это привело к двум интересным последствиям.
Во-первых, функция receive() обросла продвинутыми возможностями. Это нужно было для того, чтобы можно было сделать всего один вызов receive(), возврат из которого происходил бы когда вся нужная работа уже сделана. Вот примеры того, что может SObjectizer-овский receive():
using namespace so_5;
// Прочитать и обработать 3 сообщения.
// Если 3 сообщений сейчас в mchain нет, то дождаться их.
// Возврат из receive либо после обработки 3 сообщений,
// либо если канал будет принудительно закрыт.
receive( from(chain).handle_n( 3 ),
handlers... );
// Прочитать и обработать 3 сообщения.
// Но если всех трех сообщений в mchain нет, то ждать появления
// нового сообщения не более 200ms.
// Т.е. если канал пуст более 200ms, то вернуться из receive, даже если
// трех сообщений обработано еще не было.
receive( from(chain).handle_n( 3 ).empty_timeout( milliseconds(200) ),
handlers... );
// Читать и обрабатывать все сообщения из канала.
// Но обработка завершается если оказывается, что канал пуст
// более 500ms.
receive( from(chain).handle_all().empty_timeout( milliseconds(500) ),
handlers... );
// Читать и обрабатывать все сообщения из канала.
// Но делать это не более чем 2s.
receive( from(chain).handle_all().total_time( seconds(2) ),
handlers... );
Во-вторых, вскоре выяснилось, что даже не смотря на то, что в SObjectizer-овский mchain можно помещать сообщения разных типов, и даже не смотря на наличие продвинутой функции receive(), временами нужно иметь возможность работать сразу с несколькими каналами...
select(), но только для чтения
В SObjectizer была добавлена функция select() для чтения и обработки сообщений из нескольких mchain-ов. Понятное дело select() появился не просто так, а под влиянием возможностей языка Go. Но у SObjectizer-овский select() были две особенности.
Во-первых, наш select(), как и receive(), был ориентирован на сценарии, когда select() вызывается всего один раз и внутри него делается вся полезная работа. Например:
using namespace so_5;
mchain_t ch1 = env.create_mchain(...);
mchain_t ch2 = env.create_mchain(...);
// Извлечь и обработать 3 сообщения.
// Это могут быть 3 сообщения из канала ch1.
// Или 2 из ch1 и одно из ch2.
// Или одно из ch1 и 2 из ch2...
//
// Если сообщений нет, то ждать их без ограничения по времени.
// select() возвращает управление если будет обработано 3 сообщения,
// или все каналы будут закрыты.
select( from_all().handle_n( 3 ),
receive_case( ch1,
[]( const first_message_type & msg ) { ... },
[]( const second_message_type & msg ) { ... } ),
receive_case( ch2,
[]( const third_message_type & msg ) { ... },
[]( so_5::mhood_t< some_signal_type > ) { ... } ),
... ) );
// Извлечь и обработать 3 сообщения.
// Если оба канала пусты, то ждать не более 200ms.
select( from_all().handle_n( 3 ).empty_timeout( milliseconds(200) ),
receive_case( ch1,
[]( const first_message_type & msg ) { ... },
[]( const second_message_type & msg ) { ... } ),
receive_case( ch2,
[]( const third_message_type & msg ) { ... },
[]( so_5::mhood_t< some_signal_type > ) { ... } ),
... ) );
// Извлечь и обработать все сообщения из каналов.
// Если оба канала пусты, то ждать не более 500ms.
select( from_all().handle_all().empty_timeout( milliseconds(500) ),
receive_case( ch1,
[]( const first_message_type & msg ) { ... },
[]( const second_message_type & msg ) { ... } ),
receive_case( ch2,
[]( const third_message_type & msg ) { ... },
[]( so_5::mhood_t< some_signal_type > ) { ... } ),
... ) );
Во-вторых, select() не поддерживал операции отсылки сообщений в канал. Т.е. читать сообщения из каналов можно было. А вот отсылать сообщения в канал посредством select() — нет.
Сейчас уже даже сложно вспомнить, почему так получилось. Вероятно потому, что select() с поддержкой send_case — это оказалась сложная задачка и для ее решения сходу ресурсов не нашлось.
mchain-ы в SObjectizer хитрее, чем каналы в Go
Изначально select() без поддержки send_case проблемой не считался. Дело в том, что у mchain-ов в SObjectizer есть своя специфика, которой нет у Go-шных каналов.
Во-первых, SObjectizer-овские mchain-ы делятся на безразмерные и с фиксированной максимальной емкостью. Поэтому если send() выполняется для безразмерного mchain-а, то этот send() не будет заблокирован в принципе. Поэтому нет смысла использовать select() для отсылки сообщения в безразмерный mchain.
Во-вторых, для mchain-ов с фиксированной максимальной емкостью при создании сразу указывается, что происходит при попытке записать сообщение в полный mchain:
- нужно ли ждать появления свободного места в mchain. И если нужно, то как долго;
- если свободное место не появилось, то что делать: удалять самое старое сообщение из mchain-а, игнорировать новое сообщение, бросать исключение или вообще вызывать std::abort() (этот жесткий сценарий вполне себе востребован на практике).
Т.о., довольно частый (насколько мне известно) сценарий использования select в Go для отсылки сообщения, которая не заблокирует намертво гороутину, в SObjectizer был доступен сразу "искаропки" и без select-а.
В конце-концов полноценный select()
Тем не менее, время шло, изредка возникали случаи, когда отсутствие поддержки send_case в select() все-таки сказывалось. Причем в этих случаях встроенные возможности mchain-ов не помогали, скорее напротив.
Поэтому периодически я пытался подойти к вопросу реализации send_case. Но до недавнего времени ничего не получалось. Главным образом потому, что не удавалось придумать дизайн этого самого send_case. Т.е. как должен выглядеть send_case внутри select()? Что именно он должен делать в случае возможности отсылки? В случае невозможности? Как быть с делением на безразмерные и фиксированные mchain-ы?
Устроившие меня ответы на эти и другие вопросы удалось найти только в декабре 2019-го. Во многом благодаря консультациям с людьми, которые знакомы с Go и использовали Go-шые select-ы в реальной работе. Ну а как только картинка send_case окончательно сложилась, как тут же подоспела и реализация.
Так что сейчас можно написать вот так:
using namespace so_5;
struct Greeting {
std::string text_;
};
select(from_all().handle_n(1),
send_case(ch, message_holder_t<Greeting>::make("Hello!"),
[]{ std::cout << "Hello sent!" << std::endl; }));
Важно то, что send_case в select() игнорирует реакцию на перегрузку, которая была задана для целевого mchain. Так, в примере выше ch мог быть создан с реакцией abort_app при попытке отослать сообщение в полный канал. И если попытаться вызвать простой send() для записи в ch, то может быть вызван std::abort(). Но вот в случае select()-а этого не произойдет, select() будет ждать пока в ch появится свободное место. Либо пока ch не будет закрыт.
Вот еще несколько примеров того, что умеет send_case в SObjectizer-овском select():
using namespace so_5;
// Отослать одно сообщение в канал, который первым
// окажется готов к записи.
// Ждать без ограничения времени.
select(from_all().handle_n(1),
send_case(ch1, message_holder_t<FirstMessage>::make(...), []{...}),
send_case(ch2, message_holder_t<SecondMessage>::make(...), []{...}),
send_case(ch3, message_holder_t<ThirdMessage>::make(...), []{...}));
// Выполнить три операции отсылки сообщения.
// Отсылка может закончится успешно (сообщение отослано)
// или неудачно (канал закрыт).
select(from_all().handle_n(3),
send_case(ch1, message_holder_t<FirstMessage>::make(...), []{...}),
send_case(ch2, message_holder_t<SecondMessage>::make(...), []{...}),
send_case(ch3, message_holder_t<ThirdMessage>::make(...), []{...}));
// Попытаться отослать сообщение в chW.
// Ждать свободное место в chW не появилось за 150ms.
select(from_all().handle_n(1).empty_timeout(150ms),
send_case(chW, message_holder_t<Msg>::make(...), []{...}));
// Попытаться отослать сообщение в chW.
// Не ждать, если в chW нет свободного места.
select(from_all().handle_n(1).no_wait_on_empty(),
send_case(chW, message_holder_t<Msg>::make(...), []{...}));
// Попытаться отослать все сообщения, но ждать этого не более 250ms.
select(from_all().handle_all().total_time(250ms),
send_case(ch1, message_holder_t<FirstMessage>::make(...), []{...}),
send_case(ch2, message_holder_t<SecondMessage>::make(...), []{...}),
send_case(ch3, message_holder_t<ThirdMessage>::make(...), []{...}));
Естественно, что send_case в select() можно использовать совместно с receive_case:
// Либо отослать одно сообщение в первый из готовых к
// записи каналов. Либо прочитать одно сообщение из первого
// непустого канала.
select(from_all().handle_n(1),
send_case(ch1, message_holder_t<FirstMsg>::make(...), []{...}),
send_case(ch2, message_holder_t<SecondMsg>::make(...), []{...}),
receive_case(ch3, [](...){...}),
receive_case(ch4, [](...){...}));
Так что теперь в SObjectizer-е подход CSP можно использовать, что называется, во все поля. Будет не хуже, чем в Go. Многословнее, конечно же. Но не хуже :)
Можно сказать, что закончилась многолетняя история добавления поддержки CSP-подхода в SObjectizer.
Другие важные вещи в этом релизе
Окончательный переезд на GitHub
Изначально SObjectizer жил и развивался на SourceForge. Года эдак с 2006-го. Но на SF.net скорость работы Subversion падала все ниже и ниже, поэтому в прошлом году мы перебрались на BitBucket и Mercurial. Стоило нам это сделать как Atlassian объявил, что Mercurial репозитории с BitBucket-а в скором времени удалят вообще. Поэтому с августа 2019-го и SObjectizer, и so5extra располагаются на GitHub-е.
На SF.net осталось все старое содержимое, включая Wiki с документацией на предыдущие версии SObjectizer-а. А также раздел Files откуда можно загрузить архивы разных версий SObjectizer/so5extra и не только (например, PDF-ки с некоторым презентациями про SObjectizer).
В общем, ищите нас теперь на GitHub-е. И не забывайте ставить звездочки, у нас их слишком мало пока ;)
Исправлено поведение enveloped messages
В SO-5.7.0 состоялся небольшой фикс, о котором можно было бы и не упоминать. Но сказать стоит, поскольку это хорошая демонстрация того, как влияют на друг на друга разнообразные фичи, накапливающиеся в SObjectizer-е за время его развития.
Четыре года назад в SObjectizer была добавлена поддержка агентов, которые являются иерархическими конечными автоматами (подробнее здесь). Затем, спустя еще пару лет, в SObjectizer добавились конверты с сообщениями. Т.е. сообщение при отсылке заворачивалось в дополнительный объект-конверт и этот конверт мог получать информацию о том, что с сообщением происходит.
Одной из особенностей механизма enveloped messages является то, что конверту сообщается, что сообщение доставлено до адресата. Т.е., что у агента-подписчика был найден обработчик для этого сообщения и что этот обработчик был вызван.
Выяснилось, что если агент-получатель сообщения является иерархическим конечным автоматом, который использует такую фичу, как suppress()
(т.е. принудительное игнорирование сообщения в конкретном состоянии), то конверт может получить неверное уведомление о доставке, хотя реально сообщение было отвергнуто получателем из-за suppress()
. Еще более интересная ситуация оказалась с transfer_to_state()
, т.к. после смены состояния агента-получателя обработчик для сообщения может быть найден, а может и отсутствовать. Но конверт о доставке сообщения информировался все равно.
Очень редкие случаи, которые, насколько мне известно, на практике ни у кого не проявились. Тем не менее просчет был допущен.
Поэтому в SO-5.7.0 этот момент доработан и если сообщение игнорируется в результате применения suppress()
или transfer_to_state()
, то конверт уже не будет думать, что сообщение до адресата доставлено.
Дополнительная библиотека so5extra сменила лицензию на BSD-3-CLAUSE
В 2017-ом году мы начали делать библиотеку дополнительных компонентов для SObjectizer под названием so5extra. За это время библиотека существенно разрослась и содержит множество полезных в хозяйстве вещей.
Изначально so5extra распространялась под двойной лицензией: GNU Affero GPL v.3 для открытых проектов и коммерческой для закрытых.
Теперь же мы сменили лицензию на so5extra и начиная с версии 1.4.0 so5extra распространяется под BSD-3-CLAUSE лицензией. Т.е. ее можно бесплатно использовать даже при разработке закрытого ПО.
Поэтому если вам чего-то не хватает в SObjectizer-е, то можно заглянуть в so5extra, вдруг там уже есть то, что вам нужно?
Будущее SObjectizer-а
Прежде чем сказать несколько слов о том, что ждет SObjectizer, нужно сделать важное отступление. Специально для тех, кто считает, что SObjectizer — это "эталонное нинужно", "наколеночная поделка", "студенческая лаба", "экспериментальный проектик, который авторы забросят когда наиграются"… (это только часть характеристик, которые нам довелось услышать от экспертов в этих наших Интернетах за последние 4-5 лет).
Я занимаюсь разработкой SObjectizer-а уже почти восемнадцать лет. И могу ответственно сказать, что он никогда не был экспериментальным проектом. Это практичный инструмент, который пошел в реальную работу начиная с самой своей первой версии в 2002-ом году.
И я, и мои коллеги, и люди которые рискнули взять и попробовать SObjectizer, многократно убеждались, что SObjectizer действительно делает разработку некоторых типов многопоточных приложений на C++ гораздо проще. Конечно же, SObjectizer не серебряная пуля и далеко не всегда он может быть использован. Но там, где он применим, он помогает.
Жизнь регулярно предоставляет возможность лишний раз в этом убедится. Нам на доработку время от времени попадает чужой многопоточный код, в котором ничего похожего на SObjectizer не было и вряд ли когда-нибудь появится. Разбирась с таким кодом то здесь, то там в глаза бросаются моменты, когда применение акторов или CSP-шных каналов могло бы сделать код и проще, и надежнее. Но нет, приходится выстраивать нетривиальные схемы взаимодействия нитей посредством mutex-ов и condition_variables там, где в SObjectizer-е можно было бы обойтись одним mchain-ом, парой сообщений и встроенным в SObjectizer таймером. А потом еще и тратить кучу времени, чтобы протестировать эти нетривиальные схемы...
Так что SObjectizer был полезен нам. Смею думать, что оказался полезен не только нам. И главное, он уже давно здесь и свободно доступен для всех желающих. Никуда он уже не уйдет. Да и куда уходить тому, что находится в OpenSource под пермиссивной лицензией? ;)
Другое дело, что мы сами реализовали в SObjectizer все свои большие хотелки. И будущее развитие SObjectizer-а будет определяться уже не столько нашими потребностями, сколько пожеланиями пользователей.
Будут такие пожелания — будут и новые фичи в SObjectizer-е.
Не будет… Ну значит мы будем просто время от времени выпускать корректирующие релизы и проверять работоспособность SObjectizer-а под новыми версиями C++ компиляторов.
Так что если вы хотите увидеть что-нибудь в SObjectizer-е, то дайте нам знать. Если вам нужна какая-то помощь с SObjectizer-ом, то не стесняйтесь, обращайтесь к нам (через Issues на GitHub-е или Google-группу), обязательно постараемся помочь.
Ну а я хочу поблагодарить тех читателей, которые смогли дочитать до конца этой статьи. И постараюсь ответить на любые вопросы о SObjectizer/so5extra, буде такие возникнут.
PS. Буду признателен, если читатели найдут время написать в комментариях интересно/полезно ли было читать статьи про SObjectizer и хотят ли они делать это и в будущем. Или же лучше и нам перестать тратить время на написание таких статей, и тем самым, перестать отнимать время пользователей Хабра?
PPS. А может быть кто-то рассматривал SObjectizer в качестве инструмента не не смог применить по тем или иным причинам? Было бы очень интересно узнать об этом.