Хабрахабр

Давайте заглянем SObjectizer-у под капот

Продолжаем знакомить читателей с открытым C++ным фреймворком под названием SObjectizer. Наш фреймворк упрощает разработку сложных многопоточных приложений за счет того, что C++программисту становятся доступны более высокоуровневые инструменты, позаимствованные из Модели Акторов, CSP и Publish-Subscribe. При этом, как бы высокопарно это не звучало, SObjectizer является одним из немногих открытых, живых и развивающихся акторных фреймворков для C++.

Но все равно читатели жалуются на наличие «белых пятен» в понимании того, как SObjectizer работает и как взаимосвязаны между собой различные типы сущностей, которыми оперирует SObjectizer. Мы уже посвятили SObjectizer-у более десятка статей на Хабре.

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

Начнем с такой штуки, как SObjectizer Environment (или SOEnv, если сокращенно). SOEnv — это контейнер, внутри которого создаются и работают все связанные с SObjectizer-ом сущности: агенты, кооперации, диспетчеры, почтовые ящики, таймеры и пр. Что можно проиллюстрировать следующей картинкой:

Например, вот в таком примере программист вручную создает экземпляр SOEnv в виде объекта типа so_5::wrapped_env_t: Фактически, чтобы начать работать с SObjectizer-ом, нужно создать и запустить экземпляр SOEnv.

int main() ; ... // Какая-то прикладная логика приложения. 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-е все же появилось понятие actor_system и возможность одновременно запустить в приложении несколько actor_system.
И мы даже натыкались на обсуждение, в котором разработчиков CAF-а спрашивали почему так.

За что отвечает SObjectizer Environment?

Кроме того, что SOEnv является контейнером, хранящим в себе кооперации, диспетчеры и пр., SOEnv еще и управляет этими сущностями.

Например, при старте SOEnv должны быть запущены:

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

Соответственно, при останове SOEnv, все эти сущности должны быть остановлены.

А когда пользователь хочет изъять своих агентов, SOEnv должен дерегистрировать кооперацию. Так же, когда пользователь хочет добавить в SOEnv своих агентов, SOEnv должен выполнить процедуру регистрации для кооперации с агентами пользователя.

Первый репозиторий, который, возможно, совсем исчезнет в следующей мажорной версии SObjectizer-а, — это репозиторий публичных диспетчеров. У SOEnv есть два основных репозитория, которыми он владеет и за содержимое которого отвечает. У каждого публичного диспетчера должно быть свое уникальное строковое имя, по которому диспетчер может быть найден и переиспользован.

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

В настоящее время имена коопераций рассматриваются как достаточно неоднозначная фича и, возможно, со временем кооперации в SObjectizer станут безымянными. Возможно, наличие имен у коопераций — это унаследованный от SObjectizer-4 рудимент. Но это не точно.

Итак, резюмируя:

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

Environment Infrastructure

Внутри SOEnv есть еще одна интересная штука, которая делает SOEnv более сложной сущностью, чем это могло показаться. Речь идет об SObjectizer Environment Infrastructure (или, сокращенно, env_infrastructure). Чтобы объяснить что это и зачем, нужно рассказать о том, с какими интересными условиями мы столкнулись по мере того, как SObjectizer стал использовался в задачах совершенно разного типа.

Так, таймер реализовывался отдельной таймерной нитью. Когда SObjectizer-5 появился, SOEnv использовал многопоточность для выполнения своей работы. И дефолтный диспетчер представлял из себя еще одну рабочую нить, на которой обслуживались заявки привязанных к дефолтному агенту диспетчеров. Была отдельная рабочая нить, на которой SOEnv завершал дерегистрацию коопераций и освобождал все связанные с кооперациями ресурсы.

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

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

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

Но при этом какую-то часть логики проще сделать не на Asio, а на SObjectizer-овских агентах. Другой пример: еще одно небольшое однопоточное приложение, которое активно работает с сетью посредством Asio. Более того, хотелось бы еще и избежать дублирования функциональности: раз уж используется Asio и в Asio есть свои таймеры, то нет смысла запускать такой же механизм еще и в SOEnv, пусть уж SObjectizer использует таймеры из Asio для обслуживания отложенных и периодических сообщений. При этом хотелось бы заставить и Asio, и SObjectizer работать на одном и том же рабочем контексте.

На уровне C++ env_infrastructure — это интерфейс с некоторым набором методов. Чтобы сделать возможным использование SObjectizer-а еще и в таких специфических условиях, в SOEnv появилось понятие env_infrastructure. При запуске SOEnv создается объект, реализующий этот интерфейс, после чего SOEnv использует данный объект для выполнения своей работы.

Плюс в so_5_extra есть еще пара однопоточных env_infrastructure на базе Asio — одна thread-safe, а вторая — нет. В состав SObjectizer-а входят несколько готовых реализаций env_infrastructure: обычная многопоточная; однопоточная, которая при этом не является thread-safe; однопоточная, которая при этом является thread-safe. мы, разработчики SObjectizer, не можем гарантировать, что интерфейс env_infrastructure будет оставаться неизменным. При большом желании пользователь может написать и свою собственную env_infrastructure, хотя дело это непростое, да и неблагодарное, т.к. Уж слишком глубоко эта штука интегрируется с SOEnv.

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

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

