☰
LabVIEW Actor Framework结合发布订阅模式的事件总线架构设计
2026/9/26 13:26:54 网站建设 项目流程

这篇演示项目其实是个比较特别的组合:歌里的“Actorfromwork”估计是手滑,正确拼写是LabVIEW的Actor Framework。我自己也是反复看了好几眼才确认,你问的其实是两个东西——Actor Framework这个官方异步架构,加上发布订阅(观察者)模式的事件总线。我专门把这个组合搭成了一个叫ESA的演示项目(Event Subscription Architecture,事件订阅架构),用来解决LabVIEW上位机里最让人头疼的多模块数据分发问题。如果你写过超过三个并行循环的LabVIEW程序,一定体验过那种滋味:采集循环要给界面、日志、报警各传一个队列引用,今天加个新窗口,明天加个新模块,采集那边就得跟着改。这篇文章就是这个ESA演示的手把手拆解,从消息类定义到总线Actor实现,再到我实际踩过的坑,适合对Actor Framework有一点点了解、想摆脱全局变量和队列满天飞的开发者。

1. 内容整体设计与思路拆解

1.1 Actor Framework究竟在解决什么问题

很多LabVIEW开发者从单线程转到多线程,第一反应就是“循环加队列”。主界面一个循环,采集一个循环,存储一个循环,三个循环靠队列互相传数据,程序跑起来确实没问题。但问题是,循环一多,队列引用就要手动到处传。我见过一个测试机程序,光是队列引用就建了十多个,每个VI的输入面板上全是队列端子,改一个地方得顺着数据流追半天。这个阶段还算好的,真正崩溃的是你新增一个功能模块的时候:采集数据原本只给界面显示,现在要加日志、加报警、加数据库,采集循环就得维护更多队列引用,逻辑越来越臃肿。

Actor Framework就是来治这个病的。它把每个并发单元封装成一个Actor,每个Actor内部有一个独立信箱(消息队列),外部向Actor发消息,Actor在自己的循环里处理,形成了“消息驱动”的状态机。这样每个Actor只关心自己收到什么消息、怎么处理,不用关心消息是谁发的。这就像公司里每个员工一个邮箱,别人发邮件给你,邮箱自动收,你按轻重缓急逐个处理。框架本身还帮你处理了Actor的启动、停止和错误传递,不用再自己手动管理循环的退出条件。

Actor Framework的精髓是消息类。每条消息都是一个继承自Message.lvclass的对象,里面打包了“你要让目标Actor做什么”的所有数据。当消息被投递到目标Actor的信箱,框架会自动调用消息的Do.vi,在目标Actor的上下文里执行对应逻辑。这种设计把“命令”变成了“数据”,代码的可测试性和可扩展性一下子高了很多。不过呢,Actor Framework默认只解决“点对点”通信:你想让A把数据同时通知B和C,还是得在A那里保存B和C的地址,B和C改了,A也得跟着改。这就引出了我加装发布订阅模式的核心动机。

1.2 为什么要用观察者模式/发布订阅模式

观察者模式在软件工程里已经存在几十年了,思路很简单:有一个被观察者(Subject),一群观察者(Observer),被观察者状态一变,自动通知所有观察者。打个最生活化的比方,就是微信公众号的关注关系:你关注了一个公众号,它一发文章,你的微信就会收到推送。公众号不需要知道一共有多少粉丝,也不需要挨个私聊拼命通知,微信平台替你完成了分发。

发布订阅模式是观察者模式的升级版。在观察者模式里,被观察者通常还是直接持有观察者的引用,只是抽象了通知接口;而在发布订阅模式里,发布者和订阅者中间隔着一个事件代理(Broker),发布者只负责把事件丢给代理,订阅者只负责告诉代理“我对哪类事件感兴趣”,双方完全不知道对方的存在。这个解耦非常重要:新增一个订阅者时,发布者代码一行都不用改,只要新订阅者向代理注册,它就能收到数据。

落到LabVIEW上位机场景,情况就更实际了。采集模块只负责产生温度、压力、振动等数据,界面模块要显示曲线,日志模块要写文件,报警模块要判断阈值。如果采集模块直接认识这三个下游,那每增加一个下游,采集模块都要加一个队列引用。引入事件总线后,采集模块只发送“DataUpdated”事件,总线负责查到谁订阅了DataUpdated,再逐个投递。我后面这套ESA演示就是围绕这个核心思路搭建的:EventBus就是那个微信平台,采集Actor是公众号,界面和日志Actor是粉丝。

1.3 ESA演示的整体架构解析

