Всем нам подробно и с пристрастием объясняли недостатки монолитов и важность микросервисов. JSON считался вторым языком (хотя вообще можно вспомнить времена xml), а изоляция компонентов решения закрепила за собой статус индустриального стандарта. Но появились зануды, утверждающие что сериализация / десериализация — это ненужные издержки, а утонченные пользователи раздражались от лишних миллисекунд дополнительных задержек от раздутых пакетов. Тогда мы пришли к gRPC — сообщения между хостами стали минимальны по размеру, а процесс сериализации сократился в разы.
И наступил мир, и наступила гармония. Однако коллегам из Армении этого показалось мало. В очередной раз прописав уже занятый порт, забив жесткий диск 100 копиями одних и тех же образов Docker, они решили, что можно лучше.
Итак, Вашему вниманию представляю свежий С++ фреймворк Areg, который полностью отсутствует в обсуждениях (правда, искал не то, чтобы долго), однако хотя бы одного словца он явно заслуживает.
Скрытый текст
Часто, когда смотрю обзоры каких‑то малоизвестных продуктов, я обращаю внимание, что автор статьи является разработчиком или связанным с данным проектом лицом, что потом выливается в отличный от независимого стиль разбора продукта. В связи с этим хочу заявить, что я не являюсь связанной стороной. Не занимаюсь разработкой фреймворка, не знаком с разработчиками и даже не являюсь контрибьютором.
Постановки проблемы
Рассмотрим главную проблему к решению. Мы имеем несколько сервисов, которые расположены на ряде хостов и процессов. Сервисы должны регулярно отправлять друг другу сообщения. Например, 100 потоков должны провести сериализацию и вывести по своему выделенному соединению TCP сообщение на другой конец. Далее 100 потоков должны по своим TCP принять сообщение, десериализовать и принять в работу.
Система gPRC нацелена на решение проблемы сериализации. Однако нерешенной остается другая проблема — каждый микросервис должен иметь свое TCP подключение с выделенным потоком, памятью и, как следствие, более частой сменой контекста.
Помимо этого, не всегда мы хотим, чтобы наш сервис получал свой поток — особенно, когда пользуются им редко и вся задача стоит, например, в запросе к БД. Чтобы вывести два независимых сервиса в один поток обычно приходится что‑то придумывать.
Если на хосте стоит менее 30 сервисов — это не будет сильно заметно. Однако на горизонте сотен и более подключений, издержки начнут набирать обороты. Хотелось бы иметь некую волшебную палочку, позволяющую «припаять два сервиса на один поток». Неплохо было бы также упростить момент связки наших сервисов и не искать судоржно в докере или хосте тот самый порт от того самого сервиса, задаваясь вопросом, свободен ли этот порт для нового сервиса в принципе. Да и вообще, когда мы проектируем некое единое приложение, какую бизнес логику несет в себе коммуникация в стиле сервер‑клиент между компонентами решения?
Идея решения
Ключевой концепт Areg — это Object RPC. Фреймворк предоставляет инструменты для разработки распределенных систем на С++. В рамках философии фреймворка у нас есть некий компонент, который находится ГДЕ‑ТО. Под последним может пониматься (1) Соседний поток (2) Соседний процесс (3) Процесс на другом хосте. Имея точку отправки и точку назначения, мы можем теперь проложить кратчайший маршрут.
Скрытый текст
Фреймворк Areg назван в честь древнеармянского слова, означающего «Солнце». Хотя в академической науке богом солнца считается Аревахач/Михр, в народной традиции и позднейшей литературе имя Арег устойчиво ассоциируется с солнечным божеством армянского пантеона. Таким образом, название фреймворка — это прямая отсылка к армянскому слову «солнце», символизирующая, вероятно, идею просвещения, энергии или центрального источника света (сервиса) в распределенной системе.
Для соседнего потока всё решается просто — относительно тривиально достигается применением стандартного набора инструментов многопоточности.
Для соседнего процесса или хоста обычно прибегают к TCP соединениям. Но заранее зная список объектов и понимая список хостов, мы можем оптимизировать трафик сообщений, имея лишь одно соединение на хост. Тут в игру вступает mtrouter. Это сервис, который стоит на хосте и обрабатывает весь трафик для Areg компонентов. Вместо ряда TCP соединений мы имеем одно, и сообщения распределяются по микросервисам в более оптимизированной манере. Все микросервисы на хосте в рамках одной модели используют единое TCP соединение до mtrouter и от него.

