设备软件架构与控制模型(四):状态机、命令队列与流程编排

设备接入增多以后,最危险的复杂度往往不是算法,而是“谁在什么时候可以做什么”。UI 可以点启动,远程接口也能启动;后台监控发现异常要停止;流程内部同时控制相机、光源和运动轴。若这些入口直接调用硬件,锁只能减少数据竞争,却不能保证业务顺序正确。

本篇把控制模型拆成三个互补部分:状态机决定命令是否合法,命令队列确定写操作的顺序,流程编排负责跨设备的长事务。三者职责不同,不能用一个不断增长的 switch 或一把全局锁替代。半导体设备状态机与任务调度的领域实践见《半导体设备软件(二):设备状态机与任务调度》。

1. 状态机保护业务不变量

状态机由四个基本元素组成:

  • 状态:系统对当前阶段的稳定判断;
  • 事件:已经发生的输入或事实;
  • 守卫条件:允许转换必须满足的条件;
  • 动作:转换过程中产生的副作用。

例如设备只有在 Ready 且门联锁、气压和回零状态均满足时才能进入 Running。把规则集中到状态转换中,比在每个按钮里各写一遍判断可靠得多。

纯转换函数便于穷举测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public enum MachineState
{
    Offline,
    Ready,
    Running,
    Paused,
    Faulted
}

public abstract record MachineEvent;
public sealed record StartRequested(bool InterlocksSatisfied) : MachineEvent;
public sealed record PauseRequested : MachineEvent;
public sealed record ResumeRequested : MachineEvent;
public sealed record FaultDetected(string Code) : MachineEvent;
public sealed record ResetCompleted : MachineEvent;

public static class MachineTransitions
{
    public static MachineState Apply(MachineState state, MachineEvent message) =>
        (state, message) switch
        {
            (MachineState.Ready, StartRequested { InterlocksSatisfied: true })
                => MachineState.Running,
            (MachineState.Running, PauseRequested)
                => MachineState.Paused,
            (MachineState.Paused, ResumeRequested)
                => MachineState.Running,
            (_, FaultDetected)
                => MachineState.Faulted,
            (MachineState.Faulted, ResetCompleted)
                => MachineState.Ready,
            _ => throw new InvalidOperationException(
                $"Event {message.GetType().Name} is invalid in {state}.")
        };
}

这个函数只决定目标状态,真正的硬件动作由命令处理器执行。若先把状态改成 Running,随后启动硬件失败,就会制造假状态。更稳健的顺序是:验证守卫条件,执行必要动作,确认结果,再提交状态转换;执行期间可以使用 Starting 之类的过渡状态防止重复命令。

2. 不要造“超级状态机”

整机、工位和单个设备的状态变化速度与责任不同。把所有组合塞进一个枚举,会迅速出现 RunningButCameraRecoveringAndDoorOpen 之类不可维护的状态。

可以采用分层模型:

1
2
3
4
整机状态:Offline / Ready / Running / Paused / Faulted
  ├─ 工位 A:Idle / Processing / Completed
  ├─ 工位 B:Idle / Processing / Completed
  └─ 设备健康:Camera Healthy,Axis Faulted,PLC Healthy

上层状态聚合下层事实,但不要简单复制全部状态。整机 Ready 的含义应由明确规则计算,例如所有必需设备健康、流程未占用、联锁满足且 Recipe 已验证。

3. 命令队列建立单一写入顺序

状态机回答“能不能做”,队列回答“先做哪个”。对拥有唯一写控制权的设备控制器,可以使用 System.Threading.Channels 建立有界异步队列:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
using System.Threading.Channels;

public interface IMachineCommand
{
    ValueTask ExecuteAsync(CancellationToken cancellationToken);
}

public interface ICommandFailureHandler
{
    ValueTask HandleAsync(
        IMachineCommand command,
        Exception exception,
        CancellationToken cancellationToken);
}

public sealed class CommandPump(ICommandFailureHandler failureHandler)
{
    private readonly Channel<IMachineCommand> _channel =
        Channel.CreateBounded<IMachineCommand>(new BoundedChannelOptions(100)
        {
            SingleReader = true,
            SingleWriter = false,
            FullMode = BoundedChannelFullMode.Wait
        });

    public ValueTask EnqueueAsync(
        IMachineCommand command,
        CancellationToken cancellationToken) =>
        _channel.Writer.WriteAsync(command, cancellationToken);

