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 x
T2: read x
T2: compute x + 1
T2: write x

可能的交错:

代码块PLAINTEXT · 4 行收起展开
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

代码块PLAINTEXT · 4 行收起展开
T1: 检查余额 >= 80
T2: 检查余额 >= 80
T1: 扣款 80
T2: 扣款 80

如果检查和扣款不是一个不可分割的整体,余额就可能被扣成负数。

竞态常见于:

场景共享状态
计数器递增数字变量
余额扣减账户状态
文件写入文件偏移和内容
任务领取队列和任务状态
缓存更新缓存值和版本
连接关闭连接状态和资源

判断一个问题是不是竞态,可以问:

  1. 是否有多个执行流?
  2. 是否访问同一份可变状态?
  3. 至少一个执行流会写这份状态?
  4. 最终结果是否依赖它们谁先谁后?

如果答案都接近“是”,就要按并发问题处理。

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 这几步就应该被看成一个整体。

评价锁是否合理,要问:

  1. 它保护的是哪份共享状态?
  2. 它维护的具体不变量是什么?
  3. 所有访问这份状态的路径都用了同一套保护吗?
  4. 锁的范围是否刚好覆盖不变量可能被破坏的过程?
  5. 锁持有期间是否做了慢操作?
  6. 有没有多个锁互相等待的风险?

锁太小,保护不住不变量。锁太大,会降低并发度,还可能放大阻塞。

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

假设有个任务系统偶尔出现“同一个任务被处理两次”。

不要只看其中一次日志。按并发问题拆:

  1. 共享状态是什么?

任务状态: pending / running / done

  1. 多个执行流是谁?

worker A
worker B

  1. 危险操作是什么?

检查任务是否 pending
把任务改成 running
开始处理任务

  1. 这几步是不是原子的?

如果不是,就可能出现:

Worker A: 看到 pending
Worker B: 看到 pending
Worker A: 改成 running
Worker B: 也改成 running

  1. 有无可见性或提交延迟?

一个 worker 改了状态,另一个 worker 是否马上能看到?

  1. 重复处理是否有幂等保护?

即使领取重复了,最终写结果会不会重复产生副作用?

最后,你可以把修复方向落到几个选择:

  • 领取任务时用原子状态转移。
  • 让任务只被一个队列消费者拥有。
  • 给任务处理加唯一执行记录。
  • 让最终写入具备幂等性。
  • 记录版本号,提交时检查版本是否仍匹配。

这就是并发问题的实际分析方式:不是“线程不安全”四个字结束,而是指出哪份状态、哪条路径、哪种交错破坏了哪个不变量。

反例:只把写入放进锁,不代表检查也安全

很多并发修复看起来加了锁,实际没有保护住不变量。

危险版本:

if task.status == PENDING:
lock
task.status = RUNNING
unlock
process(task)

两个执行流的交错可能是:

代码块PLAINTEXT · 6 行收起展开
T1: 读取 task.status == PENDING
T2: 读取 task.status == PENDING
T1: 加锁,写 RUNNING,解锁
T2: 加锁,写 RUNNING,解锁
T1: process
T2: process

这里锁确实保护了“写入那一瞬间”,但没有保护真正的不变量:

检查 PENDING 和改成 RUNNING 必须是一个不可分割的整体。

更合理的临界区应该包住检查和转移:

代码块PLAINTEXT · 8 行收起展开
lock
  if task.status == PENDING:
   task.status = RUNNING
   owner = current_worker
   claimed = true
  else:
   claimed = false
unlock

if claimed:
process(task)

判断锁是否正确,不要问“有没有锁”,要问:

它保护的不变量是什么?
不变量可能被破坏的整个窗口是否都在保护范围内?
所有路径是否使用同一把锁或同一套原子规则?

手推:丢失更新为什么不是“少执行了一次”

初始值:

x = 0

两个执行流都执行:

x = x + 1

把它拆成机器更接近的三步:

read x
compute x + 1
write x

一种交错:

代码块PLAINTEXT · 6 行收起展开
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 领取。

先写危险版本:

代码块JAVA · 2 行收起展开
select task where status = PENDING limit 1
update task set status = RUNNING

如果两个 worker 同时 select,就可能选到同一个任务。正确设计要把“检查 + 修改”变成一个原子状态转移:

只有当 status 仍然是 PENDING 时,
才能把它改成 RUNNING,
并记录 owner 和版本。

状态表可以这样看:

当前状态事件条件下一个状态
PENDINGworker claimversion 匹配,未被领取RUNNING(owner)
RUNNINGworker successowner 匹配DONE
RUNNINGtimeout超过租约PENDINGFAILED
DONEclaim不允许DONE

这里的关键不是某个数据库语法,而是并发不变量:

同一时刻,一个任务最多只有一个有效 owner。

所有实现都必须维护这个不变量。

排障卡:偶发并发 bug 的最小证据

偶发问题不能靠“多试几次”解决,要把不可见的交错变成可见证据。

要记录什么为什么
操作的唯一标识把不同请求/任务的日志分开
线程或执行单元标识看出两个动作是否真的并发
读到的旧值和写入的新值找第一次状态偏离
加锁/解锁或进入/离开临界区时间判断是否存在竞态、死锁、长时间持锁
单调序号而不只是真实时钟避免时钟误差掩盖因果关系

练习:构造两个执行流同时修改同一个计数器的时间线,写出 read -> compute -> write 三步。只要两个执行流都读到旧值,你就能解释“明明执行了两次,结果只增加一次”的原因。

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

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

  1. 并发和并行有什么区别?
  2. 为什么 x = x + 1 在并发下可能出错?
  3. 什么是竞态条件,如何判断一个 bug 是否是竞态?
  4. 原子性、可见性、有序性分别在解决什么问题?
  5. 锁保护的到底是代码,还是共享状态的不变量?
  6. 死锁、活锁、饥饿有什么区别?
  7. 为什么超时不等于失败,重试可能带来重复副作用?
  8. 为什么日志时间顺序不能直接当作因果顺序?
  9. 如何从共享状态、执行流、交错路径三个角度分析偶发 bug?

如果你以后看到“偶尔错、复现不了、重复执行、状态被覆盖、任务卡住、锁等待、超时重试异常”,先不要把它当普通顺序代码看。把多条时间线画出来,找共享状态和同步边,问题会清晰很多。

延伸阅读