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
优点:
- 语义清楚。
- 能保护复杂不变量。
代价:
- 可能阻塞。
- 可能死锁。
- 锁粒度过大降低并发。
- 锁竞争高时上下文切换增加。
使用锁时要问:
- 保护哪份共享状态?
- 不变量是什么?
- 所有访问路径都加同一把锁了吗?
- 持锁期间是否做慢 IO?
- 是否存在锁嵌套?
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. 联系实际:如何排查锁等待
遇到线程卡住或吞吐低,按这个顺序看:
- 哪些线程在等待?
- 等的是锁、条件、IO 还是调度?
- 谁持有锁?
- 持锁线程在做什么?
- 是否持锁执行慢操作?
- 是否有锁顺序环?
- 等待时间是否集中在某把锁?
- 锁保护的不变量能否拆分?
如果能画出等待关系:
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 或远程调用 | 锁持有时间不可控 |
| 动态资源没有稳定排序 | 两个对象锁的顺序可能因输入而变 |
所以固定锁顺序不是口号,要落实成可检查规则:
代码块收起展开
所有路径都遵守;
锁顺序能从资源 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
优先按死锁处理。如果没有闭环,但大量线程都指向同一把锁,就更像锁竞争;如果大量线程都等待某个条件,则要追踪生产者是否还能推进。
设计卡:减少共享状态比加更多锁更根本
锁是修补共享可变状态的一种方式,但更稳定的设计通常会减少共享本身:
- 能局部变量解决的,不放共享对象。
- 能不可变数据传递的,不共享可变数据。
- 能消息队列串行化的,不让多个线程同时改同一状态。
- 能分片的计数器、缓存、队列,不集中到一把全局锁。
- 持锁期间只做必要的内存状态修改,把 IO、等待、回调移出去。
这就是并发设计的核心品味:先减少必须同步的东西,再选择同步工具。
失败指纹表:并发同步症状到等待关系
| 现象 | 更可能的机制 | 最小证据 | 下一步 |
|---|---|---|---|
| 所有线程都不前进 | 死锁或资源循环等待 | 线程栈、锁持有者、等待对象 | 画等待图,找环,检查加锁顺序 |
| 一个线程长期占锁 | 临界区过大或持锁期间阻塞 IO | 锁持有时间、线程栈、IO 调用位置 | 缩短临界区,把阻塞操作移出锁 |
| CPU 高但吞吐低 | 自旋、活锁或频繁重试 | CPU 栈、重试次数、CAS 失败率 | 加退避、降低竞争、改队列化 |
| 少数任务一直得不到执行 | 饥饿或优先级反转 | 调度顺序、等待时长、优先级、锁竞争 | 调整公平性,减少优先级反转窗口 |
| 偶尔读到旧值 | 可见性或发布顺序错误 | 写入时间、读取时间、同步边界、版本号 | 建立 happens-before,避免未同步共享 |
| 结果偶尔少一次更新 | 复合操作不是原子 | read/compute/write 时间线、旧值新值 | 用原子操作、锁或消息串行化保护状态转移 |
13. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 什么是竞态条件?
- 锁保护的到底是什么?
- 自旋和阻塞的成本差异是什么?
- 信号量适合限制什么资源?
- 条件变量为什么醒来后还要重新检查条件?
- 原子操作适合什么,不适合什么?
- 原子性、可见性、有序性分别解决什么?
- 死锁如何产生,如何画等待图排查?
- CPU 高但吞吐低时,为什么可能是锁竞争或活锁?
并发的核心不是“多开线程”,而是管理共享状态、等待关系和进展条件。