В этой статье будет рассказ об опыте использования акторного подхода в одном интересном проекте автоматизированной системы управления для театра. Это именно впечатление от использования, не более того.
Недавно мне удалось поучаствовать в одной очень интересной задаче — модернизация, а по сути — разработка новой автоматизированной системы управления штанкетными подъёмами для одного из театров.
Современный театр (если он большой) — это довольно сложная организация. В нём задействовано очень много людей, техники и различных систем. Одной из таких систем является система управления «подъёмом и опусканием» декораций на сцене. Современные спектакли, а больше оперы и балеты, с каждым годом становятся всё более насыщенны техническими средствами. Используется много сложных декораций и их движения во время действа. Декорации активно используются в режиссёрских замыслах, расширяя смысл происходящего и даже «играя свою собственную роль второго плана»). Вообще, было очень интересно познакомиться с закулисной жизнью театра и узнать, что же там происходит во время спектаклей. Ведь обычные зрители видят только то, что происходит на сцене.
Но данная статья всё-таки техническая и в ней хотелось поделиться опытом использования акторного подхода для реализации управления. А так же поделиться впечатлением от использования одного из немногих C++ акторных фреймворков — sobjectizer.
Почему именно он? Мы давно присматривались к нему. Есть статьи на хабре, у него прекрасная подробная документация с примерами. Проект довольно зрелый. Беглый взгляд на примеры показал, что разработчики оперируют «привычными» нам понятиями (состояния, таймеры, события), т.е. не ожидалось больших проблем с пониманием и освоением, для использования в нашем проекте. И да, что немаловажно, разработчики адекватны и дружелюбны, готовы помочь советом (на русском языке). В общем мы решили попробовать...
А что делаем?
Итак, что из себя представляет наш «объект управления». Система штанкетных подъёмов — это 62 штанкеты (металлические трубы) во всю ширину сцены висящие над этой самой сценой, примерно через каждые 30 — 40 см от края сцены в глубь. Сами штанкеты подвешены на тросах и могут подниматься вверх или опускаться вниз на сцену (вертикальное движение). В каждом спектакле (или опере или балете) часть штанкет используется для декораций. Декорации подвешиваются на них и перемещаются (если того требует сценарий) во время действия. Само перемещение производится по команде операторов (у них есть специальные пульты управления) при помощи системы «двигатель — трос — противовес» (примерно так же, как устроены лифты в домах). Двигатели расположены по краям сцены (на нескольких ярусах), так что зрителю они не видны. Все двигатели разбиты на 8 групп и у каждой группы имеется три преобразователя частоты (ПЧ). В каждой группе одновременно может быть задействовано три двигателя, каждый подключается к своему преобразователю. Итого имеем систему из 62 двигателей и 24 преобразователей, которыми мы должны управлять.
Нашей задачей было разработать интерфейс оператора для управления этим хозяйством, а так же реализовать алгоритмы управления. В систему входит три поста управления. Два поста управления находятся непосредственно над сценой и один пост находится в машинном зале (где расположены шкафы управления) и предназначен для наблюдения за работой для дежурного электрика. В шкафах управления расположены контроллеры, осуществляющие исполнение команд, управление ШИМ, подачу питания на двигатели, отслеживание позиции штанкет. На двух верхних пультах расположены мониторы, системный блок, где крутятся алгоритмы управления и трекбол в качестве «мышки». Между пультами управления используется Ethernet сеть. До каждого шкафа управления имеется канал RS485 (т.е. 8 каналов) от каждого из двух пультов управления. Управление может осуществляться одновременно с обоих пультов (которые над сценой), но при этом со шкафами обменивается только один из пультов (назначенный оператором главным), второй пульт в этот момент считается резервным и на нём обмен отключён.
Причём тут акторы
С точки зрения алгоритмов вся система построена на событиях. Либо это какие-то изменения датчиков, либо действия оператора, либо наступление какого-то времени (таймеры). И на такие алгоритмы очень хорошо ложится система акторов, которые обрабатывают приходящие события, формируют какие-то ответные воздействия и всё это в зависимости от их состояния. В sobjectizer все эти механизмы идут «из коробки». К основным принципам на которых строится такая система, можно отнести: взаимодействие между акторами происходит посредством сообщений, акторы могут иметь состояния и переходить между ними, в каждом состоянии актор обрабатывает только те сообщения, которые его интересуют в данный момент. Интересно, что концептуально в sobjectizer работа с акторами отделена от работы с рабочими потоками. Т.е. Вы можете описать нужных Вам акторов, реализовать их логику, реализовать их взаимодействие посредством сообщений. Но потом отдельно решить вопрос выделения потоков (ресурсов) для их работы. Это обеспечивается так называемыми «диспетчерами» которые и отвечают за ту или иную политику работы с потоками. Например есть диспетчер, который каждому актору выделяет отдельный поток для работы, есть диспетчер который обеспечивает пул потоков (т.е. акторов может быть больше чем потоков) с возможностью задать максимальное количество потоков, есть диспетчер который выделает один поток на всех. Наличие диспетчеров обеспечивает очень гибкий механизм настройки системы акторов под свои нужды. Можно объединять группы акторов для работы с одним из диспетчеров, при этом смена одного вида диспетчера на другой, это по сути смена одной строчки кода. По заявлениям авторов фреймворка, написать свой уникальный диспетчер тоже не сложно. В нашем проекте этого не понадобилось, потому что всё что нам было необходимо, уже есть в sobjectizer.
Ещё одной интересной особенностью является наличие понятия «кооперация» акторов. Кооперация это группа акторов, которая может либо вся существовать либо вся уничтожается (или не запускается), если хотя бы один актор, входящий в кооперацию, не смог начать работу или завершился. Не побоюсь даже привести такую аналогию (хоть это и из другой «оперы»), что концепция «кооперации» — это как концепция «подов» в модном нынче Kubernetes, только кажется в sobjectizer, она возникла раньше…
Во время создания каждый актор включается в кооперацию (кооперация может состоять из одного актора), привязывается к тому или иному диспетчеру и запускается в работу. При этом акторов (и кооперации) можно (легко) создавать динамически в большом количестве и как обещают разработчики — это не дорого. Все акторы обмениваются между собой через «почтовые ящики» (mbox). Это тоже достаточно интересная и сильная концепция в sobjectizer. Она обеспечивает очень гибкий механизм обработки приходящих сообщений. Во-первых, за ящиком может скрываться больше чем один получатель. Это действительно очень удобно. Например, создаётся ящик, в который поступают события от внешних датчиков и каждый актор подписывается в нём на интересующие его события. Это обеспечивает работу в стиле «publish/subscribe». Во-вторых, разработчики предусмотрели возможность относительно легко создавать свои реализации ящиков, которые могут проводить предварительную обработку приходящих сообщений (например как-то их фильтровать или распределять особым образом между потребителями). Помимо этого каждый актор имеет свой собственный ящик и даже может передавать «ссылку» на него в сообщениях другим акторам, например для того, чтобы они могли прислать какое-то уведомление в качестве обратного ответа.
В нашем проекте, для того, чтобы обеспечить независимость групп двигателей между собой, а так же чтобы обеспечить «асинхронность» работы двигателей внутри группы, все объекты управления были разбиты на 8 групп (по количеству шкафов управления), каждой из которых выделялось по три рабочих потока (т. к. в группе одновременно может работать не более трёх двигателей).
Следует так же сказать, что sobjectizer (в текущей версии 5.5) не содержит механизмов межпроцессового и сетевого взаимодействия и оставляет эту часть разработчикам. Авторы сделали это вполне сознательно, чтобы фреймворк был более «лёгким». Более того, механизмы сетевого взаимодействия «когда-то» существовали в предыдущих версиях, но были исключены. Однако это не доставляет никакого неудобства, потому что действительно сетевое взаимодействие очень сильно зависит от решаемых задач, используемых протоколов обмена и т.п. Здесь не может быть универсальной реализации оптимальной для всех случаев.
В нашем случае для сетевого и межпроцессового взаимодействия мы использовали одну из наших давних разработок — библиотеку libuniset2. В итоге архитектура нашей системы выглядит примерно так:
- libuniset обеспечивает сетевое и межпроцессовое взаимодействие (на основе датчиков)
- sobjectizer обеспечивает создание системы взаимодействующих между собой (в одном адресном пространстве) акторов реализующих алгоритмы управления.
Итак, напомню, у нас 62 двигателя. Каждый двигатель может быть подключён к ПЧ, соответствующему штанкету может быть задана координата, к которой необходимо приехать и скорость, с которой необходимо двигаться. Помимо этого двигатель обладает состояниями:
- готов к работе
- подключён
- работает (крутится)
- авария
- подключение (переходное состояние)
- отключение (переходное состояние)
В итоге каждый «двигатель» представлен в системе актором, который реализует логику переходов между состояниями, обработку событий от датчиков и выдачу управляющих команд. В sobjectizer акторы создаются легко, достаточно просто наследовать свой класс от базового класса so_5::agent_t. При этом конструктор обязан принимать первым аргументом «некий» контекст so_5::context_t, остальные аргументы определяются необходимостью разработчика.
class Drive_A:
public so_5::agent_t
{
public:
Drive_A( context_t ctx, ... );
...
}
Т.к. эта статья не является обучающей, поэтому я не буду здесь приводить подробные тексты описаний классов или методов. В статье просто хотелось показать, как легко (в несколько строк) при помощи sobjectizer всё это реализуется. Напомню, что у проекта прекрасная подробная документация, с кучей разных примеров.
А что за «состояния» у этих акторов? О чём речь?
Использование состояний и переходов между ними для АСУ вообще родная тема. Эта «концепция» очень хорошо ложится на обработку событий. В sobjectizer эта концепция поддерживается на уровне API. В классе актора достаточно легко объявляются состояния
class Drive_A final:
public so_5::agent_t
{
public:
Drive_A( context_t ctx, ... );
virtual ~Drive_A();
// состояния
state_t st_base {this};
state_t st_disabled{ initial_substate_of{st_base}, "disabled" };
state_t st_preinit{ substate_of{st_base}, "preinit" };
state_t st_off{ substate_of{st_base}, "off" };
state_t st_connecting{ substate_of{st_base}, "connecting" };
state_t st_disconnecting{ substate_of{st_base}, "disconnecting" };
state_t st_connected{ substate_of{st_base}, "connected" };
...
}
и дальше для каждого состояния разработчик определяет необходимые обработчики. Частенько требуется делать какие-то действия при входе в состояние и при выходе из него. В sobjectizer это тоже предусмотрено, вы так же просто определяете свои обработчики для этих событий («вход в состояние», «выход из состояния»). Чувствуется, что у разработчиков в прошлом, имеется большой АСУ-шный опыт...
Обработчики событий
Обработчики событий, это то место где реализуется логика Вашего приложения. Как было сказано выше, подписка производится на определённый почтовый ящик и для определённого состояния актора. Если актор не имеет состояний явно объявленных в коде, то он неявно находится в специальном состоянии «default_state». В различных состояниях Вы можете определять различные обработчики для одних и тех же событий. Если Вы не указали обработчик какого-либо события в данном почтовом ящике, оно просто будет игнорироваться (т.е. для актора его просто не будет существовать).
Синтаксис определения обработчиков очень прост. Достаточно только указать свою функцию. Не требуется указание каких-либо типов или шаблонных аргументов. Всё выводится автоматически из определения функции. Например:
so_subscribe(drv->so_mbox())
.in(st_base)
.event( &Drive_A::on_get_info )
.event( &Drive_A::on_control )
.event( &Drive_A::off_control );
Здесь показан пример подписки на события в определённом ящике для состояния st_base. Что интересно, в этом примере, st_base является базовым состоянием для других состояний и соответственно эта подписка будет действовать для всех состояний «наследующихся» от st_base. Такой подход позволяет избавиться от «копипасты» для определения одинаковых обработчиков для разных состояний. В тоже время в конкретном состоянии можно либо переопределить указанный обработчик, либо его «отключить» (suppress).
Есть ещё один способ определения обработчиков. Это непосредственное определение лямбда функций. Это очень удобный способ, потому что часто обработчики представляют из себя короткие функции в пару действий, что-то кому-то отослать или переключить состояние.
so_subscribe(drv->so_mbox())
.in(st_disconnecting)
.event([this](const msg_disconnected_t& m)
{
...
st_off.activate();
})
.event([this]( const msg_failure_t& m )
{
...
st_protection.activate();
});
Поначалу такой синтаксис кажется сложным. Но буквально за несколько дней активной разработки к нему привыкаешь и он даже начинает нравиться. Потому что вся логика работы актора в том или ином состоянии может уместиться в довольно короткий код и он весь будет перед глазами. Например в показанном примере, в состоянии «отключение»(st_disconnecting) происходит либо переход в состояние «отключён»(st_off.) либо в состояние «защита»(st_protection), если пришло сообщение о каком-то сбое. Такой код довольно легко читаем.
Кстати для простых случаев, когда по событию нужно просто перейти в какое-то состояние, есть ещё более короткий синтаксис:
auto mbox = drv->so_mbox();
st_off
.just_switch_to<msg_connected_t>(mbox, st_connected)
.just_switch_to<msg_failure_t>(mbox, st_protection)
.just_switch_to<msg_on_limit_t>(mbox, st_protection)
.just_switch_to<msg_on_t>(mbox, st_on);
Управление
Как же устроено управление всем этим хозяйством. Как было сказано выше, для непосредственного управления движением штанкет предусмотрено два пульта. На каждом пульте имеется монитор, манипулятор (трекбол) и задатчик скорости (помимо спрятанного в пульт «компьютера» на котором всё крутится и кучки всяких преобразователей). В системе заложено несколько режимов управления движением штанкет. Ручной и «режим сценария». О «режиме сценария» будет сказано дальше, а сейчас немного о «ручном режиме». В этом режиме оператор выбирает необходимый ему штанкет, подготавливает его к движению (подключает двигатель к ПЧ), задаёт для штанкета марку (целевую позицию) и как только он задаёт скорость больше нуля, штанкеты начинают движение. Для задания скорости используется специальный физический задатчик, в виде «потенциометра с ручкой», но так же есть и «экранный задатчик» скорости. Чем больше «повернули», тем громче быстрее едет. Максимальная скорость движения ограничена и равна 1.5 м/с. Ручка задатчика скорости — одна на всех. Т.е. в ручном режиме все подключённые оператором штанкеты двигаются с одной заданной скоростью. Хотя двигаться они могут в разные стороны (зависит от того, куда их направил оператор). Конечно, человеку сложно уследить за более чем двумя-тремя штанкетами одновременно, поэтому обычно в ручном режиме их двигается не много. С двух станций, операторы могут одновременно управлять каждый своими штанкетами. При этом у каждого пульта (оператора) свой задатчик скорости.
С точки зрения реализации, ручной режим не содержит какой-то особой логики. Команда подключить двигатель приходит от графического интерфейса, преобразуется в сообщение соответствующему актору, который отрабатывает её. Проходя через состояния «off» --> «connecting» --> «connected». Тоже самое с заданием позиции для движения штанкета и заданием скорости. Все эти события прилетают актору в виде сообщений, на которые он реагирует. Разве что можно отметить, что графический интерфейс и сам процесс управления, это разные процессы и между ними происходит «межпроцессовое» взаимодействие через «датчики» при помощи libuniset2.
Режим исполнения сценария (опять эти акторы?)
На самом деле ручной режим управления в основном используется только для проведения развесок во время репетиций или в простых случаях. Основной режим в котором идёт управление, это «режим выполнения сценария» или коротко «режим сценария». В этом режиме, каждый штанкет двигается к своей точке с заданными в сценарии параметрами (скорость и целевая марка). Для оператора управление в таком режиме состоит из двух простых команд:
- приготовиться (нужная группа двигателей подключаются)
- поехали (начинается движение группы к заданным для каждого целевым положениям).
Весь сценарий разбит на так называемые «повестки». Повестка это какое-то одно перемещение группы штанкет. Т.е. в каждую повестку входит группа штанкет, с заданными для каждой целевой скоростью и маркой куда необходимо приехать. На самом деле сценарий делится на акты, акты делятся на картины, картины делятся на повестки, а уже повестки состоят из «целей» для конкретных штанкет. Но с точки зрения управления, это деление не важно, т.к. именно в повестке в итоге указываются конкретные параметры движения.
Для реализации этого режима опять как нельзя лучше подошла система акторов. Был разработан «проигрыватель сценариев» который создаёт группу специальных акторов и запускает их в работу. Мы разработали два вида акторов: акторы-исполнители, предназначенные для выполнения задания для конкретного штанкета и актор-координатор, который распределяет задания между исполнителями. Причём акторы-исполнители создаются по мере необходимости, если в момент очередной команды не находится свободного. За создание и поддержание пула акторов-исполнителей отвечает актор-координатор. В итоге управление выглядит примерно следующим образом:
- оператор загружает сценарий
- «перелистывает» его до нужной повестки (обычно просто идёт подряд).
- в нужный момент нажимает кнопку «приготовиться» по которой актору-координатору присылаются команда (сообщение) по каждому штанкету входящему в текущую повестку с параметрами движения.
- актор-координатор смотрит свой пул свободных акторов-исполнителей, берёт свободного (если нет создаёт нового) и передаёт ему задание (номер штанкеты и параметры движения).
- каждый актор-исполнитель получив задание начинает отрабатывать команду «приготовиться». Т.е. подключает двигатель и переходит в режим ожидания команды «поехали».
- когда настаёт время, оператор подаёт команду «поехали»
- команда «поехали» приходит координатору. Он рассылает её всем своим задействованным в текущий момент исполнителями и они начинают «исполнение».
Тут стоит отметить, что в повестке встречаются дополнительные параметры. Например начать движение с задержкой N секунд или начинать движение только после отдельной специальной команды оператора. Поэтому список состояний у каждого актора-исполнителя достаточно большой: «готов к выполнению очередной команды», «готов к движению», «задержка движения», «ожидание команды оператора», «движение», «исполнение завершено», «сбой в работе».
После того как штанкет успешно (или нет) доехал до заданной марки, актор-исполнитель уведомляет координатора о выполненном задании. Координатор либо даёт команду отключить данный двигатель (если он больше не участвует в текущей повестке) либо выдаёт новые параметры движения. В свою очередь актор-исполнитель получив команду отключить двигатель, отключает его и переходит в состояние ожидания новых команд, либо начинает исполнять новую команду.
Благодаря тому, что в sobjectizer достаточно продуманный и удобный API для работы с состояниями, код реализации получился вполне лаконичный. Например задержка на движение описывается одной строкой:
st_delay.time_limit( std::chrono::milliseconds{target->delay()}, st_moving );
st_delay.activate();
...
Функция time_limit задаёт ограничение времени, сколько можно провести в данном состоянии и в какое состояние нужно перейти по истечении заданного времени (st_moving).
Акторы защиты
Безусловно во время работы могут возникать сбои. К системе предъявляется требования по обработке этих ситуаций. Тут тоже нашлось место применению акторов. Рассмотрим несколько подобных защит:
- защита от превышения тока
- защита от сбоя в датчике измерения
- защита от движения в обратную сторону (и такое может быть, если что-то не то с датчиком или измерителем)
- защита от движения без команды
- контроль исполнения команды (контроль того, что штанкет начал движение)
Можно видеть, что все эти защиты являются самостоятельными (самодостаточными) с точки зрения реализации, а работать должны «параллельно». Т.е. сработать может любое условие. При этом логика проверок условий срабатывания у каждой из защит своя, иногда требуется задержка (таймер) на срабатывание, иногда требуется предварительная обработка нескольких предыдущих измерений и т.д. Поэтому очень удобным оказалась реализация каждого вида защиты в виде отдельного небольшого актора. Все эти акторы запускаются в дополнение (в кооперации) к основному актору, реализующему логику управления. Такой подход позволяет легко добавлять дополнительные виды защит просто добавляя ещё одного актора в группу. При этом реализация такого актора остаётся достаточно лёгкой и понятной, т.к. он реализует только одну функцию.
Акторы защиты также имеют несколько состояний. В основном они включаются (переходят в состояние «включен») только когда двигатель подключён или штанкет двигается. При срабатывании условий для защиты, они публикуют уведомление о срабатывании защиты (с кодом защиты и некоторыми деталями для логирования), на это уведомление уже реагирует основной актор, который в случае необходимости отключает двигатель и переходит в режим защиты.
В качестве вывода..
… конечно эта статья не является каким-то «открытием». Акторный подход давно и успешно применяется во многих системах. Но для меня это был первый опыт сознательного использования акторного подхода к построению алгоритмов системы управления, в относительно небольшом проекте. И опыт оказался вполне успешным. Надеюсь удалось показать, что акторы очень хорошо накладываются на алгоритмы управления, им нашлось место буквально везде.
На опыте предыдущих проектов было видно, что так или иначе мы реализовывали «что-то подобное» (состояния, обмен сообщениями, управление потоками и т.п.), но это не было единым подходом. С использованием sobjectizer мы получили лаконичный, лёгкий инструмент для разработки, который берёт на себя массу проблем. Перестало быть нужным (явное) использования средств синхронизации (мьютексы и т.п.), нет никакой явной работы с потоками, никаких реализаций машины состояний. Всё это есть во фреймворке, логически взаимосвязано и представлено в виде удобного API, притом без потери контроля над деталями. Так что опыт получился интересный. Тем кто ещё сомневается, я рекомендую обратить внимание на акторный подход и на фреймворк sobjectizer в частности. Он оставляет положительные эмоции.
А акторный подход реально работает! Особенно в театре.