尧图精选

C#状态机实战:从枚举switch到Stateless库的选型与避坑指南

🕒 发布时间:2026/10/1 5:10:44 📁 来源:尧图网络
C#状态机到底怎么用才不浪费这篇文章用手头接触过的一些真实项目场景来讲C#状态机工控上位机、视觉对位、串口/网口通信、批量任务调度这些场景里状态机不是花架子是真能救命的东西。如果你写过那种动辄十几个if-else嵌套的回调地狱或者被多线程同时改UI状态折磨过这篇文章值得看完。同时也会结合几个网络热词里的真实痛点比如C#调用C出现access violation c0000005、DirectShow UVC回调里区分多路摄像头、Modbus TCP客户端、SqlBulkCopy、Avalonia实战等一起聊聊状态机在这些问题中的位置。先说清楚适合谁看刚接触状态机概念但不知道怎么落地的C#开发、做了几年业务系统想往工控/上位机方向转的同学、还有已经在用状态机但总分不好状态和事件的边界、想优化结构的人。我会把原理、代码、踩坑一起放进文章里争取看完就能照着写。1. 从一段烂代码说起状态机到底解决了什么问题1.1 没有状态机的代码长什么样我见过最典型的场景是设备通信上位机发一条查询指令给PLC然后等待应答。刚入门的写法通常是这样private async Task SendCommandAndWait(byte[] cmd) { await tcpClient.SendAsync(cmd); await Task.Delay(100); var resp await tcpClient.ReceiveAsync(); if (resp null) { // 重试一次 } // 判断应答类型 if (resp[0] 0x01) { /* 状态A处理 */ } else if (resp[0] 0x02) { /* 状态B处理 */ } else { /* 异常处理 */ } }这段代码最要命的地方是把“流程控制”和“数据处理”搅在一起。只要业务稍微复杂一点——比如先握手、再校零、再启动测量、再等待数据稳定、再超时重连——你就会看到if-else一层套一层回调里套回调。到后期别说别人看不懂三天后自己都捋不清。1.2 用生活化类比理解状态机状态机可以理解成一台自动售货机机器永远处于某个“状态”待机、收款、出货、找零每个状态只响应固定几个“事件”投币、选货、取消、取货每个事件会让机器从当前状态跳到下一个状态同时执行对应的动作退币、掉货。它之所以好用是因为它把“在什么状态下遇到什么事应该做什么”整理成了一张清清楚楚的表。这个思路在C#里落地时状态通常用枚举表示事件也叫触发用另一个枚举表示状态转移表可以用switch、字典或者第三方库来做。三条基本规则是行为只跟当前状态有关事件触发一次只转移一次每个转移可以挂载进入动作和退出动作。1.3 为什么C#项目里特别值得用状态机结合我接触的领域我觉得C#状态机有四个场景收益特别明显串口/Modbus TCP上位机通信设备的每个响应都有特定时序状态机天然适合描述“等帧头-收长度-收数据-校验-处理”这种协议流程。视觉检测与运动控制联动取像、图像处理、结果判断、动作执行不是一个线程里跑完的状态机可以串起整个节拍。UI驱动的业务流程比如扫码枪录入、弹窗确认、保存任务、批量导入用户操作乱序发生时状态机能帮你兜住。多线程协作每个线程的状态独立管理互不干扰主线程只负责汇总状态减少锁的滥用。所以C#语言本身是支持这套思路的。委托、泛型、Task、async/await、事件这些语言特性都能让状态机写得更优雅。这也是为什么工控老手跟你说“上位机框架绕不开状态机”。2. C#里实现状态机的三种方式与选型2.1 最简单枚举驱动的大switch对初学者或者只有十几个状态的小项目直接用枚举加switch就够了enum ComState { Idle, WaitHead, WaitLen, WaitData, Complete } void OnByteReceived(byte b) { switch (currentState) { case ComState.Idle: if (b 0xAA) { currentState ComState.WaitHead; } break; case ComState.WaitHead: if (b 0x55) { currentState ComState.WaitLen; } else { currentState ComState.Idle; } break; // ... } }好处是零依赖、逻辑直接。坏处是状态一变多switch分支会很长而且转移条件的判断和动作执行混在同一个case里可读性会慢慢下降。适合那种“我只是想把这个步骤理顺”的场景比如一个小工具、一个临时脚本、一个内部测试程序。2.2 进阶状态模式State Pattern如果系统规模再大一点比如一个上位机软件里有登录、待机、运行、暂停、报警、关机这些业务状态每个状态下要做的事情又很多我会建议用状态模式。interface IState { void OnEnter(); void OnExit(); void Handle(Context ctx, Event evt); }每个状态一个类每个事件逻辑一个方法。好处是新增状态不用改动所有旧状态风险可控。缺点是类会比较多框架重一些。适合业务流程比较固定、后续扩展频繁的项目。2.3 偷懒直接使用成熟的状态机库C#生态里比较常用的有Stateless、StateMachine、AppStateManager等。Stateless是轻量级的库支持状态转移表配置、Guard条件、OnEntry/OnExit回调、异步事件处理我用它写过好几个项目。var machine new StateMachineState, Trigger(State.Idle); machine.Configure(State.Idle) .Permit(Trigger.Start, State.Running) .OnEntry(() Console.WriteLine(进入待机状态)); machine.Configure(State.Running) .Permit(Trigger.Stop, State.Idle) .Ignore(Trigger.Repeat);适合已经熟悉状态机概念、想快速交付、不想在基础设施上花时间的团队。我平时做快速原型时也会用它省掉自己写转移表的功夫。2.4 三种方式怎么选我给自己定了个选型原则也分享给你参考场景推荐方案理由小工具、单文件、状态少于15个枚举switch代码量最小调试直接业务流程复杂、需要多人维护状态模式每个状态独立改动隔离通信协议、UI流程、需要配置灵活Stateless等库支持Guard、异步、可视化转移表异步事件多、需要超时/重置自研轻量状态机可以做得足够精简并匹配业务这里不说哪个绝对最好关键是你得能解释清“为什么选它”。我见过把Stateless硬塞到简单串口工具里的反而因为库的抽象太多看代码的人一头雾水。状态机的复杂度一定要匹配项目复杂度。3. 手写一个可复用的轻量状态机核心3.1 为什么有时候我还是愿意自己写先声明Stateless这类库本身没问题但有些场景我确实会倾向于自研一个非常轻的版本一是设备通信项目里我经常需要记录每一次状态跳转的历史日志方便故障追溯二是某些工控场景要求极低延迟能省一层框架调用是一层三是我希望状态的判断逻辑能直接被测试代码调用而不是通过库的封装。自研不一定要多厉害够用就行。下面这个轻量版是我在实践中慢慢调整出来的核心只保留四个能力状态定义、事件定义、转移表、进入/退出动作。如果你有更复杂的需求再往上加。3.2 核心代码及逐段说明public class StateMachineTState, TTrigger where TState : struct, Enum where TTrigger : struct, Enum { // 转移表用“状态事件”作为键目标状态作为值 private readonly Dictionary(TState, TTrigger), TState _transitions new(); // 进入动作表 private readonly DictionaryTState, Action _entryActions new(); // 退出动作表 private readonly DictionaryTState, Action _exitActions new(); // 当前状态 private readonly object _lock new(); private TState _currentState; public TState CurrentState { get { lock (_lock) return _currentState; } } public StateMachine(TState initialState) { _currentState initialState; } public StateMachineTState, TTrigger Configure(TState state) { // 链式编程的入口其实可以提前预留配置项 return this; } public StateMachineTState, TTrigger Permit(TTrigger trigger, TState targetState) { _transitions[(_currentState, trigger)] targetState; return this; } }等等上面这个写法有个问题Permit的时候我还不知道当前是哪个状态。所以我换一种更标准的写法用临时变量记录正在配置的状态private TState? _configuringState; public StateMachineTState, TTrigger Configure(TState state) { _configuringState state; return this; } public StateMachineTState, TTrigger Permit(TTrigger trigger, TState targetState) { if (_configuringState null) throw new InvalidOperationException(请先调用Configure指定状态); _transitions[(_configuringState.Value, trigger)] targetState; return this; } public StateMachineTState, TTrigger OnEntry(Action action) { if (_configuringState null) throw new InvalidOperationException(请先调用Configure指定状态); _entryActions[_configuringState.Value] action; return this; } public bool CanFire(TTrigger trigger) { lock (_lock) { return _transitions.ContainsKey((_currentState, trigger)); } } public void Fire(TTrigger trigger) { lock (_lock) { if (!_transitions.TryGetValue((_currentState, trigger), out var target)) throw new InvalidOperationException($状态{_currentState}不接受事件{trigger}); _exitActions[_currentState]?.Invoke(); _currentState target; _entryActions[_currentState]?.Invoke(); } }这个版本已经能跑通基本转移。你可以这样使用var sm new StateMachineComState, ComEvent(ComState.Idle); sm.Configure(ComState.Idle) .Permit(ComEvent.Start, ComState.WaitReply) .OnEntry(() Console.WriteLine([状态机] 进入待机)); sm.Configure(ComState.WaitReply) .Permit(ComEvent.Timeout, ComState.Failure) .Permit(ComEvent.ReceiveOK, ComState.Success);有几个细节我特别提醒一下lock是必须的上位机场景里UI线程和通信线程同时调用Fire的场景太常见了不加锁就会出现状态错乱。加锁之后状态跳变就原子化了。异常处理不要只扔异常生产环境建议在Fire里包一层try-catch记录日志后再抛出否则状态机崩了整个上位机也崩了。事件不支持的场景要单独处理有些状态机语义里“Ignore”表示忽略某些事件如果直接抛异常会让UI弹窗一堆错误信息。建议在Fire之前先判断CanFire。3.3 线程安全与防重入我再多提一点工控恶意问题有些回调不是串行执行的。比如UVC摄像头回调里每一帧图像来一次回调回调里你再触发状态机Fire结果断线重连的同时又一帧图像来了两个线程同时跳状态。我这个锁版本能挡掉一部分但还有另一个问题动作回调里又触发了Fire就会发生“重入”。防重入的常见做法是给动作加一个“执行中”标记private int _isHandling; public void Fire(TTrigger trigger) { lock (_lock) { if (!_transitions.TryGetValue((_currentState, trigger), out var target)) return; // 防止重入如果已经在处理动作直接返回或推后 if (Interlocked.CompareExchange(ref _isHandling, 1, 0) ! 0) return; try { _exitActions[_currentState]?.Invoke(); _currentState target; _entryActions[_currentState]?.Invoke(); } finally { Interlocked.Exchange(ref _isHandling, 0); } } }当然这个方案会把重入事件丢掉有些场景里你可能希望丢到一个队列里延迟处理。简单的做法是把重入事件存到ConcurrentQueueFire处理完当前状态后从队列里取下一个事件继续跑。我在后面的实操案例里会用到这个思路。3.4 结合C#语言特性委托、泛型、Task的落地方式我上面的自研版本里已经用到泛型、委托Action、lambda也就是匿名委托。这些语言特性本来就是C#状态机得以简洁的根基。泛型状态和事件都枚举化写起来安全。委托进入/退出动作都是委托可以自由切换成实例方法、静态方法、lambda。Task状态动作里需要异步时可以改成Func 比如OnEntryAsync。但要注意锁和async/await不能混用await之后锁会丢失这是个常见坑。我的建议是把异步动作放在转移之外的流程里执行状态机本身保持同步。比如“进入运行状态”动作里需要延时时可以用一个独立的后台任务去跑状态机只负责标记“运行中”。事件当状态跳变需要通知UI/其他模块时可以给StateMachine加一个静态/实例事件比如StateChanged事件。4. 实操案例上位机设备通信流程状态机4.1 这个案例要解决什么有一次我做一个Modbus TCP客户端的上位机。流程大概是上位机发起一个测量任务设备收到指令后开始运动运动完成后回传结果数据。问题出在设备响应速度不固定有时候几十毫秒有时候两三秒还时不时给你一个异常码。用之前那种“一个方法等待应答”的写法遇到异常重试和超时的组合代码非常难看。于是我把整个通信流程拆成了状态机。4.2 状态划分与转移表我定义了这么几个状态状态含义可接受事件跳转目标Idle空闲待机StartSendingSending指令已发出等待设备响应AckOKWaitingResultSending指令已发出等待设备响应SendFailIdleWaitingResult已收到设备确认等待结果数据ResultArrivedCompletedWaitingResult已收到设备确认等待结果数据TimeoutFaultFault通信异常或超时RetrySendingFault通信异常或超时AbortIdleCompleted结果已取得ResetIdle核心思路是所有可能被UI打断的操作比如用户点取消都被定义为“事件”而不是在代码里直接改状态变量。这样无论是通信线程还是UI线程都只能通过Fire来改变状态不会有一方忽然把状态改掉导致另一方懵掉的情况。4.3 代码骨架public enum ComEvent { Start, AckOK, SendFail, ResultArrived, Timeout, Retry, Abort, Reset } public enum ComState { Idle, Sending, WaitingResult, Fault, Completed } public class DeviceWorkflow { private readonly StateMachineComState, ComEvent _sm; public DeviceWorkflow() { _sm new StateMachineComState, ComEvent(ComState.Idle); _sm.Configure(ComState.Idle) .Permit(ComEvent.Start, ComState.Sending) .OnEntry(() UiManager.SetStatus(待机)); _sm.Configure(ComState.Sending) .Permit(ComEvent.AckOK, ComState.WaitingResult) .Permit(ComEvent.SendFail, ComState.Fault) .OnEntry(SendCommand); _sm.Configure(ComState.WaitingResult) .Permit(ComEvent.ResultArrived, ComState.Completed) .Permit(ComEvent.Timeout, ComState.Fault) .OnEntry(() timeoutTask StartTimeout()); _sm.Configure(ComState.Fault) .Permit(ComEvent.Retry, ComState.Sending) .Permit(ComEvent.Abort, ComState.Idle) .OnEntry(() UiManager.ShowFault(通信失败)); _sm.Configure(ComState.Completed) .Permit(ComEvent.Reset, ComState.Idle) .OnEntry(HandleResult); } private void SendCommand() { // 异步发送使用async void要格外小心最好用async Task配合调度 _ TcpHelper.SendAsync(Encoding.ASCII.GetBytes(MEASURE\r\n)); } }这里注意一个细节状态进入Sending状态时会触发SendCommand这个动作跟状态机的Fire是同步执行的。如果你在动作里做阻塞等待会卡住状态机。所以SendCommand里只用_ TcpHelper.SendAsync(...)这种fire-and-forget的方式真正的回调在通信层里触发AckOK事件。4.4 超时与重试逻辑怎么挂进去超时的实现我建议不要直接塞进状态机Fire里面而是用一个CancellationTokenSource配合Task.Delayprivate CancellationTokenSource _cts; private Task StartTimeout() { _cts new CancellationTokenSource(); return Task.Delay(2000, _cts.Token).ContinueWith(t { if (t.IsCanceled) return; _sm.Fire(ComEvent.Timeout); }, TaskScheduler.Default); }为什么单独挂而不直接Delay到状态转移里因为一旦收到结果事件你要立刻把超时取消不然多了一个2秒后触发的Timeout就会让已经Completed的状态机再跳转到Fault这是个非常经典的竞态问题。我在早期版本里就踩过最后靠取消令牌和状态判断两个手段才压住。4.5 UI线程与后台线程的协作上位机项目里会有通信线程、UI线程、日志线程。状态机本身自带lock所以状态安全没问题。但动作里的UI更新要用UI线程调度。WinForms里可以这样private static void UiManagerUpdate(Action action) { if (mainForm.InvokeRequired) mainForm.BeginInvoke(action); else action(); }在Avalonia项目里也有类似机制核心是一样的状态机的动作不要跨线程直接碰控件。另外如果你用WPF/SQLite之类的组件我还见过一种简化方案状态机动作里只更新ViewModel属性通过数据绑定让UI自动刷新UI代码完全不进状态机。5. 状态机图、三段式状态机与调试辅助5.1 什么是状态机图怎么画才有用我平时画状态机图用的是简单的事件-状态转移表加箭头图。UML状态机图是标准画法一个圆角矩形代表状态箭头代表事件转移箭头上标注事件名/触发条件。这图有两个实际用途一是写代码之前用图理清逻辑二是做代码评审时把图贴在文档里大家看着图讨论比在代码里一行行抠快得多。如果不想用手画可以用PlantUML之类的文本工具写状态图再输出图片放到博客或需求文档里。5.2 三段式状态机是什么概念网络热词里出现了“三段式状态机”这个词更多来自FPGA和单片机领域但在C#的工控上位机语境里也被借用来描述一种分层思路把“事件接收”、“状态判定”、“动作执行”三个阶段拆开。在C#里的落地方式可以是第一段统一的事件入口所有外部消息串口数据、按钮点击、定时器、网络包先转换成枚举事件。第二段状态判定引擎根据当前状态和事件查转移表得出新状态。第三段执行动作转移表只负责“跳转”真正做事放在状态动作里。这个分层的好处是消息来源再多进了状态机之后都是标准事件不会有一堆if分支散落在各处。我在设备通信代码里就是这么做的串口数据、TCP数据、UI按钮、定时超时全部走同一个Fire入口。5.3 日志与历史追溯状态机的调试是我的重点经验。一个实用小技巧状态机每次跳转都记录一条日志格式固定为“时间 | 当前状态 | 事件 | 目标状态 | 动作结果”。遇到卡死、跳错看日志一眼定位。我习惯给自研状态机加一个事件历史缓冲private readonly Liststring _history new(); private readonly int _historyLimit 200; private void LogTransition(TState from, TTrigger trigger, TState to) { var line ${DateTime.Now:HH:mm:ss.fff} | {from} | {trigger} | {to}; _history.Add(line); if (_history.Count _historyLimit) _history.RemoveAt(0); }这个列表里有时间戳配合上位机的其他日志就能还原整个故障时间线。这在排查“设备某次动作没触发”之类的问题时节省的时间特别多。6. 常见问题与排查技巧实录6.1 状态跳错了是逻辑问题还是竞态问题症状界面显示已经“完成”但通信线程还在执行发送动作导致下一次操作从错误状态开始。排查思路先看日志里状态跳转序列确认有没有重复事件再看有没有两个线程同时Fire。绝大多数情况下是竞态解法是让所有事件都经过同一个方法入口由状态机统一加锁。不要图省事在多个地方直接改状态变量。6.2 C#调用C出现access violation c0000005热词里提到过C#调用C出现access violation c0000005这个问题在状态机项目里出现时通常不是状态机本身的问题而是非托管代码回调时机和状态转移不同步。比如C DLL在工作线程里回调C#委托此时回调里又去访问托管对象释放后就会crash。我的建议是非托管回调只做一件事——往队列里塞数据别在回调里直接触发状态机或更新UI。状态机消费队列数据这样就把C线程和托管逻辑彻底隔离。另外要保证委托实例被强引用保存否则GC回收后回调执行也会触发AccessViolation。6.3 UVC摄像头回调里区分多个摄像头DirectShow/UVC相机项目中多路摄像头回调纠缠在一起时状态机里的源标识就很重要。我的方案是摄像头回调进来时把设备标识比如设备路径、索引作为事件的一部分传入状态机每一个摄像头一套状态机实例互不共享状态。回调函数里不区分区分工作交给“事件”状态机实例完成。这样处理多路摄像头就变成管理一个状态机字典键是设备标识值是状态机实例。6.4 SqlBulkCopy等耗时长操作的“进行中”状态有一些场景用状态机记录耗时任务的阶段状态比如SqlBulkCopy批量导数据、CSV批量导入导出、Word书签替换之类的长时间操作。我建议这些操作的状态机放在后台任务队列里主线程只响应“开始”“完成”“失败”三个事件。虽然任务内部不需要复杂状态机但外层加状态机后UI按钮的Enabled状态就由状态机统一驱动不会出现“任务还在跑用户又点了一遍开始”这种问题。6.5 状态机卡死或丢失事件状态机自带的lock如果被某个耗时操作占用了比如在OnEntry里做了Thread.Sleep或阻塞I/O后续Fire都会排队看起来就是卡死。解法是动作里尽量不阻塞耗时操作放到后台线程如果确实需要同步等结果可以在独立任务里做再把完成事件通过Fire送回来。6.6 状态机常见问题速查表问题常见原因解决办法状态错乱多线程直接改状态变量统一走Fire入口内部加锁事件被忽略转移表漏配用CanFire断言或配置默认Ignore卡死OnEntry里阻塞等待动作异步化完成后再Fire重复触发回调重入或事件重复投递防重入标记或事件队列去重状态漂移用状态变量而不是状态机砍掉所有直接赋值状态的做法UI不刷新跨线程刷新控件用BeginInvoke调度或绑定ViewModel7. 最后分享几个小习惯我在实际项目里用状态机绕了很多弯想再给你几个具体建议。第一状态机和业务逻辑不要完全揉在一起状态机只负责转移业务动作尽量写在外部类或服务里这样测试状态机可以直接单测不需要接硬件。第二初期宁可多定义几个状态也别为了省事把两个语义不同的阶段合并成一个状态否则后面加分支时又要重构。第三不要把所有希望寄托在第三方库上库只是工具状态机的核心是思路思路理顺了哪怕你用最简单的枚举加switch也能写出很稳的代码。第四遇到难以复现的诡异问题先看状态历史日志再想是不是跨线程竞态这个顺序排查下来八成问题都能定位。状态机这东西一开始觉得多了一层抽象很麻烦真正用熟之后你会发现它其实是让复杂流程变简单的最好工具。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →