Продолжаем знакомить читателей с открытым C++ным фреймворков под названием SObjectizer. Наш фреймворк упрощает разработку сложных многопоточных приложений за счет того, что C++программисту становятся доступны более высокоуровневые инструменты, позаимствованные из Модели Акторов, CSP и Publish-Subscribe. При этом, как бы высокопарно это не звучало, SObjectizer является одним из немногих открытых, живых и развивающихся акторных фреймворков для C++.
Мы уже посвятили SObjectizer-у более десятка статей на Хабре. Но все равно читатели жалуются на наличие «белых пятен» в понимании того, как SObjectizer работает и как взаимосвязаны между собой различные типы сущностей, которыми оперирует SObjectizer.
В этой статье мы попробуем заглянуть под капот SObjectizer-у и постараемся «на пальцах» и в картинках объяснить из чего он состоит и как, в общих чертах, он работает.
SObjectizer Environment
Начнем с такой штуки, как SObjectizer Environment (или SOEnv, если сокращенно). SOEnv — это контейнер, внутри которого создаются и работают все связанные с SObjectizer-ом сущности: агенты, кооперации, диспетчеры, почтовые ящики, таймеры и пр. Что можно проиллюстрировать следующей картинкой:
Фактически, чтобы начать работать с SObjectizer-ом, нужно создать и запустить экземпляр SOEnv. Например, вот в таком примере программист вручную создает экземпляр SOEnv в виде объекта типа so_5::wrapped_env_t:
int main() {
so_5::wrapped_env_t sobj{...};
... // Какая-то прикладная логика приложения.
return 0;
}
Этот экземпляр сразу же начнет работать и автоматически завершит свою работу при разрушении объекта so_5::wrapped_env_t.
Сама по себе сущность SOEnv как отдельное понятие нам потребовалось для того, чтобы иметь возможность запускать внутри одного приложения несколько независимых друг от друга экземпляров SObjectizer-а.
int main() {
so_5::wrapped_env_t first_soenv{...};
so_5::wrapped_env_t second_soenv{...};
...
so_5::wrapped_env_t another_soenv{...};
... // Какая-то прикладная логика приложения.
return 0;
}
Это дает возможность получить в своем приложении вот такую картинку:
Забавный факт. Наш ближайший и гораздо более распиаренный конкурент, C++ Actor Framework (он же CAF), еще не так давно умел запускать только одну подсистему акторов в приложении. И мы даже натыкались на обсуждение, в котором разработчиков CAF-а спрашивали почему так. Но со временем в CAF-е все же появилось понятие actor_system и возможность одновременно запустить в приложении несколько actor_system.
За что отвечает SObjectizer Environment?
Кроме того, что SOEnv является контейнером, хранящим в себе кооперации, диспетчеры и пр., SOEnv еще и управляет этими сущностями.
Например, при старте SOEnv должны быть запущены:
- таймер, который будет обслуживать отложенные и периодические сообщения;
- диспетчер по умолчанию, на котором будут работать все агенты, которые не были явно привязаны к другим диспетчерам;
- созданные пользователем публичные диспетчеры.
Соответственно, при останове SOEnv, все эти сущности должны быть остановлены.
Так же, когда пользователь хочет добавить в SOEnv своих агентов, SOEnv должен выполнить процедуру регистрации для кооперации с агентами пользователя. А когда пользователь хочет изъять своих агентов, SOEnv должен дерегистрировать кооперацию.
У SOEnv есть два основных репозитория, которыми он владеет и за содержимое которого отвечает. Первый репозиторий, который, возможно, совсем исчезнет в следующей мажорной версии SObjectizer-а, — это репозиторий публичных диспетчеров. У каждого публичного диспетчера должно быть свое уникальное строковое имя, по которому диспетчер может быть найден и переиспользован.
Второй репозиторий, являющийся самым главным, — это репозиторий коопераций. У каждой кооперации так же должно быть свое уникальное строковое имя, под которым кооперация и хранится в репозитории коопераций. Попытка зарегистрировать кооперацию с уже занятым именем завершится ошибкой.
Возможно, наличие имен у коопераций — это унаследованный от SObjectizer-4 рудимент. В настоящее время имена коопераций рассматриваются как достаточно неоднозначная фича и, возможно, со временем кооперации в SObjectizer станут безымянными. Но это не точно.
Итак, резюмируя:
- SOEnv владеет такими сущностями, как таймер, дефолтный и публичные диспетчеры, кооперации;
- при старте SOEnv запускает таймер, дефолтный и публичный диспетчеры;
- во время работы SOEnv отвечает за регистрацию и дерегистрацию коопераций;
- при завершении работы SOEnv дерегистрирует все остающиеся живые кооперации, останавливает публичные и дефолтный диспетчеры, после чего останавливает таймер.
Environment Infrastructure
Внутри SOEnv есть еще одна интересная штука, которая делает SOEnv более сложной сущностью, чем это могло показаться. Речь идет об SObjectizer Environment Infrastructure (или, сокращенно, env_infrastructure). Чтобы объяснить что это и зачем, нужно рассказать о том, с какими интересными условиями мы столкнулись по мере того, как SObjectizer стал использовался в задачах совершенно разного типа.
Когда SObjectizer-5 появился, SOEnv использовал многопоточность для выполнения своей работы. Так, таймер реализовывался отдельной таймерной нитью. Была отдельная рабочая нить, на которой SOEnv завершал дерегистрацию коопераций и освобождал все связанные с кооперациями ресурсы. И дефолтный диспетчер представлял из себя еще одну рабочую нить, на которой обслуживались заявки привязанных к дефолтному агенту диспетчеров.
Поскольку SObjectizer предназначен для упрощения реализации сложных многопоточных приложений, использование многопоточности внутри самого SObjectizer-а рассматривалось (да и рассматривается сейчас) как вполне нормальное и приемлемое решение.
Однако, время шло, SObjectizer начали применять в проектах, о которых мы сами раньше не думали, и стали обнаруживаться ситуации, когда многопоточный SOEnv — это слишком избыточно и дорого.
Например, небольшое приложение, которое периодически просыпается, проверяет наличие какой-то информации, обрабатывает эту информацию при появлении, складывает куда-то результат и засыпает снова. Все операции вполне могут выполняться на одном единственном рабочем потоке. Кроме того, само приложение должно быть легковесным и расходов на создание дополнительных рабочих потоков внутри SOEnv хотелось бы избежать.
Другой пример: еще одно небольшое однопоточное приложение, которое активно работает с сетью посредством Asio. Но при этом какую-то часть логики проще сделать не на Asio, а на SObjectizer-овских агентах. При этом хотелось бы заставить и Asio, и SObjectizer работать на одном и том же рабочем контексте. Более того, хотелось бы еще и избежать дублирования функциональности: раз уж используется Asio и в Asio есть свои таймеры, то нет смысла запускать такой же механизм еще и в SOEnv, пусть уж SObjectizer использует таймеры из Asio для обслуживания отложенных и периодических сообщений.
Чтобы сделать возможным использование SObjectizer-а еще и в таких специфических условиях, в SOEnv появилось понятие env_infrastructure. На уровне C++ env_infrastructure — это интерфейс с некоторым набором методов. При запуске SOEnv создается объект, реализующий этот интерфейс, после чего SOEnv использует данный объект для выполнения своей работы.
В состав SObjectizer-а входят несколько готовых реализаций env_infrastructure: обычная многопоточная; однопоточная, которая при этом не является thread-safe; однопоточная, которая при этом является thread-safe. Плюс в so_5_extra есть еще пара однопоточных env_infrastructure на базе Asio — одна thread-safe, а вторая — нет. При большом желании пользователь может написать и свою собственную env_infrastructure, хотя дело это непростое, да и неблагодарное, т.к. мы, разработчики SObjectizer, не можем гарантировать, что интерфейс env_infrastructure будет оставаться неизменным. Уж слишком глубоко эта штука интегрируется с SOEnv.
Агенты, кооперации, диспетчеры и disp_binder-ы. А так же event_queue
При работе с SObjectizer-ом разработчику, в основном, приходится иметь дело со следующими сущностями:
- агентами, в которых и реализуется бизнес-логика приложения (или части приложения);
- диспетчерами, которые определяют, как и где будут работать агенты;
- сообщениями и почтовыми ящиками, посредством которых агенты обмениваются информацией между собой и другими частями приложения.
В данном разделе мы поговорим про агентов и диспетчеров, а в следующем пройдемся по почтовым ящикам.
Диспетчеры и event_queue
Разговор про агентов начнем с диспетчеров, т.к. поняв для чего нужны диспетчеры, будет легче разобраться в венегрете из агентов, коопераций агентов и disp_binder-ов.
Ключевой момент в реализации SObjectizer-а — это то, что SObjectizer сам доставляет сообщения до агентов. Агенту не нужно в цикле вызывать какой-нибудь метод receive, а затем анализировать тип возвращенного из receive сообщения. Вместо этого агент подписывается на интересующие его сообщения и когда нужное сообщение возникает, SObjectizer сам вызывает у агента метод-обработчик этого сообщения.
Однако, самый важный вопрос в этой схеме таков: где именно SObjectizer делает вызов метода-обработчика? Т.е. на контексте какой рабочей нити агент будет обрабатывать адресованные ему сообщения?
Вот как раз диспетчер — это и есть та самая сущность в SObjectizer-е, которая и отвечает за предоставление рабочего контекста для обработки сообщений агентами. Грубо говоря, диспетчер владеет одной или несколькими рабочими нитями, на которых и происходит вызов методов-обработчиков у агентов.
В состав SObjectizer-а входит восемь штатных диспетчеров — начиная от самых примитивных (например, one_thread или thread_pool) и заканчивая продвинутыми (вроде adv_thread_pool или prio_dedicated_threads::one_per_prio). Разработчик может создать столько диспетчеров в своем приложении, сколько ему нужно.
Например, представим себе, что нужно сделать приложение, которое будет опрашивать несколько подключенных к компьютеру устройств, как-то обрабатывать полученную информацию, складывать ее в БД и отдавать эту информацию во внешний мир через какой-то MQ-шный брокер. При этом взаимодействие с устройствами будет синхронным, а обработка данных может быть довольно сложной и многоуровневой.
Можно создать по одному one_thread-диспетчеру на каждое устройство. Соответственно, все действия с устройством будут выполняться на отдельной нити и блокировка этой нити синхронной операцией не будет сказываться на остальном приложении. Так же отдельный one_thread-диспетчер можно выделить для работу с БД. Для остальных задач можно будет создать один единственный thread_pool-диспетчер.
Таким образом, когда разработчик выбирает SObjectizer в качестве инструмента, то одна из основных задач разработчика — это создание нужных разработчику диспетчеров и привязка агентов к соответствующим диспетчерам.
event_queue
Итак, в SObjectizer агенту не нужно самостоятельно определять, есть ли какое-то ожидающее обработки сообщение. Вместо этого сам диспетчер, к которому привязан агент, вызывает у агента методы-обработчики для поступивших агенту сообщений.
Но здесь возникает вопрос: каким образом диспетчер узнает о том, что агенту адресовано какое-то сообщение?
Вопрос отнюдь не праздный, т.к. в «классической» Модели Акторов у каждого актора есть собственная очередь сообщений, адресованных актору. В первых версиях SObjectizer-5 мы пошли по этой же дорожке: у каждого агента была своя очередь сообщений. Когда агенту отсылали сообщение, то сообщение сохранялось в этой очереди, а следом диспетчеру, к которому привязан агент, ставилась заявка на обработку этого сообщения. Получалось, что отсылка сообщения агенту требовала пополнения двух очередей: очереди сообщений самого агента и очереди заявок диспетчера.
В этой схеме были свои положительные стороны, но все они нивелировались огромным недостатком — ее неэффективностью. Поэтому в скором времени в SObjectizer-5 мы отказались от собственных очередей сообщений у агентов. Теперь очередь, в которую помещаются адресованные агенту сообщения, принадлежит не агенту, а диспетчеру.
Логика простая, если диспетчер определяет где и когда агент будет обрабатывать свои сообщения, то пусть диспетчер и владеет очередью сообщений агента. Так что сейчас в SObjectizer имеет место следующая картинка:
Связующим элементом между агентом и диспетчером является event_queue — объект с определенным интерфейсом, который производит сохранение сообщения агента в соответствующую очередь заявок диспетчера.
Объектом event_queue владеет диспетчер. Именно диспетчер определяет, как именно event_queue реализуется, сколько event_queue-объектов у него будет, будет ли event_queue уникален для каждого агента или же несколько агентов будут работать с общим объектом event_queue и т.д.
Агент изначально не имеет связи с диспетчером, эта связь появляется в момент привязки агента к диспетчеру. После того, как агент привязан к диспетчеру, у агента появляется ссылка на event_queue и когда агенту передается адресованное агенту сообщение, то это сообщение отдается в event_queue и уже event_queue отвечает за то, чтобы заявка на обработку сообщения встала в нужную очередь диспетчера.
При этом в жизни агента есть несколько моментов, когда у агента нет связи с диспетчером, т.е. агент не имеет ссылки на свой event_queue. Первый момент — это промежуток между созданием агента и его привязкой к диспетчеру в момент регистрации. Второй момент — это промежуток времени при дерегистрации, когда агента уже отвязан от диспетчера, но еще не уничтожен. Если в эти моменты агенту адресуется сообщение, то в процессе его доставки обнаруживается, что у агента нет event_queue и сообщение в этом случае просто выбрасывается.
Агенты, кооперации и disp_binder-ы
Запуск агентов в SObjectizer-е происходит в четыре этапа.
На первом этапе программист создает пустую кооперацию (подробнее ниже).
На втором этапе программист создает экземпляр своего агента. Агент в SObjectizer-е реализуется обычным C++ классом и создание агента выполняется как обычное создание экземпляра этого класса.
На третьем этапе программист должен добавить своего агента в кооперацию. Кооперация — это еще одна уникальная штука, которая, насколько нам известно, есть только в SObjectizer-е. Кооперацией является группа агентов, которые должны появляться в SOEnv и исчезать из SOEnv единовременно и транзакционно. Т.е., если в кооперации три агента, то все три должны успешно начать свою работу в SOEnv, либо ни один из них не должен это сделать. Точно так же либо все три агента одновременно изымаются из SOEnv, либо же все три агента продолжают работать вместе.
Надобность в кооперациях возникла практически сразу же при начале работ над SObjectizer-ом, когда стало понятно, что в большинстве случаев агенты будут создаваться в приложении не поодиночке, а взаимосвязанными группами. И для того, чтобы разработчику не приходилось самому придумывать схемы контроля за стартом группы и реализацией отката, когда из трех нужных ему агентов два стартовали успешно, а третий — нет, и были придуманы кооперации.
Итак, на третьем шаге программист наполняет свою кооперацию агентами. После того, как кооперация наполнена, следуют четвертый шаг — регистрация кооперации. В коде это может выглядеть так:
so_5::environment_t & env = ...; // SOEnv внутри которого будет жить кооперация.
// Шаг №1: создаем кооперацию.
auto coop = env.create_coop("demo");
// Шаг №2: создаем агента, которого мы хотим поместить в кооперацию.
auto a = std::make_unique<my_agent>(... /*аргументы для конструктора my_agent*/);
// Шаг №3: отдаем агента в кооперацию.
coop->add_agent(std::move(a));
...
// Шаг №4: регистрируем кооперацию.
env.register_coop(std::move(coop));
so_5::environment_t & env = ...; // SOEnv внутри которого будет жить кооперация.
env.introduce_coop("demo", [](so_5::coop_t & coop) { // Шаг №1 уже сделан автоматически.
// Здесь сразу выполняются шаги №2 и №3.
coop.make_agent<my_agent>(... /*аргументы для конструктора my_agent*/);
...
}); // Шаг №4 выполняется автоматически.
При регистрации кооперации разработчик передает созданную и заполненную кооперацию SOEnv. SOEnv выполняет целый ряд действий: проверяет уникальность имени кооперации, запрашивает у диспетчеров ресурсы, необходимые для агентов кооперации, вызывает у агентов метод so_define_agent(), проводит привязку агентов к диспетчерам, отсылает каждому агенту специальное сообщение, чтобы у агента был вызван метод so_evt_start(). Естественно, с откатом ранее выполненных действий, если какая-то операция из этого перечня завершилась неудачно.
И вот когда кооперация зарегистрирована, вот тогда уже агенты находятся внутри SObjectizer-а (точнее, внутри конкретного SOEnv) и могут полноценно работать.
Одной из важнейших частей регистрации кооперации является привязка агентов к диспетчерам. Именно после привязки у агента появляется реальная ссылка на event_queue, что делает возможным доставку сообщений до агента.
После успешной регистрации кооперации мы будем иметь какую-то такую картинку:
disp_binder-ы
Выше мы уже несколько раз упоминали «привязку агентов к диспетчерам», но еще ни разу не пояснили, как же эта самая привязка выполняется. Как вообще SObjectizer понимает, к какому именно диспетчеру каждый из агентов должен быть привязан?
И вот тут в дело вступают специальные объекты под названием disp_binder-ы. Они служат как раз для того, чтобы привязать агента к диспетчеру при регистрации кооперации с агентом. А также для того, чтобы отвязать агента от диспетчера при дерегистрации кооперации.
В SObjectizer определен интерфейс, который должны поддерживать все disp_binder-ы. Конкретные же реализации disp_binder-ов зависят от конкретного типа диспетчера. И каждый диспетчер реализует свои собственные disp_binder-ы.
Чтобы привязать агента к диспетчеру разработчик должен создать disp_binder-а и указать этого disp_binder-а при добавлении агента к кооперации. По сути, код наполнения кооперации должен выглядеть как-то так:
auto & disp = ...; // Ссылка на диспетчер, к которому нужно привязать агента.
env.introduce_coop("demo", [&](so_5::coop_t & coop) {
// Создаем агента и указываем, какой disp_binder ему нужен.
coop.make_agent_with_binder<my_agent>(disp->binder(),
... /* аргументы для конструктора my_agent */);
...
});
Важный момент: именно кооперация владеет disp_binder-ами и только кооперация знает, какой агент какой disp_binder использует. Поэтому реальная картинка зарегистрированной кооперации будет выглядеть вот так:
Почтовые ящики
Еще один ключевой элемент SObjectizer-а, который имеет смысл рассмотреть хотя бы поверхностно — это почтовые ящики (или mbox-ы, в терминологии SObjectizer-а).
Наличие почтовых ящиков также отличает SObjectizer от других акторных фреймворков, реализующих «классическую» Модель Акторов. В «классической» Модели Акторов сообщения адресуются конкретному актору. Поэтому отправитель сообщения должен знать ссылку на актора-получателя.
У SObjectizer-а же ноги растут не только (и не столько) из Модели Акторов, но и из механизма Publish-Subscribe. Поэтому у нас операция отсылки сообщения в режиме 1:N изначально встроена в SObjectizer. И поэтому в SObjectizer сообщения отсылаются не напрямую агентам, а в mbox-ы. За mbox-ом может скрываться один агент-получатель. Или несколько (или несколько сотен тысяч получателей). Или вообще ни одного.
Поскольку сообщения отсылаются не напрямую агенту-получателю, а в почтовый ящик, то нам потребовалось ввести еще одно понятие, которого нет в «классической» Модели Акторов, но которое является краеугольным в Publish-Subscribe: подписку на сообщения из mbox-а. В SObjectizer если агент хочет получать сообщения из mbox-а, он должен сделать подписку на сообщение. Нет подписки — сообщения до агента не доходят. Есть подписка — доходят.
Штатные типы mbox-ов
В SObjectizer есть два типа mbox-ов. Первый тип — это multi-producer/multi-consumer (MPMC). Этот тип mbox-ов используется для реализации взаимодействия в режиме M:N. Второй тип — это multi-producer/single-consumer (MPSC). Этот тип mbox-ов появился позже и он предназначен для эффективного взаимодействия в режиме M:1.
Изначально в SObjectizer-5 были только MPMC-mbox-ы, поскольку механизма доставки M:N достаточно для решения любых задач. И тех, где требуется взаимодействие в режиме M:N, и тех, где требуется взаимодействие в режиме M:1 (в этом случае просто создается отдельный mbox, которым владеет один-единственный получатель). Но в режиме M:1 у MPMC-mbox-ов слишком высокие накладные расходы по сравнению с MPSC-mbox-ами, поэтому для снижения накладных расходов для случаев взаимодействия M:1 в SObjectizer и были добавлены MPSC-mbox-ы.
Любопытный момент. Наличие MPSC-mbox-ов впоследствии помогло добавить в SObjectizer такую фичу, как обмен мутабельными сообщениями. Эта функциональность изначально казалась невероятной, но раз она потребовалась пользователям, то мы придумали способ ее реализовать. И именно MPSC-mbox-ы стали одной из базовых вещей для мутабельных сообщений.
Multi-Producer/Multi-Consumer mbox-ы
MPMC-mbox отвечает за доставку сообщения всем агентам, которые на сообщение подписались. Будет таких агентов много, будет ли такой агент в единственном числе или таких агентов вообще не будет — это всего лишь детали работы. Поэтому MPMC-mbox хранит список подписчиков для каждого типа сообщения. И общую схему MPMC-mbox-а можно представить следующим образом:
Здесь Msg1, Msg2, ..., MsgN — это типы сообщений, на которые подписываются агенты.
Multi-Producer/Single-Consumer mbox-ы
MPSC-mbox гораздо проще, чем MPMC-mbox, поэтому он и работает эффективнее. В MPSC-mbox-е хранится только ссылка на агента, с которым связан этот MPSC-mbox:
Механизм доставки сообщения до агента «на пальцах»
Если совсем коротко рассказывать про то, как сообщения в SObjectizer-е доставляются до получателя, то вырисовывается следующая картинка:
Сообщение отсылается в mbox. Mbox выбирает получателя (в случае MPMC-mbox-а — это все подписчики на данный тип сообщения, в случае MPSC-mbox-а — это единственный владелец mbox-а) и отдает сообщение получателю.
Получатель смотрит, если ли у него актуальная ссылка на event_queue. Если есть, то сообщение передается в event_queue. Если ссылки на event_queue нет, то сообщение игнорируется.
Если сообщение передали в event_queue, то event_queue сохраняет сообщение в соответствующей очереди диспетчера. Что это будет за очередь зависит от типа диспетчера.
Когда диспетчер при разгребании своих очередей дойдет до этого сообщения, он вызовет агента на своем рабочем контексте (грубо говоря на контексте какой-то из своих рабочих нитей). Агент найдет у себя метод-обработчик для данного сообщения и вызовет его (еще раз подчеркнем, вызов произойдет на контексте, предоставленном диспетчером).
Вот, собственно, и все, что можно сказать и принципе работы механизма доставки сообщений в SObjectizer в общих чертах. Хотя в деталях там все несколько сложнее, но в совсем уж детали мы сегодня заглядывать не будем.
Заключение
В данной статье мы попытались сделать понятный, хотя и поверхностный, обзор основных механизмов и особенностей SObjectizer-а. Надеемся, что кому-то эта статья поможет лучше понять, как работает SObjectizer. И, может быть, лучше понять то, для чего ему может пригодиться SObjectizer.
Но если вы чего-то не поняли или хотите узнать еще о чем-то, то задавайте вопросы в комментариях. Нам нравится, когда нам задают вопросы и мы с удовольствием на них отвечаем. Заодно большое спасибо всем, кто вопросы задает — вы заставляете нас совершенствовать и развивать и сам SObjectizer, и документацию для него.
Так же, пользуясь случаем, хотим предложить всем, кто еще не знаком с SObjectizer-ом, познакомиться с нашим фреймворком. Он написан на C++11 (минимальные требования gcc-4.8 или VC++ 12.0), работает под Windows, Linux, FreeBSD, macOS и, с помощью CrystaxNDK, на Android-е. Распространяется под BSD-3-CLAUSE лицензией (т.е. даром). Взять можно с github-а или с SourceForge. Доступная на данный момент документация находится здесь. Плюс к тому, в состав SObjectizer-а входит большое количество примеров и да, все они в актуальном состоянии :)
Посмотрите, вдруг понравится. А если что-то не понравится, то сообщите нам, постараемся исправить. Нам сейчас очень важна обратная связь, так что если вы не нашли в SObjectizer-е чего-то нужного для себя, то расскажите нам об этом. Возможно, мы это сможем добавить в следующих версиях SO-5.
Автор: eao197