Areg берет на себя синхронизацию обозначенных компонентов, их шов между хостами и, в целом, предоставляет свою библиотеку самых разных инструментов — от мьютексов до умных таймеров.
Несколько примеров
Разработчики предоставили ряд обширных примеров, которые надо пережевывать по одному и помедленнее. Здесь я обозначу ряд аспектов программирования на этом фреймворке, которые, на мой взгляд, являются отличительными чертами. Если вы хотите посмотреть на пошаговые инструкции, рекомендую самостоятельно пройтись по примерам от разработчиков.
Интерфейс
Начнем с разработки интерфейса. Для него нам нужно создать *.siml файл с декларацией модели. Сравнивая с gRPC, это что‑то вроде proto файлов. В отличие от последнего, такой файл имеет xml формат и в минималистичном виде выглядит как‑то так:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> <ServiceInterface FormatVersion="1.1.0"> <Overview ID="1" Name="HelloService" Version="1.0.0" Category="Public"/> <MethodList> <Method ID="2" Name="hello_service" MethodType="Request"/> </MethodList> </ServiceInterface>
Выглядит угрожающе, однако разработчики предоставляют нам инструмент для работы с интерфейсами Areg, который называется Lusan (дословно переводится как «Рысь»). В нем в очень доходчивой манере можно объяснить, что мы хотим от модели. Например, давайте рассмотрим декларацию типов данных в Lusan. Думаю, что все очевидно с примера ниже. В нем мы декларируем контейнер с типом данных double — DArray и две структуры с разным набором переменных.

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


Что нам дает это упражнение (файл *.siml)? Применив волшебный макрос cmake addServiceInterface, нам генерируют необходимые для работы файлы сервера и клиента на этапе конфигурации. Применяя OOP 101 навыки, мы можем получить наследуемые классы для работы с задекларированными методами. Для сервера и клиента нам нужны, соответственно,...ProviderBase.hpp и...ConsumerBase.hpp.
Декларация модели
Имея один или несколько интерфейсов, мы готовы к декларации модели. Так как мы говорим о ORPC, наша модель не ограничивается клиент‑серверными отношениями, а идет дальше, охватывая потоки. В коде ниже (взятый из примера 12) можем изучить общую структуру синтаксиса моделей в Areg. Мы декларируем два потока, в каждый из которых помещаем один и тот же сервис. В точке старта инициализируем модель и запускаем. Это дает старт перезаписанным нами методам, посвященным запуску приложения, и после этого сервисы могут начать работу.
<...> // Describe mode, set model name BEGIN_MODEL(_modelName) // define component thread BEGIN_REGISTER_THREAD( "TestServiceThread1") // define component, set role name. This will trigger default 'create' and 'delete' methods of component BEGIN_REGISTER_COMPONENT( "TestService1", ServicingComponent ) // register dummy 'empty service'. In this example we demonstrate simple initialization REGISTER_IMPLEMENT_SERVICE( areg::EmptyServiceName, areg::EmptyServiceVersion ) // end of component description END_REGISTER_COMPONENT( "TestService1" ) // end of thread description END_REGISTER_THREAD( "TestServiceThread1" ) ////////////////////////////////////////////////////////////////////////// // // Duplication of same service implementation in the registers. // // The duplication requires other component thread and different role // name of the component. The same implementation could do by using // 2 models, where each mode contains thread and component. // ////////////////////////////////////////////////////////////////////////// // define component thread BEGIN_REGISTER_THREAD( "TestServiceThread2") // define component, set role name. This will trigger default 'create' and 'delete' methods of component BEGIN_REGISTER_COMPONENT( "TestService2", ServicingComponent ) // register dummy 'empty service'. In this example we demonstrate simple initialization REGISTER_IMPLEMENT_SERVICE( areg::EmptyServiceName, areg::EmptyServiceVersion ) // end of component description END_REGISTER_COMPONENT( "TestService2" ) // end of thread description END_REGISTER_THREAD( "TestServiceThread2" ) // end of model description END_MODEL(_modelName) <...> int main() { areg::Application::setup(true, true, false, true, true, nullptr); areg::Application::load_model(_modelName); areg::Application::wait_quit( areg::WAIT_INFINITE ); // wait for quit signal to complete application. areg::Application::unload_model(_modelName); // stop and unload components areg::Application::release(); // release and cleanup resources of application. return 0; }
Таким образом все микросервисы нашего приложения могут быть помещены в такую модель и распределены по логическим потокам. При этом неважно, будут ли эти приложения на том же хосте или подключатся через роутер. Все обмены между сервисами через интерфейс безопасны и оптимизированы в зависимости от местоположения компонентов.
Скрытый текст
Тут можно отметить, что модель не то, чтобы заточена под масштабизацию каких‑то отдельных компонентов. Технически можно запустить несколько копий программ сервиса или клиента, но, как понимаю из устройства фреймворка, это может привести к некритическому, но все же UB (об этом еще чуть ниже).
Обозначив модель, мы можем скомпилировать отдельные компоненты и запустить их, по сути, откуда угодно. Главное, указать в конфигурации адрес к местоположению роутера и убедиться, что до него можно достучаться. Роутер же в свою очередь ведет список всех компонентов и обеспечивает доступ к ним.

