Когда мы начали рассказывать про свой OpenSource акторный фреймворк для C++ на Хабре, мы пообещали описывать некоторые особенности деталей реализации SObjectizer-а. Одна из новых фич, которая была реализована в недавно вышедшей версии 5.5.19, отлично подходит для такого рассказа. Кроме того, она интересна еще и тем, что нам пришлось взглянуть на сценарии использования SObjectizer с совершенно другой стороны. Можно даже сказать, что один из наших шаблонов оказался разорванным.
Речь идет о возможности SObjectizer-а выполнять все свои действия на одной единственной рабочей нити. Начиная с версии 5.5.19 использовать Actor- и Publish/Subscribe модели можно даже в однопоточном приложении. Понятное дело, что акторы должны будут работать в режиме кооперативной многозадачности, но в каких-то случаях именно это и требуется.
А где может потребоваться использовать акторов в однопоточном приложении?
Как оказалось, есть целый класс задач, где нужны маленькие легковесные приложения. Внутри которых использование акторов вообще и SObjectizer-а в частности уместно, а вот создание нескольких рабочих нитей и связанные с этим накладные расходы — это уже как из пушки по воробьям.
Скажем, у нас может быть большое приложение, состоящее из основного master-процесса и дочерних процессов-worker-ов, коих может быть хоть сто, хоть тысяча. Master-процесс распределяет работу по worker-ам и забирает результаты их работы, а также контролирует жизнеспособность worker-ов, перезапуская их по мере надобности. Дочерние worker-ы, как правило, должны быть простыми и легковесными процессами. Очень хочется, чтобы каждый из них обходился всего одной рабочей нитью. Ведь одно дело иметь в системе тысячу процессов-worker-ов с одной нитью внутри, совсем другое — тысячу worker-ов с четырьмя рабочими потоками внутри.
Или другой пример: маленькая программка, которая должна время от времени опрашивать пару устройств и отсылать снятые данные MQTT-шному брокеру. Работа с каждым из устройств может быть оформлена в виде агентов. Но многопоточность здесь вряд ли потребуется. Тем более, что работать все это может на небольшом одноплатнике с ограниченными ресурсами и даже если сам одноплатник вполне тянет обычный Linux-овый дистрибутив, то все равно нет смысла расходовать ресурсы без должных на то оснований.
Где же здесь произошел разрыв нашего шаблона?
Изначально SObjectizer создавался как инструмент для упрощения разработки больших и сложных многопоточных приложений. SObjectizer-овские диспетчеры и взаимодействие агентов только посредством асинхронных сообщений позволяют писать приложения с десятками, а то и сотнями, рабочих потоков внутри, при этом программисту не приходится иметь дело ни с одним mutex-ом или condition_variable. Поэтому сегмент небольших однопоточных приложений мы даже не рассматривали в качестве ниши для применения SObjectizer-а. Как оказалось, зря. Модели Акторов и Publish/Subscribe вполне хорошо себя чувствуют и в однопоточных приложениях.
Как же нам удалось заставить SObjectizer работать в однопоточном режиме?
Сперва нужно рассказать, зачем SObjectizer-у вообще было нужно несколько рабочих потоков. Эти рабочие потоки нужны для:
- Обслуживания таймеров. SObjectizer запускает отдельную таймерную нить, которая определяет время для отсылки отложенных и периодических событий. Когда используется таймерная нить обработка сообщений агентами практически не оказывает влияния на точность таймера.
- Завершения процедуры дерегистрации коопераций. Когда кооперация с агентами изымается из SObjectizer Environment, все входившие в кооперацию агенты должны быть отвязаны от своих диспетчеров. А диспетчеры, соответственно, должны освободить выделенные агентам ресурсы. Так, если агент был привязан к active_obj-диспетчеру, то диспетчер должен завершить работу выделенной агенту нити и вызвать для нее join(). И тут очень важно, на каком именно контексте будет вызываться join(). Ибо если вызывать join() на контексте той самой нити, для которой join() и вызывается, то возникнет классический тупик. Посему SObjectizer использует отдельную нить, которой отсылаются нотификации о том, что все агенты кооперации полностью закончили свою работу и можно отвязывать их от диспетчеров. Поэтому все join-ы безопасно вызываются на контексте этой отдельной нити.
- Обслуживания агентов, привязанных к дефолтному диспетчеру. Если программист явным образом не привязывает агента к какому-то конкретному диспетчеру, то агент оказывается привязанным к дефолтному диспетчеру. Этому дефолтному диспетчеру нужна какая-то рабочая нить, на которой он будет вызывать события для привязанных к нему агентов.
Получается, что когда обычный SObjectizer запускается посредством вызова so_5::launch, то текущая нить (т.е. та на которой был вызван so_5::launch) используется для выполнения начальных действий, после чего блокируется до момента завершения работы SObjectizer Environment. Попутно SObjectizer создает три описанных выше нити для таймера, окончательной дерегистрации коопераций и дефолтного диспетчера. Плюс столько нитей, сколько потребуется дополнительным, созданным пользователем, диспетчерам.
Мы захотели чтобы SObjectizer мог делать все нужные ему операции на контексте всего одной нити — той, на которой и произошел вызов so_5::launch.
Для этого нам потребовалось ввести новое понятие — environment infrastructure, т.е. инфраструктура, которая будет обслуживать нужды самого SObjectizer-а. Был сделан соответствующий интерфейс, переделаны внутренности SObjectizer Environment, чтобы в нужных местах дергались методы этого интерфейса. Ну и затем было сделано несколько реализаций:
- default_mt — старая добрая реализация, использующая несколько дополнительных рабочих нитей. Создается и используется по умолчанию, если программист явно не задал другой тип environment infrastructure;
- simple_mtsafe — простая однопоточная реализация, в которой таймеры, дерегистрация коопераций и дефолтный диспетчер используют ту нить, на которой был вызван so_5::launch. Но при этом simple_mtsafe инфраструктура обеспечивает потокобезопасность SObjectizer-а. Об этом чуть подробнее ниже;
- simple_not_mtsafe — еще одна простая однопоточная реализация, в которой таймеры, дерегистрация коопераций и дефолтный диспетчер также используют ту самую нить, на которой был вызван so_5::launch. Однако, потокобезопасность SObjectizer-а при этом не обеспечивается.
Как работают однопоточные инфраструктуры?
В основе простых однопоточных инфраструктур лежит единственный цикл, внутри которого SObjectizer Environment последовательно выполняет следующие действия:
- проверяет наличие полностью готовых к дерегистрации коопераций. Если таковые есть, то выполняет окончательную дерегистрацию этих коопераций и уничтожает находящихся в них агентов;
- проверяет наличие сработавших таймеров и, если таковые есть, выполняет диспетчеризацию отложенных/периодических сообщений, время срабатывания которых наступило;
- проверяет наличие заявок в очереди дефолтного диспетчера. Если очередь не пуста, то берется первая и выполняется, после чего цикл повторяется снова.
При этом, очевидно, точность работы таймера начинает зависеть от того, какие агенты работают на дефолтном диспетчере: если эти агенты быстро обрабатывают свои события, то таймер работает более-менее точно. Если же обработка может затягиваться на секунды или на десятки секунд, то точность таймера оказывается никакой и после завершения длительного обработчика может быть сгенерирована сразу пачка таймерных событий. Но это вполне естественная плата за отсутствие отдельной таймерной нити.
Слово «simple» в названиях simple_mtsafe и simple_not_mtsafe используется не просто так, а потому, что дефолтный диспетчер применяет простую FIFO схему обработки событий без учета приоритетов агентов. Если кому-то нужна однопоточная инфраструктура с поддержкой приоритетов агентов, то дайте знать, включим такую доработку в наш план работ.
Чем отличаются simple_mtsafe и simple_not_mtsafe?
Нужно пояснить, почему у нас есть simple_mtsafe и simple_not_mtsafe, и что вообще означает защита SObjectizer-а от многопоточности.
В принципе, есть две ситуации, когда нам может потребоваться однопоточный SObjectizer:
- Однопоточный SObjectizer должен работать строго внутри однопоточного приложения. Т.е. запустили SObjectizer на главной нити и все, дальше вся работа производится только внутри SObjectizer. Нет никаких других рабочих нитей, нельзя обратиться к SObjectizer извне главной нити приложения. Для такой ситуации предназначена инфраструктура simple_not_mtsafe. Она в своей реализации использует тот факт, что с SObjectizer-ом работают только из одной нити, поэтому внутренности SObjectizer-а защищать от многопоточности не нужно.
- Однопоточный SObjectizer должен работать внутри многопоточного приложения. Например, на главной нити приложения должен работать GUI-интерфейс, а на соседней нити — SObjectizer. В этом случае возможно обращение к SObjectizer-у не только с той нити, на которой он запущен. Но и с любой другой нити приложения. Например, GUI-нить может создавать новые кооперации, уничтожать старые кооперации, отсылать сообщения агентам. Для такой ситуации предназначена инфраструктура simple_mtsafe. Она обеспечивает защиту внутренностей SObjectizer-а от многопоточного доступа, что и делает возможным работу SObjectizer-а на одной нити, а отсылку сообщений в SObjectizer из другой нити.
Мы видим задачу инфраструктуры simple_mtsafe в том, чтобы минимизировать накладные расходы SObjectizer-а, но при этом сохранить способность SObjectizer-а работать в многопоточном приложении. Так, в simple_mtsafe SObjectizer будет использовать всего одну рабочую нить вместо трех-четырех, как в случае с инфраструктурой default_mt. Но при этом пользователь может создать в своем приложении столько дополнительных рабочих потоков, сколько ему нужно, имя при этом возможность взаимодействовать с SObjectizer-ом из этих потоков.
Основное применение simple_mtsafe мы видим в разработке небольших GUI-приложений, в которых разработчик хочет вынести часть своей логики на дополнительный поток, в котором будет крутиться SObjectizer. При этом главный поток приложения останется доступным для обслуживания связанных с GUI операций.
А вот инфраструктура simple_not_mtsafe нужна только для случаев, когда пользователь хочет иметь именно что однопоточное приложение, в котором должен существовать один-единственный рабочий поток, на котором выполняются вообще все действия приложения.
Соответственно, основное применение simple_not_mtsafe мы видим в небольших утилитах, с более-менее сложной логикой внутри, но в которых важна экономия ресурсов. В легковесных процессах-worker-ах. А также в приложениях для совсем слабеньких платформ.
Как раз в том, что инфраструктура simple_not_mtsafe предназначена только и исключительно для однопоточных приложений, кроется принципиальное различие в реализациях simple_mtsafe и simple_not_mtsafe: инфраструктура simple_mtsafe вынуждена защищать свои «потроха» mutex-ом. Тогда как simple_not_mtsafe не нужно этого делать.
В итоге основные циклы работы инфраструктур simple_mtsafe и simple_not_mtsafe очень похожи, а отличаются они присутствием работы с std::mutex в случае simple_mtsafe. Код для simple_mtsafe:
template< typename ACTIVITY_TRACKER >
void
env_infrastructure_t< ACTIVITY_TRACKER >::run_main_loop()
{
m_activity_tracker.wait_started();
std::unique_lock< std::mutex > lock( m_sync_objects.m_lock );
for(;;)
{
process_final_deregs_if_any( lock );
perform_shutdown_related_actions_if_needed( lock );
if( shutdown_status_t::completed == m_shutdown_status )
break;
handle_expired_timers_if_any( lock );
try_handle_next_demand( lock );
}
}
И для simple_not_mtsafe:
template< typename ACTIVITY_TRACKER >
void
env_infrastructure_t< ACTIVITY_TRACKER >::run_main_loop()
{
m_activity_tracker.wait_started();
for(;;)
{
process_final_deregs_if_any();
perform_shutdown_related_actions_if_needed();
if( shutdown_status_t::completed == m_shutdown_status )
break;
handle_expired_timers_if_any();
try_handle_next_demand();
}
}
Примечание. В методы simple_mtsafe-инфраструктуры (вроде process_final_deregs_if_any() и try_handle_next_demand()) передается ссылка на std::unique_lock для того, чтобы можно было отпустить mutex на время выполнения соответствующих операций, после чего захватить его вновь.
Правда, работа с std::mutex в simple_mtsafe не обходится бесплатно. Эффективность simple_mtsafe-инфраструктуры на синтетических бенчмарках вроде ping-pong-а, оказывается на 25%-30% ниже, чем у инфраструктур default_mt и simple_not_mtsafe. Что вполне ожидаемо.
Текущий статус и дальнейшие направления работы
Версия 5.5.19, в которой реализованы инфраструктуры default_mt, simple_mtsafe, simple_not_mtsafe, доступна для загрузки на SourceForge. Там же есть соответствующая документация.
В настоящий момент инфраструктура simple_not_mfsafe не имеет собственного mutex-а только для собственного основного рабочего цикла и связанных с этим структур данных (например, не защищены mutex-ами списки готовых к окончательной дерегистрации коопераций и таймерные заявки). Однако, в других частях SObjectizer-а различные примитивы синхронизации (вроде mutex-ов и spinlock-ов) все-таки присутствуют. Например, внутри каждого агента есть spinlock, который для simple_not_mtsafe в принципе не нужен, но свое место внутри класса agent_t занимает.
Это произошло потому, что по предварительным оценкам попытка убрать из внутренностей SObjectizer-а все связанные с синхронизацией объекты для инфраструктуры simple_not_mtsafe, могла бы затянуть работу над версией 5.5.19 еще, как минимум, на несколько месяцев. Чего нам сильно не хотелось.
Не хотелось так же и ломать совместимость между версиями SObjectizer, что было бы неизбежно, если бы мы попробовали перейти на использование шаблонной магии для более эффективной реализации simple_not_mtsafe. Например, одна из идей была в том, чтобы тип инфраструктуры для агента задавался параметром шаблона. Тогда бы пришлось описывать свои классы агентов как-то так:
template<typename ENV_INF>
class my_agent : public so_5::agent_t<ENV_INF> {
...
};
А это обязательно сломало бы совместимость и существенно затруднило бы перевод старого кода на новые версии SObjectizer.
Поэтому мы решили в версии 5.5.19 оставить уже существующие объекты синхронизации как есть, а над способом их изъятия для simple_not_mtsafe подумать при разработке следующей версии. Вот, начинаем думать. Если кому-то кажется, что это очень важная штука, то дайте знать, начнем думать интенсивнее ;)
Дабы продемонстрировать, куда это все может завести в пределе, мы попробовали запилить пример примитивного однопоточного HTTP-сервера, в котором асинхронная обработка запросов делегируется SObjectizer-у. При этом и HTTP-сервер (на базе парсера от NodeJS и Asio), и SObjectizer сообща работают на единственной главной нити приложения. Вроде работает. Правда, сопутствующие технологии, вроде restinio (наш асинхронный HTTP-сервер) и so_5_extra (позволяет совместно жить на одной нити SO-5 и Asio) пока еще не достигли продакшен качества. Но мы над этим работаем.
Вместо послесловия
Работа над версией 5.5.19 заняла намного больше времени, чем мы сами ожидали, хотя виной тому вполне объективные причины. Надеемся, что следующую версию, 5.5.20, работа над которой, по сути, уже началась, мы сможем выкатить намного оперативнее. Сейчас формируется что-то вроде wish-list-а для новой версии. Ну и, соответственно, у читателей есть возможность повлиять на функционал SObjectizer-а. Напишите нам в комментариях, что бы вы хотели видеть в SObjectizer-е. Или, напротив, чего бы вы видеть не хотели. Или может вам что-то мешает использовать SObjectizer?
Мы очень внимательно прислушиваемся к тому, что нам говорят. Так, в свое время мы избавились от пространство имен so_5::rt и добавили такие фичи, как приоритеты агентов, иерархические конечные автоматы и мутабельные сообщения именно благодаря обсуждениям SObjectizer-а на различных профильных ресурсах и не только. Посему есть вполне реальный шанс сделать из SObjectizer-а нужный для вас инструмент, только чужими руками :)
Автор: eao197