    public async Task RunAsync(CancellationToken cancellationToken)
    {
        await foreach (IMachineCommand command in
            _channel.Reader.ReadAllAsync(cancellationToken))
        {
            try
            {
                await command.ExecuteAsync(cancellationToken);
            }
            catch (OperationCanceledException)
                when (cancellationToken.IsCancellationRequested)
            {
                throw;
            }
            catch (Exception exception)
            {
                await failureHandler.HandleAsync(
                    command,
                    exception,
                    cancellationToken);
            }
        }
    }

    public void Complete() => _channel.Writer.TryComplete();
}

有界队列使过载可见。FullMode = Wait 会对生产者施加背压,但这只适合不能静默丢弃的控制命令;高频遥测可以采用另一条管线和不同丢弃策略。队列容量不是越大越好,过大的命令积压会让用户看到“停止已点击”却迟迟不执行。

每条命令还应携带命令 ID、提交者、截止时间和权限上下文,并通过独立的完成源返回结果。入队成功只表示已被接受排队,不表示设备动作完成。

示例把单条命令异常交给 ICommandFailureHandler,由它记录失败、完成调用方结果并决定队列是否还能继续;若故障已经使设备状态不可知,处理器应让状态机进入 Faulted,也可以再次抛出异常终止命令泵。不能未经分类就吞掉异常并继续执行下一条硬件命令。

4. 停止类动作不能都排在队尾

暂停、取消、正常停止和急停的语义不同:

动作目标常见处理
暂停在可恢复点暂时挂起完成当前原子步骤,保存上下文
取消放弃当前业务任务协作式取消,执行必要清理
正常停止让设备受控回到非运行态阻止新任务,减速或完成安全序列
急停尽快消除危险能量独立安全回路或安全控制器执行

急停不应作为普通软件命令排在 100 个动作后面。软件可以观察急停、停止继续发命令并引导复位,但人身安全相关的紧急停止必须按设备风险评估和适用安全标准实现,不能依赖通用操作系统调度或托管队列。

正常停止也通常需要高优先级控制通道,或者通过取消当前执行上下文后进入受控停止流程。不要给普通队列随意加入“插队”能力,否则命令顺序重新变得不可推理。

5. 流程编排管理长事务

一次设备任务可能包含:夹紧工件、移动、开光源、采集、处理、保存和释放。它不是数据库事务,已经发生的物理动作无法统一回滚。流程编排器需要显式描述:

  • 每个步骤的前置条件和完成条件;
  • 步骤的超时、取消点和可重试性;
  • 失败时进入安全状态的补偿动作;
  • 可持久化检查点,以及重启后允许恢复的位置;
  • 资源占用关系,避免两个流程争用同一设备。

补偿不等于撤销。例如“夹爪闭合”失败后的补偿可能是切断驱动并要求人工检查,而不是盲目发送“张开”。只有当前物理状态可确认、逆操作本身安全且幂等时,自动补偿才成立。

流程步骤最好是粗粒度业务动作,而不是把每次寄存器读写都做成节点。过细会淹没意图,过粗又无法设置检查点和诊断失败位置。

6. 状态发布要避免乱序

控制循环拥有可变状态,UI 和外部系统读取快照。可以为每次状态变更增加单调递增的 Revision,让订阅者丢弃迟到更新。时间戳有助于诊断,但不同线程、进程和机器上的墙上时钟不适合单独承担排序责任。

持久化事件日志可以帮助重建执行过程,但不要默认把所有设备状态都改造成事件溯源。高频遥测量大且带噪声,通常更适合时序存储;真正需要审计和恢复的命令、状态转换与 Recipe 快照才值得长期保留。

7. 验证控制模型

至少覆盖以下测试:

  • 每个状态允许和拒绝的命令;
  • 重复启动、重复停止和迟到事件;
  • 命令排队时发生取消或故障;
  • 队列满时生产者的行为;
  • 流程每一步失败后的最终状态;
  • 暂停、取消和恢复跨越检查点的行为;
  • 状态通知乱序、重复和订阅者变慢。

测试重点是最终状态、已发生的物理副作用和保存的证据,而不只是某个 Mock 方法被调用几次。

总结

状态机负责守住不变量,命令队列负责建立唯一写顺序,流程编排负责跨设备长事务。三者组合后,UI、远程接口和后台任务都必须通过同一入口提交意图,设备控制权才不会被多个线程和模块瓜分。

下一篇将讨论 Recipe 和配置版本:哪些参数属于产品工艺,哪些属于设备常数与校准,以及如何保证一次运行始终绑定到可追溯的参数快照。

参考资料