Таймеры
Ранее описанная структура демонстрирует способность работы в режиме Event‑Based, то есть от одного вызова методов интерфейса к другому. Помимо этого, фреймворк предоставляет удобный инструментарий для работы Timer‑Based. Как показано в примере, класс может быть унаследован от TimerConsumer и тот сможет получать уведомления об истечении таймера:
class TimerDispatcher final : public areg::DispatcherThread , private areg::TimerConsumer { public: explicit TimerDispatcher(const areg::String & disp_name) : areg::DispatcherThread(disp_name, areg::DEFAULT_BLOCK_SIZE, areg::IGNORE_VALUE ) , areg::TimerConsumer() , mOneTime(*this, disp_name + "_one_time") {} void start_timers() { mOneTime.start_timer(areg::TIMEOUT_1_MS * 500u, static_cast<areg::DispatcherThread&>(*this), 1); } void stop_timers() { mOneTime.stop_timer(); } protected: void process_timer(areg::Timer & timer) final { printf("%s : Timer [ %s ] expired.\n", areg::DateTime::now().format_time().as_string(), timer.name().as_string()); } //! Override the default implementation to escape assertion bool post_event(areg::Event& eventElem) final { ASSERT(eventElem.is_event_id(areg::TimerEvent::CLASS_ID)); return areg::EventDispatcher::post_event(eventElem); } private: areg::Timer mOneTime; };
В примере выше мы устанавливаем таймер на 500 мс, который сработает ровно один раз. Нам также предоставляют post_event метод, который может реагировать на разные другие события. Метод не обязателен и служит инструментом низкоуровнегого контроля проходящих через сервис событий. В нашем случае у нас могут быть только события таймера.
Несколько бенчмарков
Мне хотелось ответить на вопрос — какова экономия на масштабе в условиях крайней загрузки сервера? Как плохо будет с задержкой и памятью с ростом количества клиентов? Поэтому мы устроим бенчмарк следующим образом.
Код инструментов для эксперимента доступен тут. Там же можно найти пример сборки cmake и докерфайл для настройки среды.
Возьмем модель, состоящую из сервера и N клиентов. Нетворк будем симулировать при помощи Docker. Сервер разместим в одном докере, всех клиентов — в другом. Сервер будет отправлять запросы на перемножение матриц псевдослучайного размера в адрес клиентов. Клиент же будет возвращать результат перемножения. Таким образом, значимые массивы чисел будут курсировать из сервера и обратно. Обозначим замеры по времени:
t1 — перед отправкой сервером из application слоя
t2 — получение клиентом в application слой
t3 — завершение расчетов
t4 — перед отправкой клиентом из application слоя
t5 — получение сервером в application слой
Проверять будем сервер и клиента на основе фреймворков Areg‑sdk и gRPC. Для Areg у нас будет минималистическая модель с одним логическим потоком, одним продъюсером и одним консъюмером. Для gRPC это самый простой сервер и клиент. Для тестов используем 2 набора данных — 3000 примеров разных границ размерностей. Каждый пример состоит из двух матриц размерами M x K, где M и K равномерно распределены между двумя числами.
Что же у нас по итогам? Давайте посмотрим результаты обработки 3000 записей матриц размером от 30 до 100.

По самой общей метрике Total round‑trip (t5 — t1), Areg модель заметно проседает. Немного посмотрев в детали, обнаруживаем, что задержка связана с Client compute time (t3 — t2). Пока не готов объяснить, как там могла появится задержка — в обоих реализациях там только само перемножение. Предполагаю, что areg все‑таки не рассчитан на запуск большого количества копий, и это проявляется в большом объеме процессов за кулисами. С другой стороны, Areg может где‑то делать таки копирование при доступе к сырым данным для дальнейшей манипуляции. Однако, если посмотреть на srv→cli метрику (t2 — t1), обнаруживается хорошая устойчивость areg при росте активных процессов. Но тут опять же с обратной связью — cli→srv (t5 — t4). Задержка areg больше и растет она схожими темпами как grpc.
Могу предположить следующее — из сервера к клиенту запрос идет через mtrouter. Сервер у нас один и идет к одному роутеру, и тот может спокойно оптимизировать трафик на хост клиента. Однако на обратном маршруте картина иная — каждый инстанс клиента независимо отправляет на хост сервера свой пакет (ведь в данном тесте у нас каждый areg клиент — отдельный процесс и не привязан физически к одной модели), снижая эффект от единой дистрибуции роутера. Возможно, что здесь наше UB от произвольного подключения клиентов без заявления модели проявляется более сильно. Однако проделав такой эксперимент с компиляцией единой модели с N клиентами не увидел сильно отличного результата.
Наконец, можно отметить более эффективное использование RAM системой areg. Если убрать память, которая не связана с экспериментом, получим примерно 10% преимущества перед grpc.
Скрытый текст
Скажу сразу, что опыт проводил на личной Ubuntu машине, не очищенной от сторонних процессов, включая GUI, поэтому значение RAM, например, надо занижать примерно на 3 GB. Для того, чтобы убедится в корректности данных, эксперимент повторял несколько раз в разной последовательности. Не всегда получал один и те же цифры, но всегда получал один и тот же тренд. Поэтому на все графики надо смотреть в сравнении и в динамике. Также я не стал приводить динамику потребления CPU, так как вариация там была очень низкой и представляет собой скорее шум, чем интересные данные.
Рассмотрим результаты более тяжелого эксперимента. На графике ниже отображены результаты перемножения 3000 матриц размерами от 30 до 400.

Во многом выводы похожие. Выделяется тут метрика Client serialization (t4 — t3), которая выверяет время подготовки данных в требуемый формат, а также время сбора статистики памяти и cpu утилизации. Areg тут показывает стабильные результаты с ростом количества клиентов по сравнению с gRPC. Разрыв RAM также был увеличен — теперь areg потребляет около 30% меньше этого ресурса. Отправка данных сервером у areg так же неизменна, в то время как gRPC деградирует заметно быстрее.
Заключение
Мы рассмотрели новый фреймворк для распределительных систем Areg‑sdk. На мой взгляд, с точки зрения процесса проектирования сервиса, Areg — это очень удобная вещь. Вместо конфигураций бесконечных портов и прочего нам достаточно знать координаты нашего mtrouter и вести развертывание без сторонних инструментов. Привлекает также удобный контроль на уровне потоков, куда мы можем расположить наши микросервисы.
С практической точки зрения, мы видим противоречивые результаты. Общая задержка увеличивается при масштабе, однако, односторонняя задержка и память ведут себя хорошо как при увеличении размера системы, так и при увеличении потока данных. Тем не менее, при начале изучения фреймворка я ожидал большего эффекта, учитывая концепцию такого Монолитного сада микросервисов — когда микросервисы объединены под одной четко обозначенной моделью. Возможно, в будущем разработчики добавят возможность ставить несколько роутеров, которые смогут общаться друг с другом и улучшить проходимость пакетов между хостами.
Что мне еще нравится, как концепция — это объединение группы сервисов (например, все сервисы, написанные на C++) в отдельные сущности, которые потом можно выводить на универсальные методы коммуникации (применяя тот же самый gRPC). Таким образом можно найти очень хороший баланс между модулярностью и производительностью итогового продукта.
Считаю, что Areg может найти свое место, особенно, когда хочется свои наносервисы поместить на один поток для экономии издержек. Разработчики выпускают версию 2.0 с поддержкой встроенной разработки — область, в которой, на мой взгляд, предлагаемые концепты зашли бы идеально. Лично сам занимаюсь встроенной разработкой, и, если появится возможность, обязательно попробую внедрить этот фреймворк на прод и обязательно сообщу о результатах, если таковые появятся.