control - flow - state - machine
07. 控制流、状态机与协议
0. 本章先解决什么问题
程序看起来是在“执行语句”,更底层一点看,它其实一直在做两件事:
根据当前状态和输入
-> 选择下一条路径
-> 改变状态
-> 再选择下一条路径
这就是控制流。
如果只会看变量和表达式,你能看懂“算了什么”。但要理解一个程序为什么卡住、为什么进入错误分支、为什么协议握手失败、为什么递归爆栈、为什么资源没有释放,就必须看懂“程序怎么走”。
本章要把几件事连起来:
- 顺序、分支、循环、调用、返回这些基本控制流。
- 调用栈如何支撑函数调用和递归。
- 状态机为什么是理解协议、任务、连接生命周期的通用模型。
- 异常、超时、取消、中断这些“非正常路径”为什么同样重要。
- 学完后如何把一个真实流程画成状态转换图,从而定位 bug。
一句话:
数据告诉你系统里有什么。
控制流告诉你系统下一步会变成什么。
这张图给出一个通用读法:正常路径、错误路径、超时、取消都要进入同一套状态转换模型。只画 happy path 的流程图通常不够,真正的 bug 往往藏在异常路径和资源收尾里。
这张图怎么读
读这张图时,要把“流程”拆成三层:
| 层 | 关注点 | 典型问题 |
|---|---|---|
| 执行路径 | 下一步走哪里 | 分支漏边界、循环不退出、递归不收敛 |
| 状态转移 | 当前状态如何变成下一状态 | 非法状态、重复推进、终态被修改 |
| 资源生命周期 | 资源何时获得、移交、释放 | 异常路径没释放、半完成状态泄漏 |
很多控制流 bug 都来自这三层没有对齐:
路径已经进入失败分支,
状态还停留在 running,
资源还没有释放。
所以读代码时不要只问“这一行后面执行哪一行”,还要问:
这条路径会把状态推进到哪里?
这条路径会不会跳过清理?
这条路径是否允许重入、重试、取消、超时?
1. 控制流是什么
控制流就是程序执行路径的选择规则。
最基本的控制流有六类:
| 控制流 | 直觉 | 常见问题 |
|---|---|---|
| 顺序 | 从前往后执行 | 某一步依赖前一步结果 |
| 分支 | 根据条件选路 | 条件写错、边界漏掉 |
| 循环 | 重复执行一段逻辑 | 无限循环、少一轮、多一轮 |
| 调用 | 进入另一个过程 | 参数传错、调用链过深 |
| 返回 | 回到调用点 | 返回值含义不清、状态未恢复 |
| 异常/中断 | 跳出正常路径 | 资源未释放、状态半更新 |
一个很小的例子:
读入 n
如果 n < 0:
报错
否则:
从 1 加到 n
输出结果
这里既有顺序,也有分支,也有循环。你分析它时,不只要看加法是否正确,还要问:
n < 0时有没有提前停止?n = 0时循环执行几次?- 循环变量从哪里开始,到哪里结束?
- 报错路径会不会继续执行后面的逻辑?
这类问题就是控制流问题。
2. 程序计数器:CPU 眼里的下一步
在机器层面,CPU 并不知道“业务流程”。它只需要知道:
下一条指令在哪里?
这个位置通常由程序计数器保存。大多数情况下,CPU 执行完一条指令后,程序计数器自然指向下一条指令。
指令 A
指令 B
指令 C
但分支、循环、函数调用、返回、异常都会改变“下一条指令”的位置。
代码块收起展开
if 条件成立:
跳到位置 X
else:
跳到位置 Y所以从底层看:
控制流 = 对“下一条指令地址”的管理
这能解释一个重要事实:控制流不是高级语言才有的东西。任何程序最终都要落到“下一步执行哪里”。
3. 顺序执行:最容易被忽略的依赖
顺序执行看起来最简单:
step1
step2
step3
但顺序背后常常藏着依赖关系:
打开文件
读取内容
关闭文件
这里不能随便交换顺序。你不能先读取再打开,也不能在读取前关闭。
顺序依赖常见于:
| 场景 | 顺序为什么重要 |
|---|---|
| 初始化 | 资源必须先创建,后使用 |
| 校验 | 输入必须先检查,后处理 |
| 写入 | 数据要先准备,后提交 |
| 清理 | 任务结束后释放资源 |
| 协议 | 必须先握手,再传输数据 |
很多 bug 表面是“值错了”,实际是“步骤顺序错了”。
例如:
先把任务标记为完成
再真正写入结果
如果写入失败,系统就会出现一个矛盾状态:
状态说任务完成了
结果却不存在
分析顺序控制流时,要问:
- 哪些步骤必须发生在前?
- 哪些步骤可以交换?
- 哪一步失败后,后续步骤是否还能执行?
- 状态是不是在正确的时间点更新?
4. 分支:条件如何改变路径
分支把一个流程分成多条路径。
代码块收起展开
if condition:
path A
else:
path B分支的核心问题不是“有没有 if”,而是:
条件是否完整地区分了所有可能状态?
例如处理一个数字:
n < 0
n = 0
n > 0
如果代码只处理了 n > 0 和 n < 0,中间的 n = 0 就可能落入意外路径。
分支常见错误:
| 错误 | 例子 |
|---|---|
| 边界漏掉 | 只判断 <,忘了 = |
| 条件重叠 | 两个分支都可能匹配 |
| 条件顺序错误 | 更宽泛的条件放在前面 |
| 默认路径模糊 | else 承担了太多含义 |
| 状态不一致 | 分支 A 更新状态,分支 B 没更新 |
分支也影响性能。CPU 为了快,会猜测下一步走哪条路,这叫分支预测。如果猜错,流水线里提前做的工作要丢掉。
你不需要一开始就掌握所有硬件细节,但要有这个直觉:
大量不可预测的分支
-> CPU 更难提前安排工作
-> 可能带来性能损耗
控制流因此同时影响正确性和性能。
5. 循环:重复执行背后的状态推进
循环不是“重复代码”这么简单。循环本质上是一个状态推进器:
初始状态
-> 判断是否继续
-> 执行一轮
-> 更新状态
-> 再判断
一个循环最少要看四件事:
| 问题 | 为什么重要 |
|---|---|
| 初始状态是什么 | 决定第一轮从哪里开始 |
| 继续条件是什么 | 决定什么时候停止 |
| 每轮做什么 | 决定结果如何变化 |
| 每轮如何推进状态 | 决定是否会无限循环 |
示意:
i = 0
while i < n:
work(i)
i = i + 1
如果忘记 i = i + 1,循环永远停不下来。如果把条件写成 i <= n,可能多执行一轮。如果 work(i) 会改变 n,循环次数可能变得不稳定。
分析循环时,建议固定问这六个问题:
- 循环变量是谁?
- 退出条件是什么?
- 每轮循环后,变量是否向退出条件靠近?
- 循环体是否会修改影响退出条件的状态?
- 最小输入、空输入、最大输入会怎样?
- 每轮成本是多少,总共执行多少轮?
复杂度分析其实也在看循环:
循环执行次数 * 每轮成本 = 总成本
嵌套循环就是多层状态推进:
代码块收起展开
for i in 0..n:
for j in 0..m:
work(i, j)如果 work 是常数成本,总成本通常和 n * m 同级。
6. 函数调用:把控制流交给另一个过程
函数调用看起来像“执行一个名字”:
result = f(x)
实际发生的是:
准备参数
保存返回位置
进入 f
执行 f
把结果交回调用者
回到原来的位置继续执行
调用链可以画成:
调用的价值在于抽象:调用者不用知道函数内部每一步怎么做,只需要知道它的输入、输出和约定。
但调用也会制造问题:
| 问题 | 含义 |
|---|---|
| 参数约定不清 | 函数接受什么输入不明确 |
| 返回值语义不清 | 返回 0、-1、null、空集合分别表示什么 |
| 副作用隐藏 | 函数偷偷改了外部状态 |
| 调用链太深 | 出错时很难看清责任层 |
| 资源归属不清 | 谁创建资源,谁释放资源 |
读复杂程序时,不要只沿着代码从上往下看。更有效的方式是画调用链:
入口在哪里?
它调用了谁?
谁负责校验?
谁负责改变状态?
谁负责处理失败?
7. 调用栈:函数调用如何被记住
函数调用需要“记住回来的路”。这通常由调用栈完成。
一次调用会创建一个栈帧。栈帧大致保存:
| 内容 | 作用 |
|---|---|
| 参数 | 被调用函数需要的输入 |
| 局部变量 | 函数内部临时状态 |
| 返回地址 | 函数结束后回到哪里 |
| 调用现场 | 调用前的一些执行上下文 |
调用栈示意:
栈顶
solve()
compute()
main()
栈底
当 solve() 返回,它的栈帧被弹出,控制流回到 compute()。
这解释了几个常见现象:
| 现象 | 栈角度的解释 |
|---|---|
| 局部变量出了函数就不能用 | 对应栈帧已经结束 |
| 栈溢出 | 调用层数太深,栈空间不够 |
| 调用栈日志能定位错误 | 它记录了函数进入路径 |
| 递归必须有终止条件 | 否则不断压入新栈帧 |
当你看到错误堆栈时,不要只看最后一行。要从入口一路读到出错点:
谁调用了谁?
错误是在哪一层第一次出现的?
上层传入了什么不合法状态?
8. 递归:用调用栈表达重复结构
递归是函数直接或间接调用自己。
它适合处理“问题可以拆成同类子问题”的场景:
代码块收起展开
solve(problem):
如果 problem 足够小:
直接解决
否则:
拆成 smaller problem
solve(smaller problem)
合并结果递归必须有两个部分:
| 部分 | 作用 |
|---|---|
| base case | 停止继续递归 |
| recursive step | 把问题变小 |
没有 base case,会无限递归。recursive step 没有让问题变小,也会无限递归。
递归和循环本质上都在表达重复,但方式不同:
| 对比 | 循环 | 递归 |
|---|---|---|
| 状态放在哪里 | 显式变量 | 调用栈 |
| 适合场景 | 线性重复、计数 | 树、分治、嵌套结构 |
| 常见风险 | 无限循环 | 栈溢出 |
| 分析重点 | 循环变量是否推进 | 子问题是否变小 |
递归的成本要同时看:
递归调用次数 + 每次调用成本 + 栈空间
所以递归不是“高级写法”,它只是把控制流和状态放到了调用栈里。
9. 状态机:把流程看成状态和转移
很多流程用 if/else 看会变乱,用状态机看会变清楚。
状态机由三部分组成:
状态集合 + 事件/输入 + 转移规则
一个门的例子:
状态: Closed, Open
事件: push, pull
Closed + push -> Open
Open + pull -> Closed
更一般地:
当前状态 + 输入事件 -> 下一个状态 + 动作
状态机让你把问题从“很多 if/else”变成三个问题:
- 有哪些状态?
- 有哪些事件?
- 每个状态下,遇到每个事件应该怎么转移?
例如一个任务生命周期:
Pending
-> Running
-> Succeeded
Running
-> Failed
Running
-> Cancelled
如果画成状态机,你马上能问出关键问题:
Succeeded之后还能不能回到Running?Pending能不能直接Cancelled?Running超时后进入Failed还是Cancelled?- 重复收到完成事件怎么办?
- 失败后能不能重试?
这些问题比空泛地说“任务状态有 bug”更有力量。
9.1 状态机能消灭布尔变量迷宫
很多流程一开始写得很简单:
isRunning
isDone
isCancelled
hasError
但状态一多,就会出现矛盾组合:
isRunning = true
isDone = true
isCancelled = true
这到底表示什么?如果没人说得清,说明系统需要显式状态机。
用枚举状态表达:
PENDING
RUNNING
SUCCEEDED
FAILED
CANCELLED
再规定合法转移:
| 当前状态 | 事件 | 下一个状态 |
|---|---|---|
PENDING | start | RUNNING |
PENDING | cancel | CANCELLED |
RUNNING | finish | SUCCEEDED |
RUNNING | fail | FAILED |
RUNNING | cancel | CANCELLED |
SUCCEEDED | 任意重复完成事件 | SUCCEEDED |
这比到处写 if/else 更容易检查:
终态能不能再变化?
重复事件是否幂等?
非法事件是忽略、报错还是记录?
10. 协议就是状态机
协议不只是“消息格式”。协议还规定:
在什么状态下
可以接收什么消息
收到后要做什么
下一步进入什么状态
一个简化握手可以写成:
INIT
— send hello —> WAIT_ACK
WAIT_ACK
— receive ack —> ESTABLISHED
— timeout —> CLOSED
ESTABLISHED
— receive data —> ESTABLISHED
— close —> CLOSING
CLOSING
— finish —> CLOSED
如果在 INIT 状态收到数据包,通常不是合法输入。如果在 WAIT_ACK 状态一直等不到确认,就要处理超时。如果在 CLOSING 状态又收到重复关闭消息,就要决定忽略、重发确认,还是报错。
协议 bug 常见来源:
| 问题 | 结果 |
|---|---|
| 状态没更新 | 系统以为还在旧阶段 |
| 接收了当前状态不该接收的事件 | 非法消息被当成合法消息处理 |
| 超时没处理 | 连接或任务永久挂起 |
| 重复消息没处理 | 状态被重复推进 |
| 半关闭状态没分清 | 一方已停止发送,另一方还在等待 |
| 失败路径没定义 | 出错后不知道回滚还是继续 |
这也是网络、操作系统、编译器、UI、文件系统都大量使用状态机思想的原因。
10.1 协议状态机要处理乱序、重复和迟到
真实协议不会只收到“刚好正确”的消息。你必须考虑:
| 情况 | 例子 | 处理问题 |
|---|---|---|
| 乱序 | 后发的消息先到 | 是否缓存、丢弃、等待前置状态 |
| 重复 | 同一确认包到达两次 | 是否幂等处理 |
| 迟到 | 超时后旧响应才回来 | 是否还能改变状态 |
| 半关闭 | 一方不再发送但仍能接收 | 状态是否拆成读/写两个方向 |
| 版本不兼容 | 对方发送未知字段 | 忽略、降级还是断开 |
这也是为什么协议实现不能只写:
收到 A -> 回 B
收到 C -> 回 D
还要写:
当前状态下收到 A 是否合法?
如果 A 已经过期怎么办?
如果 A 重复到达怎么办?
如果等待 B 超时怎么办?
11. 异常控制流:正常路径之外的路径
新手写程序时常常只写正常路径:
打开资源
使用资源
关闭资源
但真实系统里还有:
打开失败怎么办?
使用到一半失败怎么办?
关闭失败怎么办?
外部取消怎么办?
超时怎么办?
程序崩溃怎么办?
异常控制流包括:
| 异常控制流 | 例子 |
|---|---|
| 错误返回 | 函数返回失败码 |
| 异常抛出 | 跳过中间普通流程 |
| 中断 | 设备通知 CPU 有事件 |
| 信号 | OS 通知进程某件事发生 |
| 超时 | 等待太久后放弃 |
| 取消 | 外部要求任务停止 |
| 重试 | 失败后重新尝试 |
| 回滚 | 撤销已经做过的修改 |
异常路径的危险在于它会跳过普通代码。
打开文件
写入一半
发生错误
直接返回
文件没有关闭
状态没有恢复
这类问题不是算法错,而是控制流漏了。
设计异常路径时要问:
- 资源一定会释放吗?
- 状态会不会停在半更新状态?
- 错误会不会被吞掉?
- 调用者能不能知道失败原因?
- 重试会不会重复造成副作用?
- 日志或证据能不能还原当时路径?
健壮系统不是没有失败,而是失败路径也被设计过。
11.1 取消和超时不是普通失败
取消和超时很容易被粗暴地当作“失败”,但它们的语义不一样。
| 情况 | 含义 |
|---|---|
| 普通失败 | 操作尝试执行,但遇到错误 |
| 取消 | 外部不再需要结果,要求尽快停止 |
| 超时 | 调用方等不到结果,但被调用方可能仍在运行 |
这三个状态的后续处理不同:
- 失败:记录原因,决定是否重试或回滚
- 取消:尽快释放资源,不再提交无意义结果
- 超时:调用方放弃等待,但要考虑对方可能稍后成功
如果把超时简单等同于失败,就可能出现:
调用方超时重试
原请求稍后成功
重试请求又成功
副作用发生两次
所以任何带外部副作用的流程,都要把 timeout/cancel/retry 写进状态机。
12. 资源清理:控制流必须收尾
资源包括文件、内存、锁、网络连接、事务、临时文件、设备句柄等。
资源管理的基本原则是:
谁获得资源
谁就必须保证它最终被释放或移交
常见资源流程:
Acquire
-> Use
-> Release
但真实流程可能是:
Acquire
-> Use
-> error
-> Release
所以资源释放不能只放在正常路径末尾。它要覆盖所有可能离开的路径:
| 离开路径 | 是否要清理 |
|---|---|
| 正常返回 | 要 |
| 错误返回 | 要 |
| 异常抛出 | 要 |
| 超时取消 | 要 |
| 中途重试 | 要,或明确移交 |
如果控制流路径很多,资源清理就容易漏。解决思路通常是让语言、库或结构帮你收尾,比如作用域清理、自动关闭、统一 finally/cleanup、事务边界等。
这背后的核心不是某种语法,而是一个通用约束:
每条离开路径都必须经过清理点。
13. 如何阅读一个复杂流程
遇到复杂流程时,不要急着逐行钻进去。先画一个控制流骨架。
推荐步骤:
- 找入口:流程从哪里开始?
- 找出口:有哪些正常结束和失败结束?
- 找状态:流程中有哪些阶段?
- 找事件:什么输入或事件会推动状态变化?
- 找循环:哪些地方会重复等待、重试、轮询?
- 找资源:哪些资源被打开、占用、锁住?
- 找异常路径:失败、超时、取消怎么走?
可以画成:
如果某条路径绕过 Cleanup,那就是高风险点。
如果某个状态没有出口,那可能是卡死点。
如果某个事件在当前状态没有定义,那可能是协议漏洞。
14. 联系实际:把连接生命周期画出来
假设你在分析一个“连接偶尔关闭不了”的问题。不要一开始就猜是网络、系统、代码或者对方问题。先把连接生命周期画出来:
Created
-> Connecting
-> Connected
-> Closing
-> Closed
Connecting
-> Timeout -> Closed
Connected
-> RemoteClose -> Closing
-> LocalClose -> Closing
-> Error -> Closing
然后问:
- 连接现在卡在哪个状态?
- 进入这个状态的事件是什么?
- 它应该等待什么事件才能出去?
- 如果等不到,有没有超时?
- 关闭过程中资源是否一定释放?
- 重复 close 会发生什么?
- 关闭一半时又收到数据怎么办?
这套思路同样适用于:
- 任务队列为什么卡在 running。
- 文件上传为什么一直 pending。
- 登录流程为什么跳不回首页。
- 编译器为什么卡在某个分析阶段。
- 网络协议为什么握手后不能传输。
你会发现,很多复杂问题不是缺少“高级技巧”,而是没有把控制流、状态、事件、异常路径画清楚。
机制深挖:状态机最重要的不是状态名,而是不变量
一个状态机如果只列状态名,价值很有限。真正有用的是每个状态背后的不变量。
以任务状态为例:
| 状态 | 不变量 |
|---|---|
PENDING | 还没有执行者,允许被领取 |
RUNNING(owner) | 只有 owner 可以提交结果 |
SUCCEEDED | 结果已经确定,不应再被修改 |
FAILED | 失败原因已经记录,是否可重试要明确 |
CANCELLED | 调用方不再需要结果,后续完成事件不能直接提交 |
有了不变量,很多模糊问题会变成可检查规则:
RUNNING 没有 owner 是否合法?
SUCCEEDED 后又收到 fail 事件怎么办?
CANCELLED 后底层操作稍后成功,结果能不能写入?
FAILED 重试是复用同一个任务,还是创建新 attempt?
这就是状态机比 if/else 更强的地方:它让每条路径都必须回答“现在系统承诺了什么”。
边界条件:迟到事件不能随便改写终态
考虑一个带超时的远程操作:
PENDING
-> RUNNING
-> SUCCEEDED
RUNNING
-> TIMEOUT
危险时间线:
t0 调用方发起操作,状态 RUNNING
t1 调用方等待超时,状态 TIMEOUT
t2 被调用方其实处理成功,成功响应迟到
如果系统在 TIMEOUT 后仍然接受迟到的 success,就会出现语义冲突:
代码块收起展开
上层已经按超时补偿或重试;
迟到成功又把旧 attempt 写成完成;最终结果可能被旧响应覆盖。
更稳的模型是把 attempt 编号写进状态:
RUNNING(attempt=7)
TIMEOUT(attempt=7)
RUNNING(attempt=8)
迟到响应必须携带 attempt,提交前检查:
响应属于当前 attempt 吗?
当前状态还允许 success 转移吗?
该操作是否已经被取消、补偿或替代?
这类 bug 的失败指纹是:
| 现象 | 可能状态机问题 |
|---|---|
| 超时后数据又被改回旧值 | 迟到事件覆盖新状态 |
| 任务显示失败但结果存在 | 失败终态和成功副作用没有对齐 |
| 重试后出现两份结果 | attempt 没有隔离 |
| 取消后仍收到完成通知 | 取消只改了 UI/外层状态,底层完成事件未被过滤 |
所以终态不是“流程结束了”这么简单。终态是一条边界:哪些事件还能被接受,哪些只能记录为迟到证据,必须明确。
练习卡:把一个流程改写成状态机
任选一个你熟悉的流程,例如文件上传、任务执行、连接关闭、登录跳转。不要先写代码,先填这张表:
| 项 | 你要写清楚什么 |
|---|---|
| 状态 | Pending、Running、Succeeded、Failed、Cancelled 这类阶段 |
| 事件 | start、finish、error、timeout、cancel、retry |
| 转移 | 当前状态 + 事件 -> 下一个状态 |
| 动作 | 转移发生时要做什么,例如写结果、释放资源、发通知 |
| 非法事件 | 当前状态下不该接受什么事件 |
| 收尾 | 每条离开路径是否都释放资源 |
然后专门检查四类边界:
- 正常完成后,重复收到完成事件会怎样?
- 正在运行时取消,资源是否释放?
- 超时和失败是同一个状态,还是要区分?
- 失败后重试,是回到
Pending,还是创建一个新任务?
这个练习会逼你把“流程大概是这样”变成可验证的状态规则。很多状态错乱、重复执行、资源泄漏,都是因为这些规则从来没有被写清楚。
15. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 一个程序为什么进入了错误分支?
- 一个循环为什么多执行一轮、少执行一轮或永远停不下来?
- 递归为什么会栈溢出?
- 错误堆栈应该从哪里开始读?
- 一个任务为什么卡在某个状态不动?
- 协议为什么必须规定消息顺序,而不只是规定消息格式?
- 为什么超时、取消、重试这些异常路径必须单独设计?
- 为什么资源释放要覆盖所有返回、失败、异常路径?
- 如何把一段复杂流程画成状态机,再用它定位 bug?
如果你以后看到“流程卡住、状态错乱、连接关不掉、任务重复执行、资源泄漏”,第一反应应该是画出控制流和状态转换,而不是直接猜某一行代码错了。