有一个故事讲述了在一个有趣的项目中使用Actor Model的经验,该项目开发了剧院的自动控制系统。 下面我将告诉我的印象,仅此而已。
不久前,我参加了一项激动人心的任务:板岩葫芦自动控制系统(ACS)的现代化,但实际上这是新ACS的发展。
现代化的剧院(尤其是大型剧院)是一个非常复杂的组织。 有很多人,各种各样的机制和系统。 这样的系统之一是用于处理风景的升降的ACS。 现代表演,如歌剧和芭蕾舞剧,每年使用越来越多的技术手段。 秀场导演积极利用风景,甚至发挥自己的重要作用。 发现幕后发生的一切真是令人着迷,因为普通观众只能观看现场的动作。
但这是一篇技术文章,我想分享一下我使用Actor模型编写控制系统的经验。 并分享我对使用C ++的actor框架之一的印象: SObjectizer 。
我们为什么选择这个框架? 我们已经研究了很长时间。 俄语中有很多文章,并且有许多出色的文档和许多示例。 该项目看起来很成熟。 简要查看示例可以发现,SObjectizer的开发人员使用相同的术语(状态,计时器,事件等),并且我们并不期望研究和使用它会遇到大问题。 还有另一个重要因素:SObjectizer的团队很有帮助,随时准备为我们提供帮助。 因此,我们决定尝试。
我们在做什么
让我们谈谈我们项目的目标。 板条葫芦系统有62个板条(金属管)。 每个板条长达整个阶段。 它们从舞台的前边缘开始以30-40cm的间隙平行悬挂在绳索上。 每个板条都可以升高或降低。 其中一些用于风景秀。 风景固定在板条上,并在演出中上下移动。 来自操作员的命令启动运动。 一种“引擎-绳索-平衡重”系统类似于在住宅建筑物的电梯中使用的系统。 引擎被放置在舞台之外,因此观众看不到它们。 所有引擎分为8组,每组具有3个变频器(FC)。 一组中最多可以同时使用三个引擎,每个引擎都连接到一个单独的FC。 因此,我们有一个包含62个引擎和24个FC的系统,我们必须控制该系统。
我们的任务是开发用于控制该系统的人机界面(HMI)并实现控制算法。 该系统包括三个控制站。 其中两个放置在舞台的正上方,一个放置在机舱内(值班电工使用此站)。 机舱中还有带有控制器的控制块。 这些控制器执行控制命令,执行脉宽调制(PWM),打开或关闭引擎,控制板条的位置。 舞台上方的两个控制站具有显示器,系统单元和轨迹球作为指示设备。 控制站通过以太网连接。 每个控制站都通过RS485通道与控制块相连。 舞台上方的两个站均可用于同时控制系统,但只能激活一个站。 活动电台由操作员选择; 第二站将是被动的; 无源站的RS485通道已禁用。
为什么是演员?
从算法的角度来看,该系统建立在事件之上。 来自传感器的数据,操作员的动作,计时器到期……这些都是事件的示例。 Actor模型适用于此类算法:Actor处理传入事件并根据其当前状态形成一些传出动作。 这些机制可以直接在SObjectizer中使用。
这种系统的基本原理是:参与者通过异步消息进行交互,参与者具有状态并从一种状态切换到另一种状态,仅处理对于当前状态有意义的消息。
有趣的是,参与者在SObjectizer中与工作线程脱钩了。 这意味着您可以先实现和调试actor,然后再决定将哪个工作线程用于每个actor。 有实现各种与线程相关的策略的“调度程序”。 例如,有一个调度程序为每个参与者提供一个单独的工作线程。 有一个线程池分派器,它提供固定大小的工作线程池; 有一个调度程序,它在同一线程上运行所有参与者。
调度程序的存在提供了一种非常灵活的方式来调整参与者系统以满足我们的需求。 我们可以将一些参与者分组以在相同的上下文中工作。 我们只需一行代码即可更改调度程序的类型。 SObjectizer的开发人员说,编写自定义调度程序并不复杂。 但是没有必要在这个项目中编写我们自己的调度程序。 我们需要的所有东西都在SObjectizer中找到。
另一个有趣的特征是演员的合作。 合作是一组参与者,只有在所有参与者都已成功启动后才能存在。 如果至少有一个参与者未能开始合作,则无法开始合作。 看起来SObjectizer的合作与Kubernetes的Pod之间有一个类比,但似乎SObjectizer的合作早已出现...
创建参与者后,它会添加到合作中(合作只能包含一个参与者)并绑定到某些调度程序。 动态创建合作关系和参与者很容易,SObjectizer的开发人员说这是一个相当便宜的操作。
所有演员都通过“消息框”(mbox)进行交互。 这是另一个有趣且功能强大的SObjectizer的概念。 它提供了一种灵活的消息处理方式。
首先,一个mbox后面可以有多个消息接收者。 这很有帮助。 例如,可能有一个mbox供传感器用来发布新数据。 Actor可以为该mbox创建订阅,并且订阅的actor将收到他们想要的数据。 这允许以“发布/订阅”方式工作。
第二,SObjectizer的开发人员已经设想了创建自定义mbox的可能性。 创建具有特殊处理传入消息的自定义mbox相对容易(例如,根据消息的内容在多个订户之间进行过滤或传播)。
每个参与者都有一个个人mbox,参与者可以在对其他参与者的消息中传递对该mbox的引用(允许直接回复特定参与者)。
在我们的项目中,我们将所有受控对象分为八组(每个控制盒一组)。 每个组都创建了三个工作线程(这是因为只有三个引擎可以同时工作)。 它使我们在引擎组之间具有独立性。 它还允许与每个组内的引擎异步工作。
必须提及的是,SObjectizer-5没有用于进程间或/和网络交互的机制。 这是SObjectizer开发人员的有意识决定。 他们希望使SObjectizer尽可能轻巧。 此外,SObjectizer的某些早期版本中已存在对网络的透明支持,但已删除。 它并没有打扰我们,因为网络的机制高度依赖于任务,使用的协议和其他条件。 没有针对所有情况的单一通用解决方案。
在本例中,我们使用旧的库libuniset2进行网络和进程间通信。 结果,libuniset2支持与传感器和控制块的通信,而SObjectizer支持参与者和单个流程中参与者之间的交互。
正如我之前说的,有62个引擎。 每个引擎都可以连接到FC(变频器)。 可以为相应的板条指定目标坐标; 也可以指定条板运动的速度。 除此之外,每个引擎都具有以下状态:
- 准备工作;
- 连接
- 工作
- 故障;
- 连接(过渡状态);
- 断开连接(过渡状态);
系统中的每个引擎都由一个参与者表示,该参与者执行状态之间的转换,处理来自传感器的数据并发出命令。 在SObjectizer中创建actor并不难:只需从so_5::agent_t
类型继承您的类。 actor的构造函数的第一个参数应为context_t
类型,所有其他参数都可以根据开发人员的需要进行定义。
class Drive_A: public so_5::agent_t { public: Drive_A( context_t ctx, ... ); ... }
我不会显示类和方法的详细说明,因为它不是教程。 我只想展示在SObjectizer中完成所有操作的难易程度(从字面上几行即可)。 让我提醒您,SObjectizer具有出色的文档和许多示例。
演员的“状态”是什么? 我们在说什么
状态的使用和状态之间的转换是控制系统的“本地主题”。 这个概念对于事件处理非常有用。 SObjectizer在API级别支持此概念。 在演员的类中声明状态:
class Drive_A final: public so_5::agent_t { public: Drive_A( context_t ctx, ... ); virtual ~Drive_A();
然后为每个状态定义事件处理程序。 有时在进入或退出状态时有必要做一些事情。 SObjectizer中的on_enter / on_exit处理程序也支持此功能。 似乎SObjectizer的开发人员具有控制系统开发的背景。
事件处理程序
事件处理程序是实现应用程序逻辑的地方。 如前所述,将为特定的mbox和特定的状态创建订阅。 如果演员没有明确指定的状态,则它处于特殊的“ 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状态下从特定mbox订阅事件的示例。 值得一提的是,st_base是其他某些状态的基本状态,并且订阅将由派生状态继承。 这种方法可以消除处于不同状态的类似事件处理程序的复制和粘贴。 但是可以为特定状态重新定义继承的事件处理程序,也可以完全禁用事件(“抑制”)。
定义事件处理程序的另一种方法是使用lambda函数。 这是一种非常方便的方法,因为事件处理程序通常只包含一行或两行代码:将某物发送到某个地方或状态改变:
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_disconnected到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);
控制
控件如何组织? 如上所述,有两个控制站用于控制板条的运动。 每个控制站都有一个显示器,一个指示设备(跟踪球)和速度设定器(而且我们不计算站内的计算机和一些其他附件)。
有两种控制模式:手动和“场景模式”。 稍后将讨论“场景模式”,现在让我们谈谈手动模式。 在这种模式下,操作员选择一个板条,准备移动(将引擎连接到FC),设置板条的目标标记,并且当速度设置为零以上时,板条开始移动。
速度设定器是“带手柄电位器”形式的物理附件,但在站的显示屏上也显示了一个虚拟的附件。 转得越多,移动速度就越高。 最大速度限制为每秒1.5米。 速度设定器是所有板条之一。 这意味着所有选定的板条都以相同的速度移动。 条板可以向相反的方向移动(取决于操作员的选择)。 显然,对于一个人来说,控制几个板条是很困难的。 因此,在手动模式下仅处理少量的板条。 操作员可以同时从两个控制站控制板条。 因此,每个站都有一个单独的速度设定器。
从实现的角度来看,手动模式下没有特定的逻辑。 “连接引擎”命令从图形界面发出,被转换为对应的消息给actor,然后由该actor处理。 actor从“关闭”状态变为“正在连接”,然后变为“已连接”状态。 用于定位板条和设置移动速度的命令也会发生类似的情况。 所有这些命令都以消息的形式传递给参与者。 但是值得一提的是,“图形界面”和“控制过程”是独立的过程,并且libuniset2用于IPC。
场景模式(又有演员吗?)
实际上,手动模式仅用于非常简单的情况或排练期间。 主控制模式为“场景模式”。 在这种模式下,根据场景设置,将每个板条以特定的速度移动到特定的位置。 在该模式下,操作员可以使用两个简单的命令:
- 准备(一组引擎正在连接到FC);
- 前进(开始移动组)。
整个场景分为“ agendas”。 “议程”描述一组板条的单个运动。 这意味着“议程”包括一些板条,并包含目标位置和速度。 实际上,场景由行为组成,行为由图片组成,图片由议程组成,议程由板条目标组成。 但是从控件的角度来看,这并不重要,因为仅议程包含板条运动的精确参数。
演员模型非常适合这种情况。 我们开发了一个“场景播放器”,可以生成一组特殊演员并将其启动。 我们开发了两种类型的角色:执行者角色(它们控制木条的移动)和协调者角色(它们在执行者之间分配任务)。 执行程序是按需创建的:没有免费的执行程序时,将创建一个新的执行程序。 协调器管理可用执行器池。 结果,该控件大致如下所示:
- 操作员加载场景;
- “滚动”它直到所需的议程;
- 在适当的时间按下“准备”按钮。 在那一刻,一条消息被发送给协调员。 此消息包含议程中每个板条的数据;
- 协调员审查其执行者库,并在免费执行者之间分配任务(如果需要,可以创建新的执行者);
- 每个执行者接收一个任务并执行准备动作(将引擎连接到FC,然后等待“执行”命令);
- 操作员在适当的时候按下“开始”按钮;
- “ go”命令转到协调器,并在当前使用的所有执行器之间分配该命令。
议程中还有一些其他参数。 类似于“仅在延迟N秒后才开始运动”或“仅在操作员发出附加命令后才开始运动”。 因此,执行者的状态列表很长:“准备下一个命令”,“准备运动”,“运动延迟”,“等待操作员命令”,“正在移动”,“已完成”, “失败”。
当板条成功达到目标标记(或有故障)时,执行者向协调员报告任务完成情况。 协调器回复命令以关闭引擎(如果木条不再参与议程)或将新任务发送给执行者。 执行程序要么关闭引擎,然后切换到“等待”状态,要么开始处理新命令。
由于SObjectizer具有用于处理状态的相当周到且方便的API,因此实现代码非常简洁。 例如,仅通过一行代码描述移动之前的延迟:
st_delay.time_limit( std::chrono::milliseconds{target->delay()}, st_moving ); st_delay.activate(); ...
time_limit
方法指定停留在状态中的时间,然后st_moving
状态(在该示例中为st_moving
)。
保护演员
当然,可能会发生故障。 要求正确处理这些故障。 演员也用于此类任务。 让我们看一些例子:
- 过电流保护;
- 防止传感器故障;
- 防止反方向运动(如果传感器或执行器出现故障,可能会发生这种情况);
- 防止自发移动(无命令);
- 命令执行控制(应检查板条的移动)。
我们可以看到所有这些情况都是自给自足的,但是应该同时对它们进行一起控制。 这意味着任何故障都可能发生。 但是,每次检查都有其逻辑:有时需要检查超时,有时需要分析传感器中的某些先前值。 因此,保护以小参与者的形式实施。 将这些参与者添加到实现控制逻辑的主要参与者的合作中。 这种方法可以轻松添加新的保护案例:只需在合作中添加另一个保护角色即可。 这种参与者的代码通常简洁明了,因为它仅实现一个功能。
保护者角色也有几种状态。 通常,它们在发动机打开时或板条开始运动时打开。 当保护器检测到故障/故障时,它将发布通知(其中带有保护代码和一些其他详细信息)。 主要角色对通知做出反应并执行必要的操作(例如关闭引擎并切换到保护状态)。
作为结论...
...当然不是突破。 Actor模型在多种不同的系统中使用了很长时间。 但这是我第一次使用Actor模型在一个相当小的项目中构建自动控制系统的经历。 事实证明,这种经验非常成功。 我希望我已经证明角色很适合控制算法:角色到处都有地方。
我们在先前的项目中实现了类似的功能(我的意思是状态,消息交换,工作线程管理等),但这不是统一的方法。 通过使用SObjectizer,我们得到了一个小型,轻便的工具,可以解决许多问题。 我们不再需要(明确地)使用低级同步机制(例如互斥体),不再需要手动进行线程管理,也不再需要手写状态图了。 所有这些都是由框架提供的,在逻辑上连接并以便捷的API形式表示,但是您不会失去对细节的控制。 因此,这是一次令人兴奋的经历。 如果您仍然有疑问,那么我建议您特别看一下Actor模型和SObjectizer 。 它留下了积极的情绪。
演员模型真的有效! 特别是在剧院。
俄语原文