concurrency - sync

04. 并发与同步

0. 本章先解决什么问题

并发让多个任务在同一时间段内推进,但只要它们共享可变状态,就会出现危险:

两个线程同时读写同一个变量
一个线程看到另一个线程的半更新状态
多个线程互相等待锁
唤醒丢失导致永远等待

操作系统提供和支撑同步机制,用来回答:

  • 哪段代码同一时刻只能一个线程执行?
  • 一个线程改的数据,另一个线程什么时候能看见?
  • 条件不满足时线程如何睡眠,条件满足时如何被唤醒?
  • 如何限制同时访问某资源的人数?
  • 死锁如何产生和排查?

死锁等待图

这张图怎么读

死锁排查不要只看“哪个线程卡住”,要画等待图:谁持有资源,谁等待资源。等待关系形成环,就是关键证据。

1. 并发的来源

并发可能来自:

  • 多核 CPU 真正并行。
  • 单核 CPU 时间片切换。
  • 一个任务阻塞时,OS 调度另一个任务。
  • 中断打断当前执行流。
  • 异步 IO 和事件循环。

并发提升吞吐和响应性,但代价是状态顺序更复杂。

核心风险:

共享可变状态 + 不确定执行顺序

2. 竞态条件

竞态条件是结果依赖执行时序。

例子:

count = count + 1

可能拆成:

读 count
加 1
写回 count

两个线程同时执行:

T1 读到 0
T2 读到 0
T1 写回 1
T2 写回 1

结果应该是 2,实际可能是 1。

这叫丢失更新。

3. 临界区

临界区是访问共享资源并需要保护不变量的代码区域。

例如账户扣款:

检查余额
扣减余额
记录流水

这几步必须作为整体保护,否则可能出现:

余额被重复扣
流水和余额不一致

临界区保护的不是“几行代码”,而是:

共享状态的不变量。

4. 互斥锁

互斥锁保证同一时刻只有一个线程进入临界区。

lock
访问共享状态
unlock

优点:

  • 语义清楚。
  • 能保护复杂不变量。

代价:

  • 可能阻塞。
  • 可能死锁。
  • 锁粒度过大降低并发。
  • 锁竞争高时上下文切换增加。

使用锁时要问:

  1. 保护哪份共享状态?
  2. 不变量是什么?
  3. 所有访问路径都加同一把锁了吗?
  4. 持锁期间是否做慢 IO?
  5. 是否存在锁嵌套?

5. 自旋锁和阻塞锁

自旋锁:

拿不到锁时一直循环检查

适合:

  • 等待时间极短。
  • 不希望线程睡眠和唤醒。
  • 多核环境下锁很快释放。

阻塞锁:

拿不到锁时线程睡眠
锁释放后被唤醒

适合等待时间较长。

自旋浪费 CPU,阻塞有上下文切换成本。选择取决于等待时间和竞争情况。

6. 信号量

信号量维护一个许可证数量。

acquire:
如果 count > 0,count—
否则等待

release:
count++
唤醒等待者

适合限制并发访问数量:

  • 固定数量连接。
  • 固定数量设备。
  • 工作槽位。
  • 资源池。

互斥锁可以看成许可证数量为 1 的特殊情况,但信号量更强调数量控制。

7. 条件变量

条件变量用于:

条件不满足时等待
条件满足时唤醒

生产者消费者:

消费者:
lock
while queue empty:
wait
take item
unlock

生产者:
lock
put item
notify
unlock

为什么要用 while 而不是 if?

因为线程被唤醒后,条件可能又不满足:

  • 虚假唤醒。
  • 其他线程先抢走资源。
  • 多个等待者同时被唤醒。

所以醒来后要重新检查条件。

8. 原子操作

原子操作在外部看来不可被中间打断。

常见用途:

  • 计数器。
  • 状态标志。
  • 无锁结构的一部分。
  • 引用计数。

硬件可能提供 compare-and-swap 等原子指令。

但原子操作不是万能:

  • 只能直接保护简单状态。
  • 复杂不变量仍可能需要锁。
  • 高竞争下原子重试也会很贵。
  • 内存顺序语义需要理解。

9. 可见性和有序性

并发安全不仅是“同一时刻只有一个人写”。

还要看:

性质问题
原子性操作是否不可分割
可见性一个线程写入后,其他线程能否看到
有序性操作顺序是否被另一个线程按预期观察

CPU、编译器、运行时都可能优化和重排。

同步原语的作用之一,就是建立可见性和顺序关系。

10. 死锁

死锁通常需要四个条件:

条件含义
互斥资源一次只能被一个线程持有
占有并等待拿着一个资源,又等另一个
不可抢占资源不能被强行夺走
循环等待A 等 B,B 等 C,C 等 A

示意:

T1 持有 LockA,等待 LockB
T2 持有 LockB,等待 LockA

解决方向:

  • 固定加锁顺序。
  • 尽量不持锁做 IO。
  • 减少锁嵌套。
  • 使用超时。
  • 拆分资源。
  • 用队列或单线程所有权减少共享。

11. 活锁、饥饿和优先级反转

问题直觉
死锁都不动,互相等待
活锁一直在动,但没有进展
饥饿某线程长期拿不到资源
优先级反转低优先级持锁,高优先级被迫等待

并发排查不能只看线程是否活着,还要看系统是否持续推进。

CPU 很忙但任务不完成

可能是活锁、忙等、重试风暴、锁竞争。

12. 联系实际:如何排查锁等待

遇到线程卡住或吞吐低,按这个顺序看:

  1. 哪些线程在等待?
  2. 等的是锁、条件、IO 还是调度?
  3. 谁持有锁?
  4. 持锁线程在做什么?
  5. 是否持锁执行慢操作?
  6. 是否有锁顺序环?
  7. 等待时间是否集中在某把锁?
  8. 锁保护的不变量能否拆分?

如果能画出等待关系:

T1 -> waits LockB -> held by T2
T2 -> waits LockA -> held by T1

问题通常就清楚很多。

手推:条件变量为什么醒来后必须用 while 重新检查

条件变量的正确直觉不是:

被唤醒 = 条件一定成立

而是:

被唤醒 = 现在有机会重新检查条件

危险时间线:

T1 等待 queue not empty
T2 放入一个任务,notify
T1 被唤醒但还没拿到锁
T3 先拿到锁,把任务取走
T1 拿到锁,队列又空了

如果 T1 用 if:

if queue empty:
wait
take task

它醒来后可能直接取空队列。更稳的是:

while queue empty:
wait
take task

while 的意义是重新验证不变量:

我现在持有锁,并且条件真的成立。

这也能处理虚假唤醒、多个消费者竞争、notify 和调度交错等情况。

边界条件:固定锁顺序为什么能破坏死锁环

死锁需要循环等待:

T1 持有 A,等待 B
T2 持有 B,等待 A

如果规定所有线程必须按固定顺序获取锁:

先 A 后 B

那么 T2 不允许先拿 B 再等 A。等待图里就很难形成:

A -> T1 -> B -> T2 -> A

原因是所有等待边都沿着同一个资源顺序向后走,不能绕回更早的锁。

但这条规则也有边界:

情况风险
有路径绕过顺序少数代码破坏全局约定,仍可能死锁
持锁期间调用外部回调回调内部可能拿未知锁
持锁做 IO 或远程调用锁持有时间不可控
动态资源没有稳定排序两个对象锁的顺序可能因输入而变

所以固定锁顺序不是口号,要落实成可检查规则:

代码块JAVA · 2 行收起展开
所有路径都遵守;
锁顺序能从资源 id 或层级稳定推导;

持锁期间不进入不可控代码。

排障卡:把线程状态翻译成等待图

并发问题最难的地方是症状很像:死锁、锁竞争、IO 阻塞、条件变量等待、调度饥饿,都可能表现为“程序卡住”。排查时可以把每个线程状态翻译成资源关系。

看到的现象先问的问题可能结论
多个线程 BLOCKED / waiting lock谁持有这把锁?锁竞争或死锁
线程 WAITING 条件变量等待条件是什么?谁负责 notify?条件不满足或唤醒丢失
持锁线程在 IO为什么持锁做慢操作?锁粒度过大
CPU 很高但吞吐低是否忙等、重试、频繁抢锁?活锁或竞争风暴
只有一个任务长期拿不到锁锁是否公平?优先级是否被反转?饥饿或优先级反转

画等待图时,使用两类节点就够:

线程节点:T1, T2, T3
资源节点:LockA, LockB, QueueNotEmpty, Slot

边的含义要分清:

T1 -> LockB 表示 T1 正在等待 LockB
LockA -> T1 表示 LockA 当前被 T1 持有

如果出现闭环:

T1 -> LockB -> T2 -> LockA -> T1

优先按死锁处理。如果没有闭环,但大量线程都指向同一把锁,就更像锁竞争;如果大量线程都等待某个条件,则要追踪生产者是否还能推进。

设计卡:减少共享状态比加更多锁更根本

锁是修补共享可变状态的一种方式,但更稳定的设计通常会减少共享本身:

  1. 能局部变量解决的,不放共享对象。
  2. 能不可变数据传递的,不共享可变数据。
  3. 能消息队列串行化的,不让多个线程同时改同一状态。
  4. 能分片的计数器、缓存、队列,不集中到一把全局锁。
  5. 持锁期间只做必要的内存状态修改,把 IO、等待、回调移出去。

这就是并发设计的核心品味:先减少必须同步的东西,再选择同步工具。

失败指纹表:并发同步症状到等待关系

现象更可能的机制最小证据下一步
所有线程都不前进死锁或资源循环等待线程栈、锁持有者、等待对象画等待图,找环,检查加锁顺序
一个线程长期占锁临界区过大或持锁期间阻塞 IO锁持有时间、线程栈、IO 调用位置缩短临界区,把阻塞操作移出锁
CPU 高但吞吐低自旋、活锁或频繁重试CPU 栈、重试次数、CAS 失败率加退避、降低竞争、改队列化
少数任务一直得不到执行饥饿或优先级反转调度顺序、等待时长、优先级、锁竞争调整公平性,减少优先级反转窗口
偶尔读到旧值可见性或发布顺序错误写入时间、读取时间、同步边界、版本号建立 happens-before,避免未同步共享
结果偶尔少一次更新复合操作不是原子read/compute/write 时间线、旧值新值用原子操作、锁或消息串行化保护状态转移

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

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

  1. 什么是竞态条件?
  2. 锁保护的到底是什么?
  3. 自旋和阻塞的成本差异是什么?
  4. 信号量适合限制什么资源?
  5. 条件变量为什么醒来后还要重新检查条件?
  6. 原子操作适合什么,不适合什么?
  7. 原子性、可见性、有序性分别解决什么?
  8. 死锁如何产生,如何画等待图排查?
  9. CPU 高但吞吐低时,为什么可能是锁竞争或活锁?

并发的核心不是“多开线程”,而是管理共享状态、等待关系和进展条件。

延伸阅读