先解释一下,标题里的“ESA”不是什么官方名词,是我给自己这套演示起的代号,全称Event Subscription Architecture,核心就是“Actor Framework + 发布订阅”的组合。整个演示由下面几个部分组成:

角色Actor名称职责
事件代理EventBrokerActor维护订阅表,接收发布消息并广播给所有订阅者
发布者ProducerActor模拟数据采集,周期发布DataUpdated事件,不关心订阅者
订阅者SubscriberActor(实例1=UI)注册DataUpdated事件,收到广播后更新前面板波形图
订阅者SubscriberActor(实例2=Logger)注册DataUpdated事件,收到广播后写入CSV日志

我特意让UI和Logger用同一个SubscriberActor类,只是用不同的ComponentID来区分行为。这样做的原因后面会讲,主要是为了规避Actor Enqueuer类型不统一这个坑,对新手来说能少折腾很多。

数据流非常简单:Producer每200毫秒生成一个模拟温度值,构造一条PublishMessage发给EventBroker;EventBroker查到自己维护的“订阅表”里有UI和Logger两个订阅者,就把这条数据包装成EventNotificationMessage,分别发给两个订阅者Actor;UI收到后更新波形图,Logger收到后写日志。全部都是异步消息,没有一个人等待另一个人处理完。

2. 核心细节解析与实操要点

2.1 观察者模式/发布订阅模式原理再讲透

如果你以前只接触过LabVIEW,对“观察者模式”这个词可能有点陌生。它本质上就是“状态变化自动广播”的一种设计套路。温度传感器是一个数据源,界面上有温度计、记录仪、报警器三个显示模块,它们都对温度变化感兴趣。传感器一变,不用手动一个个调函数通知,状态一触发,所有观察者自动被调用。传统写法里,被观察者需要维护观察者列表,通知就是遍历这个列表,逐个调用回调函数。

发布订阅在抽象层级上比观察者模式又高了一层。观察者模式的Subject和Observer之间仍有直接耦合,而发布订阅模式引入了“事件总线”作为媒介。用之前的公众号比喻:你不是直接打电话让公众号把文章发给每个粉丝,而是粉丝在微信平台里关注公众号,微信平台完成匹配推送。公众号不知道粉丝是谁,粉丝也不知道公众号的推送逻辑。

这个区别在Actor Framework里还有一个现实意义:观察者模式的通知通常是同步回调,一个观察者处理慢了,被观察者就被卡住;但在我们的Actor实现里,通知是“把消息塞进对方的信箱”,这一操作几乎是线性的、微秒级完成,完全不是同步等待。订阅者们各跑各的循环,处理快慢互不影响,这非常适合LabVIEW那种天然并行、但对实时性要求不太苛刻的上位机场景。

2.2 Actor Framework默认通信方式和发布订阅的差异

我刚接触Actor Framework的时候,最不习惯的就是它没有现成的“广播”功能。你只能一对一发消息,所以一开始我以为要自己给每个订阅者都发一遍消息,后来才意识到这种作法违背了开闭原则。先看一张我看重对比表:

场景点对点消息(Actor Framework默认)发布订阅(加装EventBus)
发送方是否需要知道接收方是,必须持有目标Actor的Enqueuer否,只要知道总线的Enqueuer
新增一个接收模块发送方代码要改,增加一条发送路径接收模块自行注册,发送方不动
一对多广播不适合,代码会迅速膨胀天然适合
请求-应答非常合适,返回消息易控制不适合,总线转发做不到精准回复
实现复杂度框架内置,零额外工作需要自己写总线Actor和订阅表
典型用途控制指令、参数设置、查询状态数据分发、状态通知、事件上报

从这张表能看出来,发布订阅不是要替代Actor Framework默认的点对点通信,而是补充。控制逻辑、参数下发这类强关联通信,仍然应该走点对点消息;只有“一对多、业务方不知道彼此”的场景,才值得动用事件总线。

2.3 发布订阅架构的四个关键设计点

在动手写代码前,我建议先想清楚这四个点,否则后面改起来挺折腾的。

第一,事件类型要独立于消息类。我会单独建立一个EventType枚举:DataUpdated、StatusChanged、FaultOccurred,把它设置成严格类型定义。注册时用DataUpdated,发布时也用同一个DataUpdated。如果学习时省事直接放字符串,拼写错了很难查,枚举至少能在编译期兜底。

