| 本篇位置 | 并发部分的收尾。前四篇给工具,这一篇讲工具怎么伤到自己 |
| 运行环境 | 容器里的 Linux,gcc |
一、先看一个真的死锁
两个线程,两把锁,唯一的区别是拿锁的顺序:
void *t1(void *_) {
pthread_mutex_lock(&A); usleep(50000);
pthread_mutex_lock(&B);
pthread_mutex_unlock(&B); pthread_mutex_unlock(&A);
done1 = 1; return NULL;
}
void *t2(void *_) {
/* 不排序时反着拿;排序后统一先 A 后 B */
pthread_mutex_lock(ORDERED ? &A : &B); usleep(50000);
pthread_mutex_lock(ORDERED ? &B : &A);
pthread_mutex_unlock(ORDERED ? &B : &A); pthread_mutex_unlock(ORDERED ? &A : &B);
done2 = 1; return NULL;
}
一个 A→B,一个 B→A:线程1 ★卡住了,线程2 ★卡住了
(两秒后仍未完成,判定死锁;强制退出)
统一按 A→B 的顺序拿:线程1 跑完了,线程2 跑完了
同样的两把锁,同样的两段临界区。只是拿的顺序不同,一个永远卡死,一个正常跑完。
发生了什么:线程 1 拿着 A 等 B,线程 2 拿着 B 等 A。两个人各自攥着对方要的东西,谁也不肯先放。
打个比方
第 16 篇那个银行,两个出纳。
甲要办一笔业务,需要保险柜钥匙和账本。他先拿了钥匙,转身去找账本。 乙同时也要办,他先拿了账本,转身去找钥匙。
两个人就这么站着,直到下班。
⭐ 注意一件事:这两个人各自都没做错任何事。 甲的流程是对的,乙的流程也是对的。错误只存在于"两段各自正确的代码碰在一起"这件事本身。 这就是第 17 篇说的——锁放弃了组合性。
二、死锁成立需要四个条件
Coffman 在 1971 年总结出四条,必须同时满足才会死锁:
- 互斥:资源一次只能被一个人占。(这就是锁的定义,去不掉。)
- 持有并等待:拿着一个,还要另一个。
- 不可抢占:拿不到就不能硬抢别人手里的。
- 循环等待:存在一个环——甲等乙,乙等甲。
⭐ 破坏任意一条,死锁就不成立。 这四条同时给出了四种预防办法:
| 破坏哪一条 | 办法 | 代价 |
|---|---|---|
| 循环等待 | 给所有锁编号,永远按号从小到大拿 | 要维护一个全局顺序,跨模块时很难 |
| 持有并等待 | 一次性把所有锁全拿到,拿不全就一个都不拿 | 并行度下降;而且你得提前知道要哪些锁 |
| 不可抢占 | 用 trylock,拿不到就把手里的全放掉重来 |
可能活锁(大家一起放、一起重试、一起再撞) |
| 互斥 | 不用锁(无锁结构、不共享) | 第 18 篇讲过:难写难验 |
实践中第一条用得最多,因为它最容易检查,而且不影响性能。上面那个演示就是它:统一按 A→B 的顺序,死锁立刻消失。
⚠️ 这条规矩在真实项目里的难点不在"排序",在**“你根本不知道自己拿了哪些锁”**——你调用的那个库函数里面也在加锁。
三、检测和恢复:干脆不预防
还有一条路:允许死锁发生,然后检测出来、恢复。
数据库就是这么干的。事务之间不可能提前排好锁的顺序(哪个事务锁哪一行是运行时才知道的),所以数据库定期检查"等待图"里有没有环——有环就挑一个事务回滚掉,让别人继续。
⭐ 这个选择的前提是恢复是廉价的:数据库有事务,回滚是现成能力。而普通程序没有"回滚"这回事,所以只能预防。
⚠️ 这一条对写业务代码的人有直接影响:你的数据库事务会被无理由地回滚(死锁牺牲品),错误码就摆在那儿。能重试的事务必须写重试逻辑——这不是防御性编程,这是数据库明确要求的。
四、⭐ 更常见的是那三分之二
死锁很出名,但它不是最常见的。
有一项经常被引用的研究(Lu 等人,2008)分析了 MySQL、Apache、Mozilla、OpenOffice 里的真实并发 bug,结论是:死锁只占约三分之一,另外三分之二是"非死锁"的并发 bug。
而且非死锁 bug 更难对付——它们不挂死,只是偶尔算错。
类型一:原子性违反
你以为连着的两步是一个整体,其实中间能插进别人。
if (thd->proc_info) { /* 检查 */
fputs(thd->proc_info, log); /* 使用 */
}
检查和使用之间,另一个线程可能把 proc_info 设成了 NULL。于是你检查完了才崩溃。
⭐ 这是 TOCTOU(检查时到使用时)的一种。第 26 号那类文件系统竞态是同一个形状,第 30 篇的安全漏洞也是。“检查过了"和"现在还是那样"是两回事。
修法很简单:把检查和使用一起放进临界区。 难的是发现它——这两行看起来天经地义。
类型二:顺序违反
你以为 A 一定在 B 之前发生,其实不一定。
/* 线程 1 */ /* 线程 2 */
mThread = create_thread(); state = mThread->state; /* mThread 可能还是 NULL */
线程 2 假设线程 1 已经初始化好了。大多数时候确实如此——所以测试全过。偶尔机器负载高一点,顺序就反了。
修法是用条件变量或信号量把这个顺序显式表达出来,而不是靠"应该会先执行吧”。
⚠️ 第 16 篇那句话在这里再说一遍:你写的顺序、编译器生成的顺序、CPU 执行的顺序,是三件事。 跨线程的顺序假设,必须用同步原语写出来。
五、优先级反转:调度和锁撞在一起
1997 年 7 月,火星探路者号着陆后不久开始反复自动重启。
原因是这样的:
- 一个低优先级任务(气象数据采集)拿到了一把锁。
- 一个高优先级任务(信息总线管理)要这把锁,只能等。
- 这时候一堆中优先级任务(通信)跑了起来。按调度策略,它们优先于那个低优先级任务。
- 于是低优先级任务一直拿不到 CPU,锁一直放不掉。
- 高优先级任务超时,看门狗判定系统故障,重启。
⭐ 高优先级任务被中优先级任务间接堵死了——而它们之间根本没有任何锁的关系。
这就是优先级反转(priority inversion)。它是第 6 篇(调度策略)和这一篇(锁)撞在一起的产物:单看调度器没问题,单看锁也没问题。
标准解法是优先级继承:一个低优先级任务如果正拿着高优先级任务要的锁,就临时把它的优先级提到和等待者一样高,让它赶紧跑完把锁放掉。
探路者号的 VxWorks 系统本来就有这个功能,只是当时是关着的。JPL 远程把它打开,问题解决。
回到那个银行:拿着保险柜钥匙的是个新来的实习生,行长在等这把钥匙。 而一堆比实习生资深的柜员不停地插队办自己的业务——按规矩他们本来就该排在实习生前面。于是实习生始终排不上号,钥匙就一直交不出来,行长就一直等着。而资深柜员和行长之间,没有任何人在抢同一样东西。
⚠️ 这个故事的真正教训不是"记得开优先级继承",是:两个各自正确的机制组合起来会产生谁都没预料到的行为。 并发的难,绝大部分在这里。
六、代价与取舍
这一整部分(第 16–21 篇)换来了什么: 真正利用多核的能力。在单核性能已经不怎么涨的今天,这是唯一的出路。
它花了什么:
- 正确性变得极难验证。 bug 不确定、难复现、测试覆盖不了。
- 心智负担。 你得同时在脑子里跑好几条执行流。
它放弃了什么: 顺序执行这个假设。 而人的直觉是彻底按顺序的。
现在的应对方向大致是三条:
- 工具:ThreadSanitizer(能自动发现数据竞争,实验四会用)、死锁检测器、形式化验证。
- 换模型:不共享内存,改用消息传递(Go 的 channel、Erlang 的 actor)。⭐ “不要用共享内存来通信,要用通信来共享内存”——把问题从根上换掉,和第 12 篇分页的思路是同一种。
- 换语言:Rust 的所有权系统在编译期就拒绝掉数据竞争。它是唯一在编译期解决、运行时零开销的路(第 9 篇说过同样的话)。
七、小结
- 实测:两个线程两把锁,只是拿的顺序不同,一个永远卡死,一个正常跑完。⭐ 两段各自正确的代码碰在一起就错了——锁放弃了组合性。
- 死锁的四个必要条件:互斥、持有并等待、不可抢占、循环等待。破坏任意一条即可,实践中最常用的是给锁编号、按序获取。⚠️ 难点在"你不知道你调用的库里加了哪些锁"。
- 数据库选择检测加回滚,因为它有事务、恢复廉价。⚠️ 所以你的事务会被无理由回滚,必须写重试。
- ⭐ 死锁只占真实并发 bug 的三分之一。 另外三分之二是原子性违反(TOCTOU:检查和使用之间被插了一脚)和顺序违反(假设 A 先于 B 却没写出来),它们不挂死,只是偶尔算错,因此更难发现。
- 优先级反转:低优先级任务拿着锁却抢不到 CPU,把高优先级任务间接堵死。1997 年火星探路者号因此反复重启。⭐ 两个各自正确的机制,组合起来会产生谁都没预料到的行为。
- 三条出路:工具、换模型(消息传递)、换语言(Rust 在编译期解决)。
思考题
- 第二节表里"一次性拿全部锁"能破坏"持有并等待"。写一个具体场景,说明为什么它在真实代码里很难做到。
trylock拿不到就全放掉重来,可能造成活锁——两个线程一直在"拿、放、重试"的循环里同步撞车。加一点什么就能大幅降低活锁概率?(提示:网络协议里有现成的答案。)- 第四节那个原子性违反的例子,如果
proc_info改成const或者是不可变对象,bug 还在吗?这说明什么样的设计天然对并发友好? - 银行那个比方里,“优先级继承"对应什么?(提示:甲拿着钥匙,行长在等。经理该怎么做?)