mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
3471 字
9 分钟
状态机(State Machine)
2026-06-13

一、为什么需要状态机#

想象你在写一个网络请求模块。请求有几种状态:空闲、加载中、成功、失败。你用布尔变量来标记:isLoadingisSuccessisError。一开始只有三四个标志,逻辑还算清晰。但很快你会发现,这些布尔变量之间存在隐含的约束——isLoadingisSuccess 不可能同时为真,加载中不应该再次触发请求,成功之后不能再变成失败。

这些约束散落在各个 if-else 分支里,没有集中管理。一旦遗漏某个检查,就会出现「加载中还能点击」「成功后还能报错」之类的 bug。更糟糕的是,当你需要加一个「重试」状态时,所有涉及状态判断的地方都要改,漏改一个就是线上事故。

用布尔标志写出来的代码大概长这样:

// 用布尔标志管理请求状态——典型的反面模式
let isLoading = false;
let isSuccess = false;
let isError = false;
function fetch() {
if (isLoading) return; // 防止重复请求
if (isSuccess) return; // 成功了还请求?
isLoading = true;
isError = false;
// ...发起请求
}
function onSuccess() {
isLoading = false;
isSuccess = true; // 忘了重置 isError?bug 就来了
}
function onError() {
isLoading = false;
isError = true;
isSuccess = false; // 又要记得重置 isSuccess
}
function retry() {
if (!isError) return; // 只有出错才能重试
isError = false;
fetch(); // 但 fetch 里又检查 isSuccess...
}

这段代码的问题一目了然:每个函数都要手动维护多个布尔变量的联动关系,onSuccess 里忘了重置 isErrorretry 调用 fetchisSuccess 可能还是 true 导致请求被跳过。三个布尔变量有 8 种组合,但合法的只有 4 种。剩下的 4 种是不可能状态——它们不该出现,但代码没有任何手段阻止它们出现。

更具体的场景:一个订单系统。订单有「待支付」「已支付」「已发货」「已完成」「已取消」五种状态。如果用布尔标志,你需要 isPaidisShippedisCompletedisCancelled 四个变量,组合出 16 种可能。其中「已支付且已取消」「已发货且未支付」这类不可能状态占了大多数。每写一个业务判断,你都在跟这些幽灵状态搏斗。

问题的根源在于:布尔标志的组合空间远大于合法状态的数量。3 个布尔变量有 8 种组合,但你只需要 4 种合法状态;4 个布尔变量有 16 种组合,但可能只有 5 种合法状态。那些多余的组合是不可能状态——它们不该出现,但代码没有阻止它们出现。状态机从根本上解决这个问题:把合法状态和状态之间的转移规则显式定义出来,让不可能的状态不可表达。

二、现实类比#

自动售货机。它有明确的状态:空闲、已投币、正在出货。不投币就不能出货,正在出货时不能再投币。每个按钮只在特定状态下有效——你不会在出货时还能按退币键。售货机不需要一堆 if-else 来判断「当前能不能做这个操作」,它只需要一张状态转移表。

再想想十字路口的红绿灯。它永远在红、黄、绿之间循环,不可能出现「红绿灯同时亮」的情况,因为硬件电路本身就是一张硬编码的转移表。现实世界里的嵌入式设备几乎都用状态机来建模控制逻辑——电梯、洗衣机、微波炉,哪个不是按状态和转移来运转的?

三、核心思想#

状态机把实体的生命周期建模为一组有限状态显式转移。任何时刻,实体恰好处于一个状态。转移由事件触发,只有定义过的转移才能发生。

stateDiagram-v2 [*] --> idle idle --> loading : FETCH loading --> success : RESOLVE loading --> error : REJECT error --> loading : RETRY success --> idle : RESET

上面这张状态图只画了合法的转移路径。注意 successerror 之间没有箭头——这不是遗漏,而是设计:这两个状态之间不存在合法转移,状态机从结构上杜绝了这种可能。

威力所在:不存在的转移不可能发生。你无法从 success 跳到 error,因为没有定义这样的转移。这比在代码里到处加 if (!isSuccess) 判断要可靠得多——你不可能忘记加检查,因为根本不需要检查。

3.1 转移表#

核心数据结构是一张转移表——以 (当前状态, 事件) 为键,下一状态 为值的映射。拿上面的请求状态机举例,转移表如下:

当前状态事件下一状态
idleFETCHloading
loadingRESOLVEsuccess
loadingREJECTerror
errorRETRYloading
successRESETidle

查表操作是 O(1),当前状态用单个变量存储也是 O(1)。比起散落在各处的 if-else,转移表把所有规则集中在一个地方,一目了然。新增状态或修改转移逻辑只需要改这张表,不用满代码找条件分支。

3.2 守卫条件与动作#

实际的转移往往不只是「收到事件就切换状态」这么简单。你经常需要:

  • 守卫条件(Guard):即使事件出现了,也要满足额外条件才能转移。比如「从已投币到出货」需要「商品有库存」这个前提。守卫条件不满足时,事件被忽略或排队。
  • 进入动作(Entry Action):进入某个状态时自动执行的副作用,比如进入 loading 状态时发起网络请求。
  • 退出动作(Exit Action):离开某个状态时的清理操作,比如离开 loading 状态时取消请求。

加入守卫和动作后,一条完整的转移规则可以表示为:当处于状态 A,收到事件 E,且守卫条件 G 为真时,执行退出动作、转移到状态 B、执行进入动作。这些机制让状态机从纯数学模型变成可落地的工程工具。

属性
状态转移O(1)——状态 x 事件表查找
当前状态O(1)——单个变量
有效事件每状态可枚举——支持穷举检查
空间O(状态数 x 事件数) 用于转移表

四、变体与对比#

模式与状态机的关系
Actor 模型Actor 通常在内部用状态机管理行为,每个 Actor 有自己的状态和消息处理逻辑,收到消息后根据当前状态决定如何响应
熔断器熔断器本身就是状态机:关闭(正常放行)→ 打开(直接拒绝)→ 半开(试探性放行),三个状态之间的转移由失败率和超时触发
访问者访问者可以根据状态机当前状态进行不同分发,两者结合可以实现类型安全的状态行为映射
标签联合体标签联合体编码合法状态(让不可能状态不可构造),状态机编码合法转移(让不可能转移不可触发),互补关系

4.1 Mealy 机与 Moore 机#

状态机有两个经典变体,区别在于输出是否依赖当前输入

  • Mealy 机:输出取决于当前状态和当前输入。转移发生时产生输出,状态本身不携带输出信息。上面代码里的实现就是 Mealy 风格——Send(event) 返回的结果取决于事件和当前状态的组合。
  • Moore 机:输出只取决于当前状态,与输入无关。每个状态关联一个固定的输出,进入该状态就产生该输出。Moore 机通常需要更多状态来表达同样的行为,但行为更容易预测——只看状态就知道输出是什么。

工程实践中,Mealy 机更常见,因为它状态数更少、表达更紧凑。但 Moore 机在需要「状态即输出」的场景下更直观,比如 UI 渲染——当前状态直接决定渲染什么组件,不需要额外的事件判断。

状态机 vs 布尔标志的对比是核心论点:11 个布尔变量有 2048 种组合,TCP 只需要 11 种合法状态。布尔方法允许 2037 种无效组合,每个 if-else 都必须防范它们;状态机通过构造使不可能状态不可表达。

五、多语言实现#

5.1 Go 实现#

// StateMachine 用转移表管理状态生命周期
type StateMachine struct {
current string
transitions map[string]map[string]string // state -> event -> next
}
func New(initial string) *StateMachine {
return &StateMachine{
current: initial,
transitions: make(map[string]map[string]string),
}
}
// AddTransition 注册一条合法的状态转移
func (sm *StateMachine) AddTransition(from, event, to string) {
if sm.transitions[from] == nil {
sm.transitions[from] = make(map[string]string)
}
sm.transitions[from][event] = to
}
// Send 触发事件,若转移合法则更新状态
func (sm *StateMachine) Send(event string) string {
if next, ok := sm.transitions[sm.current][event]; ok {
sm.current = next
}
return sm.current
}
// Can 检查当前状态下该事件是否有效
func (sm *StateMachine) Can(event string) bool {
_, ok := sm.transitions[sm.current][event]
return ok
}
func (sm *StateMachine) State() string { return sm.current }

Go 的实现用嵌套 map 存储转移表。外层 map 的键是当前状态,内层 map 的键是事件,值是目标状态。Send 方法做一次 map 查找决定是否转移,这种设计有几个考量:第一,未定义的事件被静默忽略——状态机保持稳定而不是报错,这是状态机语义的惯例,不是疏忽;第二,用 string 而非枚举类型,是为了保持通用性,生产代码可以用 type State string 定义类型别名来获得类型安全;第三,AddTransition 是运行时构建转移表,适合配置驱动的场景,如果状态和转移在编译期就确定,可以用 constinit() 函数来固定。

5.2 TypeScript 实现#

type StateConfig<S extends string, E extends string> = {
[state in S]: { on: Partial<Record<E, S>> };
};
class StateMachine<S extends string, E extends string> {
private current: S;
constructor(
private config: StateConfig<S, E>,
initial: S,
) {
this.current = initial;
}
get state(): S {
return this.current;
}
// 触发事件,查转移表决定下一状态
send(event: E): S {
const next = this.config[this.current].on[event];
if (next !== undefined) {
this.current = next;
}
return this.current;
}
// 当前状态下该事件是否有效
can(event: E): boolean {
return this.config[this.current].on[event] !== undefined;
}
}

TypeScript 的实现把转移表直接编码为配置对象,和 Go 版的嵌套 map 思路相同,但利用了类型系统的能力。StateConfig<S, E> 用映射类型确保每个状态都必须声明 on 字段,Partial<Record<E, S>> 表示并非每个事件在每个状态下都有定义——这正是「不存在的转移不可能发生」在类型层面的体现。实际使用时,如果你传了一个在当前状态下没有定义的事件,send 会静默返回当前状态,和 Go 版行为一致。如果想更严格,可以改成 send 在无效事件时抛异常,这取决于业务需要。

六、生产验证#

  • XState —— StateMachine.ts#L58-L120 JavaScript/TypeScript 工业级状态机库。它不仅实现了基本的状态转移,还支持层级状态(嵌套状态机)、并行状态(正交区域)、延迟转移和守卫条件。Netflix 用它管理复杂的多步注册流程,Microsoft 在 VS Code 的某些扩展中用它编排交互逻辑,AWS 在 Step Functions 中用状态图语言(本质是状态机)定义工作流。
  • Linux 内核 —— tcp_input.c#L4865-L4920 TCP 连接状态机。TCP 协议定义了 11 种状态(从 CLOSEDESTABLISHEDTIME_WAIT),Linux 内核用 switch (sk->sk_state) 实现状态分发,每个 case 处理该状态下合法的入站报文。这个状态机运行在全球每台联网设备的内核里,是状态机模式最广泛的生产部署之一。TCP 状态机的设计精妙之处在于:TIME_WAIT 状态持续 2MSL 后自动转移到 CLOSED,这是一种带超时的自转移,保证连接资源最终被回收。
  • Kubernetes —— Pod 生命周期状态转移,从 PendingRunningSucceeded/Failed,通过状态机保证不会出现非法中间状态。Kubernetes 的 Pod 状态虽然看起来简单,但背后涉及容器运行时、调度器、kubelet 三方协作。状态机确保了比如一个 Failed 的 Pod 不会突然变成 Running——即使 kubelet 误报了状态,API Server 也会根据转移规则拒绝非法变更。

七、小结#

何时使用

  • 协议实现——TCP、HTTP、WebSocket 等有明确状态转移规范的场景。TCP 状态机是教科书级案例,11 个状态、数十条转移规则,用布尔标志根本无法管理。
  • UI 流程管理——多步表单、认证流程、模态框,需要约束用户操作顺序。XState 在前端社区被广泛采用正是因为这类需求。
  • 游戏逻辑——角色状态(待机、行走、攻击、死亡),每种状态下可执行的动作不同。死亡状态不接受任何输入,这靠状态机天然保证。
  • 工作流引擎——审批链、部署流水线,状态的推进必须遵循规则。Kubernetes 的 Pod 生命周期就是典型的工作流状态机。

何时不用

  • 简单布尔切换——true/false 不需要状态机,一个变量够了。一个开关灯的按钮用状态机是过度设计。
  • 无界状态——连续状态空间(位置、分数)用普通变量。角色的坐标是连续值,不可能为每个坐标建一个状态。
  • 无非法转移——如果任何状态可以转到任何其他状态,约束没有意义。一个简单的缓存标记(命中/未命中),两个状态之间可以随意切换,状态机不会带来收益。
  • 组合爆炸——5 个独立开关等于 32 种状态;应该将正交关注点分别建模为并行状态机。XState 的并行状态(orthogonal states)就是为解决这个问题设计的。

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

状态机(State Machine)
https://blog.souloss.com/posts/programming/behavioral/behavioral-state-machine/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时