第二,订阅表的数据结构要明确。在EventBrokerActor的私有数据里,我放了一个字典(Map),键是EventType,值是一个队列引用数组,数组里保存的是每个订阅者的Actor Enqueuer。这样当收到一个PublishMessage时,总线只需要按事件类型查字典,就能拿到所有订阅者。

第三,消息类至少要分成注册类、注销类和发布类。注册消息里要提着“我订阅什么事件类型”和“我的回邮地址”;发布消息里要提着“发生了什么事件”和“事件载荷”。我后面把这几个类的细节都列出来了,照着建就行。

第四,注册动作要由订阅者自己发起。订阅者最清楚自己关心什么事件,所以不应该由Main.vi去逐个给总线发注册消息,而是每个订阅者在自己Actor Core最开始的地方发一条RegisterMessage。关闭时再发一条UnregisterMessage。这样你把一个订阅者模块丢到任何系统里,它能自己“找到组织”。

3. 实操过程与核心环节实现

3.1 环境准备与Actor Framework项目模板

开始之前先确认一下你的LabVIEW版本。Actor Framework在LabVIEW 2018及以后基本已经是内置模板,新版本更是直接集成在“创建项目”向导里。如果你用的是老版本,可能需要额外安装Actor Framework工具包,但思路是一样的。

打开LabVIEW后,选择“创建项目”,在模板列表里找到“Actor Framework Application”,新建一个模板项目。这个模板会生成一个最基本的Actor,包含Actor类、Message类、主VI和几个已经写好的Helper VI。我建议新手用这个模板起步,因为Actor的启动信息、Launch Actor.vi、Send Message.vi这些标准件都已经连好了,比自己从空白项目搭建省事很多。

项目结构上,我会在项目面板里建两个文件夹,一个叫Actors,一个叫Messages。所有自定义Actor类放在Actors里,所有继承Message.lvclass的消息类放在Messages里。看起来只是收拾整齐,但对后面增删模块很有帮助,至少不会让项目越写越乱。

3.2 定义事件类型与消息类

第一步,先在项目面板里新建一个严格类型定义的枚举,命名为EventType.ctl,里面加入三个条目:DataUpdated、StatusChanged、FaultOccurred。每次如果你新增一种事件,就到这里加一个条目,总线那边完全不用动。

第二步,定义载荷簇。我建了一个DataPayload.ctl,里面包含三个成员:Channel(U16)、Timestamp(Double)、Value(Double)。对于演示程序来说够用了,如果你是真实项目,可以扩充成采集通道号、设备ID、单位等等,原则就一个:载荷应当是不可拆分的整体,不要塞大量零散参数。

第三步,创建消息类。在Messages文件夹右键,新建LabVIEW类,父类选择Message.lvclass。对每个消息类,你都需要重写Do.vi,否则框架不知道这个消息到达目标Actor后该干什么。下面是这套演示里用到的几个消息类:

消息类成员类型消息去向说明
RegisterMessage.lvclassEventTypeEventType枚举EventBrokerActor订阅者注册自己
RegisterMessage.lvclassSubscriberEnqueuerActor EnqueuerEventBrokerActor订阅者的回邮地址
UnregisterMessage.lvclassEventTypeEventType枚举EventBrokerActor订阅者注销自己
UnregisterMessage.lvclassSubscriberEnqueuerActor EnqueuerEventBrokerActor同上
PublishMessage.lvclassEventTypeEventType枚举EventBrokerActor发布者产生事件
PublishMessage.lvclassPayloadDataPayload簇EventBrokerActor事件载荷
EventNotificationMessage.lvclassEventTypeEventType枚举各个订阅者Actor总线广播给订阅者
EventNotificationMessage.lvclassPayloadDataPayload簇各个订阅者Actor载荷

每个消息类的Do.vi怎么做?拿RegisterMessage举例,在Do.vi里你会得到一个输入,叫Target Actor,这个就是目标Actor的实例(这里是EventBrokerActor)。你在Do.vi里调用EventBrokerActor的RegisterSubscriber.vi,把消息自带的EventType和SubscriberEnqueuer传进去。看起来绕,但这正是Actor Framework的标准工作方式:消息本身只是“载具”,真正跑动作的是目标Actor上的方法。

3.3 手把手实现EventBrokerActor

EventBrokerActor是整个ESA演示的心脏。新建一个Actor类EventBrokerActor.lvclass,在私有数据里加一个“EventSubscriptionMap”,键是EventType,值是一个数组,数组元素类型是订阅者的Actor Enqueuer。

然后实现RegisterSubscriber.vi。函数逻辑是这样的:先判断Map里有没有传入的EventType键;如果没有,就新建一个数组,把SubscriberEnqueuer放进去;如果有,先检查数组里是不是已经有这个Enqueuer,避免重复注册,没有就追加。这个检查很重要,我见过程序在重复启动订阅者后,总线给同一个Actor发了两条一模一样的通知,排查很久才发现是重复注册导致的。

再实现UnregisterSubscriber.vi。这个函数里要用“删除数组元素”的方式,把订阅表中匹配的Enqueuer移除。如果移除后数组为空,最好把对应的键也一起删掉,保持Map干净。

最核心的是Publish逻辑。在EventBrokerActor的消息处理循环中,当收到PublishMessage时,代码大致是:

找到订阅表中对应EventType的订阅者数组 for each subscriberEnqueuer in array: EventNotificationMessage = CreateEventNotificationMessage(eventType, payload) SendMessage(subscriberEnqueuer, EventNotificationMessage)

这里有一个容易被忽视的点:SendMessage这个操作要尽量轻量,入队本身是微秒级的,所以总线的消息循环不应该做计算、文件写入、弹窗这类耗时操作。总线的职责只是投递,不是处理业务。如果你发现总线里开始做业务逻辑,那就是架构慢慢歪了。

3.4 手把手实现Producer和Subscriber

ProducerActor的代码非常简单,它的Actor Core里是一个while循环,每隔200ms调用一次Wait(ms),然后生成一个模拟温度值,组成DataPayload簇,构造一条PublishMessage,通过Send Message.vi发给EventBrokerActor。这里最关键的概念是:Producer完全不认识任何订阅者,它只知道总线的Enqueuer。所以你在ProducerActor里千万不要保存任何界面引用、日志路径、报警阈值,那都不该由发布者操心。

SubscriberActor稍微复杂一点,但也不难。我设计了一个通用订阅者类,里面有一个属性叫ComponentID,枚举为UI和Logger两个值。在Actor Core正式开始处理消息前,先构造一条RegisterMessage,把ComponentID要订阅的事件类型(这个演示里就是DataUpdated)和自己Actor的Enqueuer发给总线,然后进入消息处理循环。

在消息处理循环中,给EventNotificationMessage添加一个分支。当收到这条消息时,检查ComponentID:

  • 如果是UI,就把Payload里的Value值更新到前面板上的波形图控件,注意控制更新频率,别让UI线程被频繁重绘拖死。更新前面板控件在Actor的消息处理循环里是安全的,因为前面板属于UI线程,而消息是异步进来的。
  • 如果是Logger,就把时间戳和Value组合成一行,写入一个CSV文件。写文件要用带缓冲的方式,别每来一条都打开关闭一次文件,我通常会提前在Actor初始化时打开文件句柄,关闭时再关闭。

理论上这个SubscriberActor类可以无限扩展,加一个DB组件ID就能写数据库,加一个Alert组件ID就能做阈值报警,这就是发布订阅模式扩展性好的直观体现。

3.5 主程序启动与停止流程

这套验证程序里,最容易翻车的就是启动和停止顺序。我在第一次写的时候,主VI里面依次启动EventBroker、Producer、SubscriberUI、SubscriberLogger,结果日志订阅者才注册了一半,Producer那边已经丢了好几条数据。プロducer是急性子,不会等别人准备好。

标准的启动顺序应该是:

  1. 启动EventBrokerActor,确保事件代理先起来。
  2. 启动SubscriberUI和SubscriberLogger。
  3. 等待注册完成,最简单的方法是Main.vi里用一个小Detector或SendMessage做握手;如果你只是想演示,用Wait延时几百毫秒也能凑合,但工程里别这么随缘。
  4. 再启动ProducerActor。

停止顺序正好反过来:

  1. 先给Producer发送StopMessage,等它完全退出循环。
  2. 再给两个订阅者发送UnregisterMessage并停止它们。
  3. 最后才停止EventBrokerActor。

为什么必须反着来?因为如果你先把总线停了,Producer再发PublishMessage,就会往一个已经不存在的队列里投递,轻则产生错误,重则卡死。同样,如果先把订阅者停了,总线的广播消息就没人接,部分消息会积压甚至丢失。顺序是整个Actor系统稳定性的隐形契约。

3.6 模拟数据与运行效果

我在ProducerActor里故意把数据做得稍微“像样”一点:每200ms产生一个Value,公式是25 + 10 * sin(time * 0.5) + random(-1,1),模拟一个带噪声的温度信号。UI实例的波形图会实时滚动显示,Logger实例则在后台疯狂写CSV。

跑起来以后你会发现,不管事后你把Producer停掉、重启、再加一个新订阅者,Producer那端的代码几乎不用改。这就是这次演示最爽的地方:逻辑解耦之后,你面对的不是一团乱麻的队列引用,而是清晰的“事件”和“订阅”。

4. 常见问题与排查技巧实录

4.1 收不到消息?先查注册顺序

这是这套演示里被问得最多的问题。症状很统一:程序跑起来,UI和Logger界面毫无动静,但Producer明明在发消息。绝大多数情况是订阅者还没注册完,Producer就已经开播了。你可以这么排查:先在EventBrokerActor的RegisterSubscriber.vi里加一个探针,看两个订阅者是否真的完成了注册。如果发现最终订阅表数组是空的,先不要怀疑总线转发逻辑,八成是启动顺序问题。解决方法是加一个“注册完成确认”的握手,或者至少让Producer在启动前等500ms以上。我习惯用握手,因为延时的魔法数字在不同电脑上解读不同,换台慢电脑可能又踩坑。

4.2 界面卡顿和广播风暴

Actor消息处理和UI更新同属一个循环,但如果你让UI订阅者每收到一条消息就马上更新波形图,频率太快时LabVIEW里面的波形图控件重绘会成为瓶颈。尤其是发布者每10ms发一条,UI就要刷100帧/秒,这不是UI能扛住的频率。常见做法是节流:订阅者内部维护一个“上次更新时间”,当两次广播间隔小于某个阈值(比如100ms)时,直接丢掉这次更新。Logger倒是可以全量记录,因为文件写入有自己的缓冲机制。

还有一个更隐蔽的坑:如果广播消息的载荷过大(比如打包了整张图片),总线每次把大块数据入队,内存会涨得很快。这种情况下,建议广播一个“新数据就绪”事件,让订阅者按需向发布者拉取数据,而不是把数据塞进每条消息。这就是典型的“推模型”换“拉模型”取舍。

4.3 启动和关闭时卡死

如果你在停止时发现程序一直退不干净,大概率是消息循环里出现了循环等待。最常见的一种死锁是:Actor A的消息处理中,发了一条消息给Actor B,然后在同一个消息处理过程中等待Actor B的回复;而Actor B处理回复时又发消息给Actor A,两个Actor互相等,正好卡成一个环。Actor Framework虽然封装了并发,但没有真正解决你设计上的循环依赖。

另一种场景是在关闭时,你给所有Actor都发了StopMessage,然后Between各Actor的停止顺序不对。亮的经验是,停止消息也要按依赖关系从“数据源头”开始发,逐个停用,不要试图并行地关掉所有Actor。

4.4 Actor Enqueuer类型不统一怎么办

这个问题是我做这套演示时踩得最深的一个坑。如果你把SubscriberUI和SubscriberLogger设计成两个完全不同的Actor类,它们的Enqueuer类型也是不一样的,不能用同一个数组保存,总线那边根本没法统一转发。有一个取巧方案是把不同的Enqueuer存成Variant,广播时再从Variant转回原始类型;但每次转发都要转换,代码难看,还容易出类型错误。

我更推荐用“基类统一”的方案:让所有订阅者继承同一个抽象基类,至少保证Enqueuer的类型是一个基类类型,这样总线就可以用一个数组存储全部订阅者。另一个更省事的做法就像演示里这样,多个订阅者本来就是同一个Actor类的不同实例,差别只在ComponentID上。等到你对Actor Framework的机制更熟悉了,再去拆独立类也不迟。

4.5 和单例模式、全局变量比,值得吗

有很多同行写上位机,第一反应是把数据丢进一个全局变量或单例里,谁想看数据谁自己去读。这种写法在简单程序里很管用,但程序一旦进入多线程并发状态,问题马上来:两个线程同时写一个全局变量,到底以谁为准?你不得不再加一堆锁、互斥变量,高耦合状态的调试难度成倍增加。Actor框架加事件总线这种模式,前期会多写一些类和方法,但后期加模块、改逻辑,基本就是增删消息处理分支的事。开发周期的账算下来,这套架构在模块数量超过4个以后,收益远大于成本。

最后再分享一个我的个人习惯:这套ESA演示里的EventBus我已经在不同项目里用了好几轮,最大的感受就是“发布者永远是松快的”。你只要把总线和消息类定义清楚,后面的Actor基本是即插即用。建议你拿到这套思路后,先别急着扩功能,把EventBrokerActor的注册、注销和转发三个消息跑通,再往里加自己的业务Actor,这个过程会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询