ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

状态机从入门到工程落地:状态表、三段式与业务实践指南

状态机从入门到工程落地:状态表、三段式与业务实践指南 1. 别把状态机想复杂它的本质就是一张规则表我第一次认真接触状态机不是在上学时的编译原理课而是在一次被产品经理逼疯的需求评审会上。对方提了一堆当用户在A页面点了B按钮如果处于C条件就跳到D状态否则停在A状态之类的逻辑听得我头皮发麻。后来我发现这种乱麻一样的业务逻辑恰好就是状态机最擅长收拾的场面。1.1 状态、事件、迁移用红绿灯把术语落地很多教程一上来就扔定义状态机由状态、事件、迁移、动作组成。术语没错但第一次看的人基本是懵的。我自己后来跟人讲状态机都爱用红绿灯打比方。想象一个只有红灯和绿灯的简化路口当前亮着红灯这就是一个状态行人按下过街按钮这叫一个事件事件发生后红灯熄灭、绿灯亮起这个过程叫迁移伴随着迁移控制器要做的动作是点亮绿灯、关闭红灯。再直白一点状态机就是一台不断回答两个问题的机器我现在在哪个状态来了一个事件我该去哪个状态顺便干什么这个模型小得可怜却能把一半以上业务逻辑描述清楚。红绿灯、电梯、洗衣机、登录流程、网络协议、游戏角色AI都是这么运转的。状态和事件要分清这是新手最容易混淆的地方。状态是稳定停留的东西比如空闲下载中已暂停事件是瞬间发生的东西比如开始按钮被点击数据包到达超时。有些人在代码里写出一堆bool变量今天加一个isDown、明天加一个isPaused后天又来个isRetrying最后这些变量组合出来的情况比状态机还复杂。其实这些bool变量的不同组合往往就是一个个独立的状态只是没人把它们抽出来而已。1.2 一个最小可运行的状态机模型理论聊完直接上最小模型。抛开语言不谈状态机的内核只有四样东西一个有限集合所有可能的状态一个有限集合所有可能的事件也叫输入一个迁移函数给定当前状态和事件返回下一个状态动作列表迁移时可以触发的事情用伪代码表达就是states {IDLE, RUNNING, PAUSED, DONE} events {START, PAUSE, RESUME, FINISH} function transition(current_state, event): if current_state IDLE and event START: next_state RUNNING action 开始计时 else if current_state RUNNING and event PAUSE: next_state PAUSED action 暂停计时 else if current_state PAUSED and event RESUME: next_state RUNNING action 继续计时 else if current_state RUNNING and event FINISH: next_state DONE action 输出结果 else: next_state current_state action 忽略非法事件 return next_state, action这段代码是不是简单得让人怀疑是的状态机底层就是这么朴素。真正的复杂度从来不在概念上而在你的状态表列得全不全和非法事件你接不接得住上。1.3 当前状态事件为什么够用确定性说明有人会问有些逻辑光靠当前状态事件真能表达吗比如我同时收到两个事件怎么办我做一个动作的同时能不能继续接受其他输入这里要先接受一个约束经典状态机是单线程、确定性的。同一时刻只处理一个事件同样一组输入序列无论跑多少次走的路径都一样。这个特性特别宝贵因为代码好调试、行为好预测。像网络协议栈、CPU指令流水线、PLC顺序控制靠的就是这种确定性。那同时来两个事件怎么办工程上的做法通常是加一个队列把事件排队或者规定优先级把同时变成有序。我自己实际写过不少并发系统最后发现把事件排队喂给状态机比在状态机里硬塞并发逻辑要省心得多。状态机的职责是保证给定输入序列输出确定结果至于输入序列怎么来是消息队列的事不该状态机管。2. 手写第一个状态机从需求到代码的完整推演讲完原理很多人还是不知道第一步该干什么。我见过不少同事代码写了一半突然说这里要上个状态机然后吭哧吭哧写了一堆switch写完发现比原来还乱。问题出在他们跳过了定义状态这个步骤直接写了迁移逻辑。2.1 先定需求场景一个带超时的文件下载器不用太复杂的例子就做一个带暂停、取消、超时重试的下载器。需求列出来是这样的用户点击下载图标任务进入等待中等待被调度到后进入下载中下载中如果用户点暂停进入已暂停已暂停状态点继续回到下载中下载中如果超过30秒没有数据进入等待中并重试任意状态点取消进入已取消下载完成进入已完成这种需求用if散写在回调函数里初看没什么一旦再加断点续传限速磁盘写满几个条件就会变成一团浆糊。用状态机来写第一步不是写代码而是列状态表。2.2 我推荐的实现顺序先画表再写码这一步真的很关键直接决定状态机质量。先画一张表行是状态列是事件表格里填下一个状态/动作。上面这个下载器状态表是这样的状态STARTPAUSERESUMETIMEOUTCANCELDONE等待中下载中---已取消-下载中-已暂停-等待中/重试计数已取消已完成已暂停--下载中-已取消-已取消------已完成------表格里的-表示非法事件。画完这张表你会立刻发现需求里的漏洞比如下载中超时重试重试次数用完了怎么办等待中要不要支持取消这些问题在写代码之前暴露出来改起来几乎零成本等代码写完了再发现就得动结构了。我个人的习惯是先画表再对着表写代码。表格是设计文档代码只是翻译。这个习惯帮我躲掉了大量加一个条件改三处的返工。2.3 四种常见实现方式对比状态机实现方式很多核心是那几类。我做了一个对比方便你按场景选实现方式思路优点缺点适合场景if/else嵌套每个状态里判断事件直观、零依赖状态一多就膨胀少于10个状态switch(state)外层按状态分支内层按事件分支比if清晰状态和事件多时仍是长函数中小型逻辑查表法用二维数组存迁移规则逻辑全集中、易改不灵活动作不好塞规则固定的协议解析状态模式每个状态一个类扩展性强、行为内聚类数量多、样板代码多状态行为复杂且相互独立这几种我都在项目里用过不能说哪种绝对好。查表法在C语言里配合函数指针特别好用状态多了也不怕状态模式在面向对象工程里更易维护但小项目用起来反而累赘。我的判断标准就一个状态数超过8个行为又各不相同直接上状态模式或状态机框架别用switch硬撑。用TypeScript写一个查表法的demo大概是这种感觉type State IDLE | DOWNLOADING | PAUSED | CANCELLED | COMPLETED; type Event START | PAUSE | RESUME | CANCEL | DONE | TIMEOUT; const table: RecordState, PartialRecordEvent, State { IDLE: { START: DOWNLOADING, CANCEL: CANCELLED }, DOWNLOADING: { PAUSE: PAUSED, CANCEL: CANCELLED, DONE: COMPLETED, TIMEOUT: IDLE }, PAUSED: { RESUME: DOWNLOADING, CANCEL: CANCELLED }, CANCELLED: {}, COMPLETED: {}, }; function transition(current: State, event: Event): State { const next table[current][event]; return next ?? current; // 非法事件保持原状态 }有人觉得这样写太数据驱动不好加动作。我的解法是表里只存状态迁移动作单独维护一个进入状态时执行的回调或者执行迁移时执行的回调。把去哪和干什么分开后面改起来非常舒服。2.4 跑起来之后怎么验证状态机没写错状态机的最大卖点是行为可预测所以验证方法也比普通逻辑简单。我自己常用的有三招第一招把迁移表打印出来逐个事件对着需求文档核对看有没有漏格、多格。有些非法事件你忘了填运行时它就会悄悄忽略这个行为可能不是你要的。第二招写穷举测试。状态和事件的数量都是有限的把所有状态事件组合都跑一遍断言最终状态是否符合预期。写起来就是两层循环成本很低收益极高。第三招记录状态轨迹。每次迁移都打一条日志格式类似[下载中] --PAUSE-- [已暂停]出问题时扫一眼轨迹问题点一目了然。这套习惯我保持了很多年调试异步代码的时候特别香。3. 状态转换图画法词法分析那种图为什么值得认真学热搜词里有人搜源程序的词法分析的状态转换图怎么画说明很多人在学编译原理时被这张图难住过。画状态转换图这件事说起来是画图实际上是在训练一种把流程拆成确定状态的思维。这种思维写业务代码、写协议解析、写PLC程序都通用。3.1 状态转换图的组成要素和画图顺序状态转换图就四种元素圆角框表示状态箭头表示迁移箭头上的文字写事件有时候还带动作双圈表示终止状态。初始状态通常用一个指向它的无来源箭头表示。画图的顺序我建议不要上来就画弧线而是按这个步骤来写下所有用户能感知的稳定状态名词化比如空闲解析中转义中结束写下所有输入事件一般是动词或输入字符比如收到数字收到左大括号收到反斜杠用表格列出状态 x 事件 - 下一个状态先列确定的再补边界把表格转成图每次有人拿着一张画得很漂亮但漏洞百出的状态转换图来找我问题几乎都出在第三步跳过了。他们直接从脑子里蹦出一堆箭头看起来很丰富实际漏了一堆非法输入。3.2 以JSON字符串解析为例画一张词法状态转换图拿一个经典的词法状态机来演示解析JSON字符串。平时我们写字符串解析常常用一堆if处理引号和反斜杠写过的人都知道转义处理最容易出bug。但用状态机的思路来想它其实只有几个状态ST_START字符串还没开始ST_NORMAL正在普通字符中ST_ESCAPE刚遇到反斜杠期待下一个转义字符ST_DONE遇到闭合引号字符串结束事件就是一个个字符普通字符记为char特殊的有双引号、\反斜杠、\n等转义目标。迁移逻辑是当前状态遇到遇到\遇到普通字符ST_STARTST_NORMALST_START不合法的转义开头可报错ST_START字符串必须以引号开头ST_NORMALST_DONEST_ESCAPEST_NORMALST_ESCAPEST_NORMALST_NORMALST_NORMALST_DONE忽略外部处理忽略忽略看到没转义的复杂逻辑被拆成了 当前状态是转义中下一个字符无论是什么都回到普通状态这一条规则。我当年第一次把字符串解析写成状态机时原来十几个flag变量全都消失了只剩一个state变量那种清爽感真的会上瘾。3.3 画图时最容易踩的坑无效状态和被吞掉的状态画状态转换图有两个高频坑一个是无效状态泛滥一个是合法状态被吞。无效状态泛滥典型表现是给每个动作都立一个状态。比如正在写日志正在更新界面也画成状态结果图上有几十个框但真正业务上的状态切换没几个。判断一个状态有没有资格成为状态就看一件事系统能否稳定地停留在这里等待事件。如果不能它只是迁移时顺带做的动作不该占用一个状态节点。合法状态被吞典型表现是初始状态没画。很多初学者画状态转换图直接从解析中开始画忘了标记等待数据输入也是一种状态。这就导致程序里最开始的输入永远进不了状态机或者每一轮都reset。在词法分析里这个初始状态尤为重要因为所有合法输入都是从它出发非法输入能否被拦截也全靠它。另外画图时要把所有事件都挂在箭头上包括非法事件。非法事件可以画成指向一个错误状态也可以直接在表格里标记忽略/报错。反正不能什么都不画因为什么都不画意味着程序对非法输入没反应这在协议解析、安全校验场景里往往就是漏洞。4. 工程里真正能打的三段式状态机Verilog、嵌入式、PLC的落地写法状态机在纯软件领域只是设计思路但在硬件描述、单片机、工业控制这些领域它几乎是必答题。热搜词里出现了三段式状态机stm32 按键状态机plc编程状态机写法说明大家真正关心的不是概念而是怎么落地。4.1 三段式状态机的核心思想把现在在哪和接下来去哪分开三段式状态机最初在FPGA/Verilog里被广泛使用但它的思想在通用编程里同样适用。三段分别指第一段时序逻辑负责状态寄存器的更新也就是当前状态变成次态第二段组合逻辑根据当前状态和输入计算次态第三段输出逻辑根据当前状态或者当前状态输入产生输出为什么要分三段核心原因是可维护性。一段式状态机把状态更新、状态判断、输出全写在一起初看代码短但加一个功能就要动一大片三段式把职责切干净改迁移规则只动第二段改输出只动第三段后人在老代码上干活时能少死很多脑细胞。这套逻辑放到普通软件里对应的就是状态变量更新用统一入口封装迁移规则用独立函数副作用动作按需挂在入口或出口。很多人说状态机难维护其实是没做这三层分离。4.2 Verilog三段式状态机为什么比一段式好改在Verilog里一段式状态机的典型写法是always块里既判断current_state又算next_state还顺便赋值output。状态一多always块里嵌套的case会让人崩溃综合工具也容易因为敏感列表写不全产生意想不到的锁存器。三段式的典型骨架大概是这样// 第一段状态寄存器更新 always (posedge clk or negedge rst_n) begin if (!rst_n) current_state IDLE; else current_state next_state; end // 第二段次态组合逻辑 always (*) begin next_state current_state; case (current_state) IDLE: if (start) next_state RUN; RUN: if (done) next_state FINISH; endcase end // 第三段输出逻辑 always (*) begin out 1b0; case (current_state) RUN: out 1b1; endcase end第二段里我给next_state默认赋值current_state这是个小技巧能省去一堆else避免忘记兜底导致锁存。输出逻辑建议用组合逻辑或寄存器输出按需选择组合逻辑简单直接但有毛刺风险寄存器输出更稳定代价是多一拍延迟。硬件上追求稳妥的话我倾向于输出也打一拍寄存。4.3 STM32按键状态机去抖、短按、长按的处理套路嵌入式里状态机的经典案例是按键扫描。很多新手写按键直接判断IO电平然后延时去抖结果一有干扰就误触发长按逻辑更是写成了屎山。用状态机做按键状态定义非常自然IDLE按键空闲PRESSING检测到按下正在确认防抖PRESSED确认按下且未释放RELEASING检测到释放正在确认防抖RELEASED释放完成等待下一次按下有一个经常被忽略的点去抖不是延时等待而是状态确认。状态机不需要delay(20ms)这种傻等只需要每隔10ms读取一次电平连续读到几次稳定电平才认为状态真正变化。这样CPU不会被延时卡住多个按键也容易扩展。按键长短按的判断最干净的方式是按下后启动一个定时器在PRESSED状态下如果定时器超过500ms且按键仍按住就触发长按动作如果按键提前释放则触发短按动作。状态机里的每个状态都有清晰的入口动作和退出动作调试时打日志也清晰。4.4 PLC编程里的状态机思路顺序控制与SCL表达PLC编程里状态机通常体现在顺序控制上。传统梯形图里用M继电器一步一步传递本质上就是状态机只是一旦步骤多了梯形图会变得很长。现在主流PLC都支持SCL结构化控制语言写状态机就容易多了。PLC状态机的思路和软件一样用一个整型变量保存当前步骤编号每个扫描周期根据当前步骤执行对应动作执行完条件满足就跳转到下一步。举个简单例子一个自动焊机流程CASE step OF 1: // 等待启动 IF start_button THEN step : 2; END_IF; 2: // 夹紧工件 clamp_on : TRUE; IF clamp_done THEN step : 3; END_IF; 3: // 焊接 weld_output : TRUE; IF weld_complete THEN step : 4; END_IF; 4: // 复位 clamp_on : FALSE; weld_output : FALSE; IF init_done THEN step : 1; END_IF; END_CASE;这种写法比M继电器连锁清晰太多而且天然支持跳步和条件分支。我在PLC项目里还喜欢加一步手动/自动切换手动模式下直接把step置成安全状态自动模式再重新开始。这在实际调试中非常有用因为设备调参数时不可能每次从头跑。不过PLC有它的特殊性扫描周期是连续的所以不需要考虑事件排队问题但必须想清楚上电初始化和异常停机的状态处理。我见过不少设备因为断电时状态变量刚好写了一半恢复供电后动作全乱了。稳妥做法是状态变量用非断电保持区上电强制回到安全初始状态。5. 状态机会踩的坑我基本都替你踩过了状态机听起来简单真在项目里用起来坑一个接一个。下面这些是我在不同项目里真实遇到过的问题不是从哪本书上抄的教条。5.1 状态爆炸状态和条件混在一起最常见的坑是有人把条件也塞进状态里。比如一个电商订单开始只有待支付已支付已发货三个状态后来加了退款中为了知道退款中的订单之前是已支付还是已发货有人就拆出支付后退款中发货后退款中两个状态。再后来优惠券、赠品、跨境清关都加进来状态数量直接爆炸。正确做法是状态只描述我现在大概在哪个阶段细节条件放在独立的数据字段里。状态爆炸的本质是用状态表达条件正确思路是用状态表达阶段用变量表达条件。什么时候该合并状态、什么时候该拆分状态标准就是在这两个状态下对同一个事件的反应是否完全相同。如果完全相同它们就是同一个状态。5.2 非法事件没处理状态机卡死的元凶状态机设计时大家总把注意力放在合法路径上非法事件经常被忽略。比如在下载中收到一个开始下载事件合理反应应该是忽略或者报错。如果代码里没有对应的分支有些语言会直接抛异常有些则静默返回状态机就留在原地。前者至少能暴露问题后者才真正危险。静默忽略会让系统进入一种我以为我做了其实什么都没发生的状态特别难排查。我的建议是状态机里所有没有定义的状态事件组合统一走一个handleUnexpectedEvent方法里面至少打一条警告日志。这样非法路径要么被补上要么在测试时就被日志炸出来。5.3 摩尔与米利输出该挂在哪里理论课一定会提Moore型和Mealy型状态机但很多教程把概念讲得很拧巴。用大白话说Moore型输出只由当前状态决定。你站在某个状态里系统就固定输出某种结果。Mealy型输出由当前状态 当前输入共同决定。同一个状态来不同事件输出不同。实际项目里选哪种我的经验是默认用Moore输出好测试、好调试遇到必须在某个事件发生时立刻产生不同输出的延迟敏感场景再用Mealy。比如一个通信协议的状态机握手阶段根据收到的帧类型不同要在同一个状态下回不同的应答包这种用Mealy就自然。很多人把输出逻辑散落在迁移函数和状态类里各写一点最后状态机行为没法从状态表里看出来。正确做法是明确输出在哪个时机关闭要么挂在进入状态时要么挂在迁移时不要两头都写。5.4 状态机用在多线程/异步场景竞态问题状态机天然是单线程思维但现实世界往往有多线程。我在做网络服务的时候一个连接的状态机被两个线程同时访问一个在收数据一个在发数据。表面上状态没变实际上两个线程都读到同一个旧状态然后各自按自己的逻辑迁移状态就乱了。解决思路有几种简单粗暴的就是给状态迁移加锁。但加锁会影响性能而且状态机逻辑复杂时死锁风险也不小。更符合状态机哲学的解法是所有事件统一扔进一个线程里串行处理用队列把异步事件转成同步序列。这样状态机就恢复成同一时刻只处理一个事件的假设所有竞态自然消失。我自己写网络层代码时状态变量只在一个现场函数里修改其他线程要改状态只往事件队列里塞。这个习惯让状态机的行为变得极好推测很多线上问题看一眼状态轨迹就知道原因。5.5 引入状态机框架之前先判断规模现在社区里状态机库很多比如XState、Spring StateMachine、Boost.Statechart。有人一听说状态机就上个框架结果配置比业务代码还长。框架不是不好而是得有规模才划算。我的判断标准大致是这样的状态少于10个事件少于8种直接手写查表或switch就够了状态多且互相行为差异大才考虑状态模式或框架一旦涉及复杂状态层级、guard条件、并行动作上框架能省大量时间。另外如果你的团队对状态机不熟手写一段清晰的查表代码可能比引一个黑盒框架更受欢迎因为能看懂本身就是一种可维护性。6. 把状态机变成你的底层思维写到这里我相信你已经有能力手写一个状态机了。但状态机真正值钱的地方不在代码里而在你的思维方式里。一旦习惯了用状态事件迁移的眼光看问题你会发现很多复杂系统突然变得清晰了。6.1 从状态机到状态模式面向对象落地如果你写面向对象语言状态机还有一种经典落地方式就是状态模式。它是把一个状态的逻辑封装成一个类状态类负责处理事件并返回可能的下一个状态。比如播放器有播放中、暂停、停止三个状态类每个类都实现play()、pause()、stop()方法内部各自决定行为。这个模式的好处是每个状态的代码都内聚在同一个类里加新状态不需要改老状态的类。坏处是类多了且状态的迁移关系散落在各个类中全局视图不清晰。所以状态模式更适合每个状态自身逻辑很重的场景比如游戏角色的AI状态。游戏里巡逻追逐攻击三种状态各自要跑寻路、播放动画、检测距离放在一个类里就是毁灭性的。这时候状态模式状态机才是工程上的正确姿势。6.2 状态机在AI编程时代的用法如何描述给大模型现在很多人用AI写代码最具性价比的用法之一就是让AI生成状态机代码。因为状态机的输入输出非常确定只要把状态表和迁移规则描述清楚大模型几乎不会跑偏。我在用AI辅助编码时会直接在提示词里写这样的内容请用Python实现一个TCP连接的状态机状态包括LISTEN、SYN_SENT、ESTABLISHED、FIN_WAIT、CLOSE_WAIT等事件包括connect、syn、ack、fin。请用查表法实现并处理非法事件。因为状态表已经定义好AI生成的代码结构通常很稳定。这也反过来提醒我们学习状态机的价值不只是自己写代码更是为和AI协作提供一份精确的需求规格说明书。你越是能把逻辑归纳成状态表就越容易让AI产出正确代码。6.3 用状态机审视常见业务逻辑状态机不只属于协议栈和嵌入式普通业务系统里到处是它的身影。审批流程的待审批已通过已驳回订单流程的待支付已支付已发货已签收音视频应用的播放中缓冲中播放完成这些全是状态机。我经常做的一件事是状态机体检把一个模块里所有的if嵌套和bool变量列出来尝试转换成状态表。如果转得过去说明这个模块本来就适合状态机只是当初没人这么设计如果转不过去往往就是逻辑混乱的地方要么是条件互相覆盖要么是有一个状态被隐式表达成多组变量这种代码迟早出bug。比如用户登录逻辑有人写isLoggedIn、isLoginPending、isTokenExpired三个bool。一旦遇到账号被踢下线这种事件更新这三个变量的顺序稍不对界面就会出现诡异的闪跳。而用状态机从未登录到登录中到已登录到会话过期再到重新登录整个过程一目了然哪里会出错边界在哪里全都清清楚楚。6.4 最后的小建议如果你想把状态机真正练成本能我建议从手边最小的事开始挑一个你正在写的业务模块把它的状态表列出来哪怕不重构代码只当练习也好。然后再遇到那些加一个功能就要改五个地方的模块你自然就会想到状态机了。我在实际项目里用状态机十多年它的门槛其实非常低低到一张白纸就能画完但它的上限又很高高到能承载CPU指令流水线、复杂网络协议、工业自动化整套控制逻辑。反正只要你愿意从这一刻我到底在哪个状态开始想问题就已经在路上了。
返回列表