control - flow - state - machine

07. 控制流、状态机与协议

0. 本章先解决什么问题

程序看起来是在“执行语句”,更底层一点看,它其实一直在做两件事:

根据当前状态和输入
-> 选择下一条路径
-> 改变状态
-> 再选择下一条路径

这就是控制流。

如果只会看变量和表达式,你能看懂“算了什么”。但要理解一个程序为什么卡住、为什么进入错误分支、为什么协议握手失败、为什么递归爆栈、为什么资源没有释放,就必须看懂“程序怎么走”。

本章要把几件事连起来:

  • 顺序、分支、循环、调用、返回这些基本控制流。
  • 调用栈如何支撑函数调用和递归。
  • 状态机为什么是理解协议、任务、连接生命周期的通用模型。
  • 异常、超时、取消、中断这些“非正常路径”为什么同样重要。
  • 学完后如何把一个真实流程画成状态转换图,从而定位 bug。

一句话:

数据告诉你系统里有什么。
控制流告诉你系统下一步会变成什么。

控制流状态机

这张图给出一个通用读法:正常路径、错误路径、超时、取消都要进入同一套状态转换模型。只画 happy path 的流程图通常不够,真正的 bug 往往藏在异常路径和资源收尾里。

这张图怎么读

读这张图时,要把“流程”拆成三层:

关注点典型问题
执行路径下一步走哪里分支漏边界、循环不退出、递归不收敛
状态转移当前状态如何变成下一状态非法状态、重复推进、终态被修改
资源生命周期资源何时获得、移交、释放异常路径没释放、半完成状态泄漏

很多控制流 bug 都来自这三层没有对齐:

路径已经进入失败分支,
状态还停留在 running,
资源还没有释放。

所以读代码时不要只问“这一行后面执行哪一行”,还要问:

这条路径会把状态推进到哪里?
这条路径会不会跳过清理?
这条路径是否允许重入、重试、取消、超时?

1. 控制流是什么

控制流就是程序执行路径的选择规则。

最基本的控制流有六类:

控制流直觉常见问题
顺序从前往后执行某一步依赖前一步结果
分支根据条件选路条件写错、边界漏掉
循环重复执行一段逻辑无限循环、少一轮、多一轮
调用进入另一个过程参数传错、调用链过深
返回回到调用点返回值含义不清、状态未恢复
异常/中断跳出正常路径资源未释放、状态半更新

一个很小的例子:

读入 n
如果 n < 0:
报错
否则:
从 1 加到 n
输出结果

这里既有顺序,也有分支,也有循环。你分析它时,不只要看加法是否正确,还要问:

  1. n < 0 时有没有提前停止?
  2. n = 0 时循环执行几次?
  3. 循环变量从哪里开始,到哪里结束?
  4. 报错路径会不会继续执行后面的逻辑?

这类问题就是控制流问题。

2. 程序计数器:CPU 眼里的下一步

在机器层面,CPU 并不知道“业务流程”。它只需要知道:

下一条指令在哪里?

这个位置通常由程序计数器保存。大多数情况下,CPU 执行完一条指令后,程序计数器自然指向下一条指令。

指令 A
指令 B
指令 C

但分支、循环、函数调用、返回、异常都会改变“下一条指令”的位置。

代码块PLAINTEXT · 4 行收起展开
if 条件成立:
  跳到位置 X
else:
  跳到位置 Y

所以从底层看:

控制流 = 对“下一条指令地址”的管理

这能解释一个重要事实:控制流不是高级语言才有的东西。任何程序最终都要落到“下一步执行哪里”。

3. 顺序执行:最容易被忽略的依赖

顺序执行看起来最简单:

step1
step2
step3

但顺序背后常常藏着依赖关系:

打开文件
读取内容
关闭文件

这里不能随便交换顺序。你不能先读取再打开,也不能在读取前关闭。

顺序依赖常见于:

场景顺序为什么重要
初始化资源必须先创建,后使用
校验输入必须先检查,后处理
写入数据要先准备,后提交
清理任务结束后释放资源
协议必须先握手,再传输数据

很多 bug 表面是“值错了”,实际是“步骤顺序错了”。

例如:

先把任务标记为完成
再真正写入结果

如果写入失败,系统就会出现一个矛盾状态:

状态说任务完成了
结果却不存在

分析顺序控制流时,要问:

  1. 哪些步骤必须发生在前?
  2. 哪些步骤可以交换?
  3. 哪一步失败后,后续步骤是否还能执行?
  4. 状态是不是在正确的时间点更新?

4. 分支:条件如何改变路径

分支把一个流程分成多条路径。

代码块PLAINTEXT · 4 行收起展开
if condition:
  path A
else:
  path B

分支的核心问题不是“有没有 if”,而是:

条件是否完整地区分了所有可能状态?

例如处理一个数字:

n < 0
n = 0
n > 0

如果代码只处理了 n > 0n < 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,循环次数可能变得不稳定。

分析循环时,建议固定问这六个问题:

  1. 循环变量是谁?
  2. 退出条件是什么?
  3. 每轮循环后,变量是否向退出条件靠近?
  4. 循环体是否会修改影响退出条件的状态?
  5. 最小输入、空输入、最大输入会怎样?
  6. 每轮成本是多少,总共执行多少轮?

复杂度分析其实也在看循环:

循环执行次数 * 每轮成本 = 总成本

嵌套循环就是多层状态推进:

代码块PLAINTEXT · 3 行收起展开
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. 递归:用调用栈表达重复结构

递归是函数直接或间接调用自己。

它适合处理“问题可以拆成同类子问题”的场景:

代码块PLAINTEXT · 7 行收起展开
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”变成三个问题:

  1. 有哪些状态?
  2. 有哪些事件?
  3. 每个状态下,遇到每个事件应该怎么转移?

例如一个任务生命周期:

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

再规定合法转移:

当前状态事件下一个状态
PENDINGstartRUNNING
PENDINGcancelCANCELLED
RUNNINGfinishSUCCEEDED
RUNNINGfailFAILED
RUNNINGcancelCANCELLED
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 通知进程某件事发生
超时等待太久后放弃
取消外部要求任务停止
重试失败后重新尝试
回滚撤销已经做过的修改

异常路径的危险在于它会跳过普通代码。

打开文件
写入一半
发生错误
直接返回
文件没有关闭
状态没有恢复

这类问题不是算法错,而是控制流漏了。

设计异常路径时要问:

  1. 资源一定会释放吗?
  2. 状态会不会停在半更新状态?
  3. 错误会不会被吞掉?
  4. 调用者能不能知道失败原因?
  5. 重试会不会重复造成副作用?
  6. 日志或证据能不能还原当时路径?

健壮系统不是没有失败,而是失败路径也被设计过。

11.1 取消和超时不是普通失败

取消和超时很容易被粗暴地当作“失败”,但它们的语义不一样。

情况含义
普通失败操作尝试执行,但遇到错误
取消外部不再需要结果,要求尽快停止
超时调用方等不到结果,但被调用方可能仍在运行

这三个状态的后续处理不同:

  • 失败:记录原因,决定是否重试或回滚
  • 取消:尽快释放资源,不再提交无意义结果
  • 超时:调用方放弃等待,但要考虑对方可能稍后成功

如果把超时简单等同于失败,就可能出现:

调用方超时重试
原请求稍后成功
重试请求又成功
副作用发生两次

所以任何带外部副作用的流程,都要把 timeout/cancel/retry 写进状态机。

12. 资源清理:控制流必须收尾

资源包括文件、内存、锁、网络连接、事务、临时文件、设备句柄等。

资源管理的基本原则是:

谁获得资源
谁就必须保证它最终被释放或移交

常见资源流程:

Acquire
-> Use
-> Release

但真实流程可能是:

Acquire
-> Use
-> error
-> Release

所以资源释放不能只放在正常路径末尾。它要覆盖所有可能离开的路径:

离开路径是否要清理
正常返回
错误返回
异常抛出
超时取消
中途重试要,或明确移交

如果控制流路径很多,资源清理就容易漏。解决思路通常是让语言、库或结构帮你收尾,比如作用域清理、自动关闭、统一 finally/cleanup、事务边界等。

这背后的核心不是某种语法,而是一个通用约束:

每条离开路径都必须经过清理点。

13. 如何阅读一个复杂流程

遇到复杂流程时,不要急着逐行钻进去。先画一个控制流骨架。

推荐步骤:

  1. 找入口:流程从哪里开始?
  2. 找出口:有哪些正常结束和失败结束?
  3. 找状态:流程中有哪些阶段?
  4. 找事件:什么输入或事件会推动状态变化?
  5. 找循环:哪些地方会重复等待、重试、轮询?
  6. 找资源:哪些资源被打开、占用、锁住?
  7. 找异常路径:失败、超时、取消怎么走?

可以画成:

流程出口与统一清理点

如果某条路径绕过 Cleanup,那就是高风险点。

如果某个状态没有出口,那可能是卡死点。

如果某个事件在当前状态没有定义,那可能是协议漏洞。

14. 联系实际:把连接生命周期画出来

假设你在分析一个“连接偶尔关闭不了”的问题。不要一开始就猜是网络、系统、代码或者对方问题。先把连接生命周期画出来:

Created
-> Connecting
-> Connected
-> Closing
-> Closed

Connecting
-> Timeout -> Closed

Connected
-> RemoteClose -> Closing
-> LocalClose -> Closing
-> Error -> Closing

然后问:

  1. 连接现在卡在哪个状态?
  2. 进入这个状态的事件是什么?
  3. 它应该等待什么事件才能出去?
  4. 如果等不到,有没有超时?
  5. 关闭过程中资源是否一定释放?
  6. 重复 close 会发生什么?
  7. 关闭一半时又收到数据怎么办?

这套思路同样适用于:

  • 任务队列为什么卡在 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,就会出现语义冲突:

代码块JAVA · 2 行收起展开
上层已经按超时补偿或重试;
迟到成功又把旧 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
转移当前状态 + 事件 -> 下一个状态
动作转移发生时要做什么,例如写结果、释放资源、发通知
非法事件当前状态下不该接受什么事件
收尾每条离开路径是否都释放资源

然后专门检查四类边界:

  1. 正常完成后,重复收到完成事件会怎样?
  2. 正在运行时取消,资源是否释放?
  3. 超时和失败是同一个状态,还是要区分?
  4. 失败后重试,是回到 Pending,还是创建一个新任务?

这个练习会逼你把“流程大概是这样”变成可验证的状态规则。很多状态错乱、重复执行、资源泄漏,都是因为这些规则从来没有被写清楚。

15. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. 一个程序为什么进入了错误分支?
  2. 一个循环为什么多执行一轮、少执行一轮或永远停不下来?
  3. 递归为什么会栈溢出?
  4. 错误堆栈应该从哪里开始读?
  5. 一个任务为什么卡在某个状态不动?
  6. 协议为什么必须规定消息顺序,而不只是规定消息格式?
  7. 为什么超时、取消、重试这些异常路径必须单独设计?
  8. 为什么资源释放要覆盖所有返回、失败、异常路径?
  9. 如何把一段复杂流程画成状态机,再用它定位 bug?

如果你以后看到“流程卡住、状态错乱、连接关不掉、任务重复执行、资源泄漏”,第一反应应该是画出控制流和状态转换,而不是直接猜某一行代码错了。

延伸阅读