В данном разделе мы поговорим про агентов и диспетчеров, а в следующем пройдемся по почтовым ящикам.

Диспетчеры и event_queue

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

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

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

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

Разработчик может создать столько диспетчеров в своем приложении, сколько ему нужно. В состав SObjectizer-а входит восемь штатных диспетчеров — начиная от самых примитивных (например, one_thread или thread_pool) и заканчивая продвинутыми (вроде adv_thread_pool или prio_dedicated_threads::one_per_prio).

При этом взаимодействие с устройствами будет синхронным, а обработка данных может быть довольно сложной и многоуровневой. Например, представим себе, что нужно сделать приложение, которое будет опрашивать несколько подключенных к компьютеру устройств, как-то обрабатывать полученную информацию, складывать ее в БД и отдавать эту информацию во внешний мир через какой-то MQ-шный брокер.

Соответственно, все действия с устройством будут выполняться на отдельной нити и блокировка этой нити синхронной операцией не будет сказываться на остальном приложении. Можно создать по одному one_thread-диспетчеру на каждое устройство. Для остальных задач можно будет создать один единственный thread_pool-диспетчер. Так же отдельный one_thread-диспетчер можно выделить для работу с БД.

Таким образом, когда разработчик выбирает 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-ы. А также для того, чтобы отвязать агента от диспетчера при дерегистрации кооперации.

Конкретные же реализации disp_binder-ов зависят от конкретного типа диспетчера. В SObjectizer определен интерфейс, который должны поддерживать все 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 от других акторных фреймворков, реализующих «классическую» Модель Акторов. Поэтому отправитель сообщения должен знать ссылку на актора-получателя.

Поэтому у нас операция отсылки сообщения в режиме 1:N изначально встроена в SObjectizer. У SObjectizer-а же ноги растут не только (и не столько) из Модели Акторов, но и из механизма Publish-Subscribe. За mbox-ом может скрываться один агент-получатель. И поэтому в SObjectizer сообщения отсылаются не напрямую агентам, а в mbox-ы. Или вообще ни одного. Или несколько (или несколько сотен тысяч получателей).

В SObjectizer если агент хочет получать сообщения из mbox-а, он должен сделать подписку на сообщение. Поскольку сообщения отсылаются не напрямую агенту-получателю, а в почтовый ящик, то нам потребовалось ввести еще одно понятие, которого нет в «классической» Модели Акторов, но которое является краеугольным в Publish-Subscribe: подписку на сообщения из mbox-а. Есть подписка — доходят. Нет подписки — сообщения до агента не доходят.

Штатные типы mbox-ов

В SObjectizer есть два типа mbox-ов. Первый тип — это multi-producer/multi-consumer (MPMC). Этот тип mbox-ов используется для реализации взаимодействия в режиме M:N. Второй тип — это multi-producer/single-consumer (MPSC). Этот тип mbox-ов появился позже и он предназначен для эффективного взаимодействия в режиме M:1.

И тех, где требуется взаимодействие в режиме M:N, и тех, где требуется взаимодействие в режиме M:1 (в этом случае просто создается отдельный mbox, которым владеет один-единственный получатель). Изначально в SObjectizer-5 были только MPMC-mbox-ы, поскольку механизма доставки M:N достаточно для решения любых задач. Но в режиме 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 выбирает получателя (в случае MPMC-mbox-а — это все подписчики на данный тип сообщения, в случае MPSC-mbox-а — это единственный владелец mbox-а) и отдает сообщение получателю. Сообщение отсылается в mbox.

Если есть, то сообщение передается в event_queue. Получатель смотрит, если ли у него актуальная ссылка на event_queue. Если ссылки на event_queue нет, то сообщение игнорируется.

Что это будет за очередь зависит от типа диспетчера. Если сообщение передали в event_queue, то event_queue сохраняет сообщение в соответствующей очереди диспетчера.

Агент найдет у себя метод-обработчик для данного сообщения и вызовет его (еще раз подчеркнем, вызов произойдет на контексте, предоставленном диспетчером). Когда диспетчер при разгребании своих очередей дойдет до этого сообщения, он вызовет агента на своем рабочем контексте (грубо говоря на контексте какой-то из своих рабочих нитей).

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

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

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

Он написан на C++11 (минимальные требования gcc-4. Так же, пользуясь случаем, хотим предложить всем, кто еще не знаком с SObjectizer-ом, познакомиться с нашим фреймворком. 0), работает под Windows, Linux, FreeBSD, macOS и, с помощью CrystaxNDK, на Android-е. 8 или VC++ 12. даром). Распространяется под BSD-3-CLAUSE лицензией (т.е. Доступная на данный момент документация находится здесь. Взять можно с github-а или с SourceForge. Плюс к тому, в состав SObjectizer-а входит большое количество примеров и да, все они в актуальном состоянии 🙂

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть