concurrency - time - order
08. 并发、时间与顺序
0. 本章先解决什么问题
单线程程序里,很多事情看起来很自然:
第一步发生
第二步发生
第三步发生
但只要系统里有多个执行流,事情马上变复杂:
线程 A 执行到一半
线程 B 插进来
线程 A 继续
线程 C 又修改了同一份状态
并发难,不是因为语法难,而是因为你必须同时思考:
- 多条时间线如何交错。
- 哪些操作必须不可分割。
- 一个执行流改过的数据,另一个执行流什么时候能看见。
- 源代码顺序和真实观察顺序是否一致。
- 锁、队列、事务、事件循环到底在保护什么。
- 超时和时间戳为什么不能直接等同于因果。
本章要建立一个通用直觉:
并发问题 = 共享状态 + 多条执行流 + 不确定顺序
只要这三个因素同时出现,你就要警惕竞态、死锁、可见性、有序性和时序 bug。
这张图展示了并发最反直觉的地方:每一步在单独线程里都“合法”,但交错起来会破坏不变量。并发学习的核心不是背锁名,而是看清共享状态、操作是否原子、时间线如何交错。
这张图怎么读
读并发时间线时,按四个问题看:
| 问题 | 说明 |
|---|---|
| 共享状态是什么 | 哪个变量、文件、任务、连接、缓存被多个执行流访问 |
| 操作能否拆开 | 看似一行的操作是否包含 read / compute / write 多步 |
| 交错窗口在哪里 | 哪两步之间可能被别的执行流插入 |
| 哪个不变量被破坏 | 余额不能负、任务只能领取一次、计数不能丢 |
一条并发 bug 时间线通常长这样:
T1 读取旧状态
T2 读取同一个旧状态
T1 基于旧状态写入新状态
T2 也基于旧状态写入新状态
所以你排查并发问题时,不能只看“每个线程内部逻辑是否正确”。要看多个线程的操作能否组成非法整体。
1. 并发和并行
并发和并行经常被混用,但它们不是一回事。
| 概念 | 含义 | 例子 |
|---|---|---|
| 并发 | 多个任务在同一时间段内推进 | 单核 CPU 在多个任务间切换 |
| 并行 | 多个任务在同一时刻真正执行 | 多核 CPU 同时跑多个任务 |
单核也能并发:
- 时间片 1:任务 A 运行
- 时间片 2:任务 B 运行
- 时间片 3:任务 A 继续
多核可以并行:
- 核心 1:任务 A
- 核心 2:任务 B
- 核心 3:任务 C
并发关注的是结构:
系统同时管理多个进行中的任务。
并行关注的是执行:
多个任务是否真的同时使用计算资源。
这个区别很重要。一个系统可能并发但不并行,也可能既并发又并行。
2. 为什么并发会出现不确定性
单线程程序里,一段代码执行顺序通常可见:
x = 1
y = x + 1
print(y)
并发程序里,每个执行流内部可能都没错,但它们之间的交错顺序不确定。
T1: read x
T1: compute x + 1
T1: write xT2: read x
T2: compute x + 1
T2: write x可能的交错:
代码块收起展开
T1: read x = 0
T2: read x = 0
T1: write 1
T2: write 1两个线程都执行了“加一”,最后结果却是 1。
原因是:
x = x + 1
看起来像一个动作,底层通常至少包含:
读取 x
计算 x + 1
写回 x
中间任何一步都可能被别的执行流插入。
3. 竞态条件:结果依赖时序
竞态条件是:
程序结果依赖多个执行流的具体执行时序。
如果时序 A 得到正确结果,时序 B 得到错误结果,这就是典型竞态。
例子:
余额 = 100
代码块收起展开
T1: 检查余额 >= 80
T2: 检查余额 >= 80
T1: 扣款 80
T2: 扣款 80如果检查和扣款不是一个不可分割的整体,余额就可能被扣成负数。
竞态常见于:
| 场景 | 共享状态 |
|---|---|
| 计数器递增 | 数字变量 |
| 余额扣减 | 账户状态 |
| 文件写入 | 文件偏移和内容 |
| 任务领取 | 队列和任务状态 |
| 缓存更新 | 缓存值和版本 |
| 连接关闭 | 连接状态和资源 |
判断一个问题是不是竞态,可以问:
- 是否有多个执行流?
- 是否访问同一份可变状态?
- 至少一个执行流会写这份状态?
- 最终结果是否依赖它们谁先谁后?
如果答案都接近“是”,就要按并发问题处理。
4. 原子性:中间状态不能被观察
原子性的意思是:
一个操作在外部看来要么没发生,要么完整发生。
不是所有看起来简单的操作都是原子的。
| 操作 | 可能不是原子的原因 |
|---|---|
x = x + 1 | 读、算、写是多个步骤 |
| 检查后插入 | 检查和插入之间可能被插队 |
| 读取后删除 | 读到的数据可能已经被别人改了 |
| 写文件 | 可能只写入一部分 |
| 更新两个字段 | 一个字段更新后,另一个字段还没更新 |
原子性可以来自:
| 来源 | 直觉 |
|---|---|
| 硬件原子指令 | CPU 保证某个读改写不可分割 |
| 锁 | 同一时间只允许一个执行流进入临界区 |
| 事务 | 多个操作作为整体提交或回滚 |
| 单线程事件循环 | 同一时刻只执行一个回调 |
| 不共享可变状态 | 根本不让多个执行流改同一份状态 |
原子性不是为了让代码“看起来严谨”,而是为了保护不变量。
例如:
库存数量 >= 0
任务不能被两个 worker 同时领取
文件头和文件体必须匹配
连接不能既是 closed 又是 active
当一个操作会临时破坏不变量,就必须保证这个中间过程不会被其他执行流观察或干扰。
5. 可见性:写了不等于别人马上看见
并发里一个反直觉点是:
一个执行流写了变量
另一个执行流不一定立刻看见
原因包括:
- CPU cache。
- 寄存器暂存。
- 写缓冲。
- 编译器优化。
- 运行时调度。
- 多核之间缓存同步延迟。
简化示意:
核心 A cache: flag = true
主内存: flag = false
核心 B cache: flag = false
如果没有同步规则,核心 B 可能继续看到旧值。
所以并发程序不能只凭源代码判断:
线程 A 写了 flag = true
线程 B 就一定能读到 true
你要问的是:
这两个执行流之间有没有建立可见性关系?
常见建立可见性的方式包括锁、原子变量、消息队列、条件变量、通道、事务提交、进程间通信协议等。具体机制不同,但共同目标是:
让一个执行流的写入,对另一个执行流可观察。
6. 有序性:源代码顺序不一定等于观察顺序
为了性能,编译器和 CPU 可能重排操作。只要单线程观察结果不变,它们就可能认为这种重排是安全的。
单线程里:
a = 1
b = 2
如果后续逻辑看不出差异,底层执行顺序可能被调整。
但并发里,另一个线程可能观察到:
b 已经变成 2
a 还没变成 1
这就是有序性问题。
更抽象地说:
程序顺序
不一定等于
其他执行流观察到的顺序
并发同步要解决的核心之一,就是建立某些“必须先发生”的关系。
你可以先不用背复杂术语,只要记住这个直觉:
如果 A 的结果必须被 B 看见,
就必须有一种同步机制把 A 和 B 连接起来。
否则“我明明先写了 A,再写了 B”只是在单线程视角下成立。
7. 临界区:共享可变状态的危险区域
临界区是访问共享可变状态、并且需要保护不变量的代码区域。
lock
读取共享状态
检查不变量
修改共享状态
写回结果
unlock
锁的目标不是“保护几行代码”,而是保护某个共享状态的不变量。
例如:
队列中的任务只能被领取一次。
那么检查任务是否可领取、标记为已领取、交给 worker 这几步就应该被看成一个整体。
评价锁是否合理,要问:
- 它保护的是哪份共享状态?
- 它维护的具体不变量是什么?
- 所有访问这份状态的路径都用了同一套保护吗?
- 锁的范围是否刚好覆盖不变量可能被破坏的过程?
- 锁持有期间是否做了慢操作?
- 有没有多个锁互相等待的风险?
锁太小,保护不住不变量。锁太大,会降低并发度,还可能放大阻塞。
8. 死锁:大家都在等别人先放手
死锁是并发系统里非常经典的问题。
简化例子:
- T1:拿到锁 A,等待锁 B
- T2:拿到锁 B,等待锁 A
结果:
T1 等 T2
T2 等 T1
谁也无法继续
死锁通常需要几个条件同时出现:
| 条件 | 直觉 |
|---|---|
| 互斥 | 资源同一时刻只能被一个执行流持有 |
| 持有并等待 | 拿着一个资源,同时等待另一个 |
| 不可抢占 | 资源不能被强行夺走 |
| 循环等待 | A 等 B,B 等 C,C 又等 A |
破坏其中一个条件,就能降低死锁风险。
常用策略:
- 规定所有锁必须按固定顺序获取。
- 避免持有锁时等待外部慢操作。
- 使用超时获取锁。
- 尽量缩小锁范围。
- 用队列、消息、单所有者模型减少共享。
死锁排查时,不要只看“哪个线程卡住了”。要画等待图:
T1 -> waits for LockB -> held by T2
T2 -> waits for LockA -> held by T1
图里出现环,就是关键证据。
9. 活锁、饥饿和优先级问题
并发问题不只有死锁。
| 问题 | 直觉 | 例子 |
|---|---|---|
| 死锁 | 都不动了 | 互相等待锁 |
| 活锁 | 一直在动,但没有进展 | 两边都不断退让又重试 |
| 饥饿 | 某个执行流长期拿不到资源 | 高优先级任务一直插队 |
| 优先级反转 | 低优先级持有资源,高优先级被迫等待 | 调度策略和锁交互 |
判断系统是否健康,不只看“线程是否还在运行”,还要看:
系统有没有持续向目标推进?
这叫 progress。
一个任务疯狂重试、日志不断刷新、CPU 很忙,但状态没有推进,也可能是并发问题。
10. 时间不可靠:时钟不是因果
真实系统里的时间很有用,但不能被过度信任。
| 问题 | 解释 |
|---|---|
| 时钟回拨 | 系统时间可能被校准回去 |
| 时钟漂移 | 不同机器的时间不完全一致 |
| 定时器不精确 | 调度、负载、休眠会影响触发 |
| 超时不等于失败 | 对方可能成功处理,只是响应迟到 |
| 先后不等于因果 | A 时间戳更早,不代表 A 导致 B |
例如:
- 客户端 10:00:00 发送请求
- 服务端 09:59:59 收到请求
这不一定是穿越时间,而可能是两台机器时钟不同步。
再比如:
请求超时
这只说明:
在规定时间内没有收到响应。
它不说明:
对方一定没有处理。
所以超时后重试必须考虑重复副作用。一次扣款、一次提交、一次写入,如果请求已经在对端成功,只是响应迟到,重试就可能造成重复操作。
11. 顺序:时间顺序、观察顺序、因果顺序
并发系统里至少有三种顺序:
| 顺序 | 含义 |
|---|---|
| 时间顺序 | 时间戳上谁更早 |
| 观察顺序 | 某个观察者看到谁先发生 |
| 因果顺序 | A 的结果是否影响 B |
这三者不能随便等同。
例子:
- A:写入配置 version=2
- B:读取配置 version=2 后开始任务
如果 B 真的读到了 A 写入的值,那么 A 和 B 有因果关系。
但如果只是日志里 A 的时间戳早于 B,不一定代表 B 是因为 A 才发生。
并发调试中,一个常见错误是:
日志 A 在日志 B 前面
所以 A 一定导致 B
这不严谨。日志可能来自不同线程、不同机器、不同缓冲区,刷新顺序也可能变化。
更可靠的证据是:
- B 的输入里包含 A 产生的版本号。
- B 的 trace/span 是 A 派生出来的。
- B 持有 A 释放后的锁或消息。
- B 读取到 A 提交后的状态。
也就是要找因果链,而不只是时间线。
12. happens-before 的直觉
很多系统和语言会定义类似 happens-before 的规则。你可以先把它理解为:
如果 A happens-before B
那么 B 应该能看见 A 在这个关系之前完成的结果。
它不是单纯的“时间上更早”,而是“同步规则保证的先后”。
例如:
线程 A:
写入 data
释放锁
线程 B:
获取同一把锁
读取 data
获取锁和释放锁之间建立了某种同步关系,所以 B 读取 data 的行为有了更可靠的语义。
你不需要在这一章背具体语言规则,但要形成判断:
两个执行流之间有没有一个被系统承认的同步边?
如果没有,就不要假设可见性和顺序自然成立。
12.1 真实时间戳不等于因果证据
并发排查里很容易误用时间戳:
日志 A 时间更早
日志 B 时间更晚
所以 A 一定导致 B
这不一定成立。原因包括:
| 原因 | 影响 |
|---|---|
| 多机时钟不完全一致 | 不同机器日志时间不能直接比较 |
| 日志缓冲 | 打印时间和 flush 时间不同 |
| 并发交错 | 时间接近的事件顺序不稳定 |
| 异步队列 | 提交事件和处理事件分离 |
| 重试 | 后来的请求可能对应更早的业务意图 |
更可靠的因果证据通常是:
同一个 request_id / task_id
单调递增的版本号
明确的状态转移日志
消息偏移量或序号
锁/事务/队列的同步边界
时间戳可以帮助排序,但不能单独证明因果。
13. 减少并发问题的设计思路
并发不一定靠“加更多锁”解决。更好的问题是:
能不能减少共享?
常见设计思路:
| 思路 | 核心 |
|---|---|
| 不可变数据 | 创建后不修改,只共享只读值 |
| 单所有者 | 某份状态只归一个执行流修改 |
| 消息传递 | 执行流之间发消息,而不是共享变量 |
| 队列串行化 | 把危险操作排队顺序处理 |
| 分片 | 不同数据由不同锁或执行流管理 |
| 事务 | 多步修改作为一个提交单元 |
| 幂等 | 重复执行不会产生额外副作用 |
一个很实用的原则:
能不共享,就不共享。
必须共享,就明确谁能读、谁能写、何时可见。
并发安全的核心不是写出复杂同步代码,而是让状态归属和状态转换足够清楚。
14. 联系实际:怎么排查一个偶发并发 bug
假设有个任务系统偶尔出现“同一个任务被处理两次”。
不要只看其中一次日志。按并发问题拆:
- 共享状态是什么?
任务状态: pending / running / done
- 多个执行流是谁?
worker A
worker B
- 危险操作是什么?
检查任务是否 pending
把任务改成 running
开始处理任务
- 这几步是不是原子的?
如果不是,就可能出现:
Worker A: 看到 pending
Worker B: 看到 pending
Worker A: 改成 running
Worker B: 也改成 running
- 有无可见性或提交延迟?
一个 worker 改了状态,另一个 worker 是否马上能看到?
- 重复处理是否有幂等保护?
即使领取重复了,最终写结果会不会重复产生副作用?
最后,你可以把修复方向落到几个选择:
- 领取任务时用原子状态转移。
- 让任务只被一个队列消费者拥有。
- 给任务处理加唯一执行记录。
- 让最终写入具备幂等性。
- 记录版本号,提交时检查版本是否仍匹配。
这就是并发问题的实际分析方式:不是“线程不安全”四个字结束,而是指出哪份状态、哪条路径、哪种交错破坏了哪个不变量。
反例:只把写入放进锁,不代表检查也安全
很多并发修复看起来加了锁,实际没有保护住不变量。
危险版本:
if task.status == PENDING:
lock
task.status = RUNNING
unlock
process(task)
两个执行流的交错可能是:
代码块收起展开
T1: 读取 task.status == PENDING
T2: 读取 task.status == PENDING
T1: 加锁,写 RUNNING,解锁
T2: 加锁,写 RUNNING,解锁
T1: process
T2: process这里锁确实保护了“写入那一瞬间”,但没有保护真正的不变量:
检查 PENDING 和改成 RUNNING 必须是一个不可分割的整体。
更合理的临界区应该包住检查和转移:
代码块收起展开
lock
if task.status == PENDING:
task.status = RUNNING
owner = current_worker
claimed = true
else:
claimed = false
unlockif claimed:
process(task)
判断锁是否正确,不要问“有没有锁”,要问:
它保护的不变量是什么?
不变量可能被破坏的整个窗口是否都在保护范围内?
所有路径是否使用同一把锁或同一套原子规则?
手推:丢失更新为什么不是“少执行了一次”
初始值:
x = 0
两个执行流都执行:
x = x + 1
把它拆成机器更接近的三步:
read x
compute x + 1
write x
一种交错:
代码块收起展开
T1: read x -> 0
T2: read x -> 0
T1: compute 1
T2: compute 1
T1: write 1
T2: write 1两个执行流都完整执行了,没有哪一个“没运行”。最终结果仍然是 1,是因为:
T2 的写入基于旧快照,覆盖了 T1 的写入结果。
这叫丢失更新。它的关键不是执行次数,而是读到的版本是否已经过期。
更一般的修复思路有三类:
| 思路 | 本质 |
|---|---|
| 加锁 | 让 read-compute-write 变成临界区 |
| 原子读改写 | 由底层提供不可分割的更新 |
| 版本检查 | 写回时确认自己读到的旧版本仍然有效 |
这个手推能帮你识别很多类似问题:
检查库存 -> 扣库存
读取余额 -> 扣款
读取配置版本 -> 写新配置
读取任务状态 -> 领取任务
只要“读到旧状态,再基于旧状态写回”,就要考虑丢失更新。
问题:设计一个不会重复领取任务的队列
学完本章,可以解决一个非常典型的问题:
多个 worker 同时从任务表领取任务。
要求:同一个任务最多只能被一个 worker 领取。
先写危险版本:
代码块收起展开
select task where status = PENDING limit 1
update task set status = RUNNING如果两个 worker 同时 select,就可能选到同一个任务。正确设计要把“检查 + 修改”变成一个原子状态转移:
只有当 status 仍然是 PENDING 时,
才能把它改成 RUNNING,
并记录 owner 和版本。
状态表可以这样看:
| 当前状态 | 事件 | 条件 | 下一个状态 |
|---|---|---|---|
PENDING | worker claim | version 匹配,未被领取 | RUNNING(owner) |
RUNNING | worker success | owner 匹配 | DONE |
RUNNING | timeout | 超过租约 | PENDING 或 FAILED |
DONE | claim | 不允许 | DONE |
这里的关键不是某个数据库语法,而是并发不变量:
同一时刻,一个任务最多只有一个有效 owner。
所有实现都必须维护这个不变量。
排障卡:偶发并发 bug 的最小证据
偶发问题不能靠“多试几次”解决,要把不可见的交错变成可见证据。
| 要记录什么 | 为什么 |
|---|---|
| 操作的唯一标识 | 把不同请求/任务的日志分开 |
| 线程或执行单元标识 | 看出两个动作是否真的并发 |
| 读到的旧值和写入的新值 | 找第一次状态偏离 |
| 加锁/解锁或进入/离开临界区时间 | 判断是否存在竞态、死锁、长时间持锁 |
| 单调序号而不只是真实时钟 | 避免时钟误差掩盖因果关系 |
练习:构造两个执行流同时修改同一个计数器的时间线,写出 read -> compute -> write 三步。只要两个执行流都读到旧值,你就能解释“明明执行了两次,结果只增加一次”的原因。
15. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 并发和并行有什么区别?
- 为什么
x = x + 1在并发下可能出错? - 什么是竞态条件,如何判断一个 bug 是否是竞态?
- 原子性、可见性、有序性分别在解决什么问题?
- 锁保护的到底是代码,还是共享状态的不变量?
- 死锁、活锁、饥饿有什么区别?
- 为什么超时不等于失败,重试可能带来重复副作用?
- 为什么日志时间顺序不能直接当作因果顺序?
- 如何从共享状态、执行流、交错路径三个角度分析偶发 bug?
如果你以后看到“偶尔错、复现不了、重复执行、状态被覆盖、任务卡住、锁等待、超时重试异常”,先不要把它当普通顺序代码看。把多条时间线画出来,找共享状态和同步边,问题会清晰很多。