本篇位置 并发部分的收尾。前四篇给工具,这一篇讲工具怎么伤到自己
运行环境 容器里的 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 年总结出四条,必须同时满足才会死锁:

  1. 互斥:资源一次只能被一个人占。(这就是锁的定义,去不掉。)
  2. 持有并等待:拿着一个,还要另一个。
  3. 不可抢占:拿不到就不能硬抢别人手里的。
  4. 循环等待:存在一个环——甲等乙,乙等甲。

破坏任意一条,死锁就不成立。 这四条同时给出了四种预防办法:

破坏哪一条 办法 代价
循环等待 给所有锁编号,永远按号从小到大拿 要维护一个全局顺序,跨模块时很难
持有并等待 一次性把所有锁全拿到,拿不全就一个都不拿 并行度下降;而且你得提前知道要哪些锁
不可抢占 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 月,火星探路者号着陆后不久开始反复自动重启

原因是这样的:

  1. 一个低优先级任务(气象数据采集)拿到了一把锁。
  2. 一个高优先级任务(信息总线管理)要这把锁,只能等。
  3. 这时候一堆中优先级任务(通信)跑了起来。按调度策略,它们优先于那个低优先级任务
  4. 于是低优先级任务一直拿不到 CPU,锁一直放不掉
  5. 高优先级任务超时,看门狗判定系统故障,重启

高优先级任务被中优先级任务间接堵死了——而它们之间根本没有任何锁的关系。

这就是优先级反转(priority inversion)。它是第 6 篇(调度策略)和这一篇(锁)撞在一起的产物:单看调度器没问题,单看锁也没问题。

标准解法是优先级继承一个低优先级任务如果正拿着高优先级任务要的锁,就临时把它的优先级提到和等待者一样高,让它赶紧跑完把锁放掉。

探路者号的 VxWorks 系统本来就有这个功能,只是当时是关着的。JPL 远程把它打开,问题解决。

回到那个银行:拿着保险柜钥匙的是个新来的实习生,行长在等这把钥匙。 而一堆比实习生资深的柜员不停地插队办自己的业务——按规矩他们本来就该排在实习生前面。于是实习生始终排不上号,钥匙就一直交不出来,行长就一直等着。而资深柜员和行长之间,没有任何人在抢同一样东西。

⚠️ 这个故事的真正教训不是"记得开优先级继承",是:两个各自正确的机制组合起来会产生谁都没预料到的行为。 并发的难,绝大部分在这里。

六、代价与取舍

这一整部分(第 16–21 篇)换来了什么: 真正利用多核的能力。在单核性能已经不怎么涨的今天,这是唯一的出路。

它花了什么:

  • 正确性变得极难验证。 bug 不确定、难复现、测试覆盖不了。
  • 心智负担。 你得同时在脑子里跑好几条执行流。

它放弃了什么: 顺序执行这个假设。 而人的直觉是彻底按顺序的。

现在的应对方向大致是三条:

  • 工具:ThreadSanitizer(能自动发现数据竞争,实验四会用)、死锁检测器、形式化验证。
  • 换模型:不共享内存,改用消息传递(Go 的 channel、Erlang 的 actor)。⭐ “不要用共享内存来通信,要用通信来共享内存”——把问题从根上换掉,和第 12 篇分页的思路是同一种。
  • 换语言:Rust 的所有权系统在编译期就拒绝掉数据竞争。它是唯一在编译期解决、运行时零开销的路(第 9 篇说过同样的话)。

七、小结

  • 实测:两个线程两把锁,只是拿的顺序不同,一个永远卡死,一个正常跑完。⭐ 两段各自正确的代码碰在一起就错了——锁放弃了组合性。
  • 死锁的四个必要条件:互斥、持有并等待、不可抢占、循环等待。破坏任意一条即可,实践中最常用的是给锁编号、按序获取。⚠️ 难点在"你不知道你调用的库里加了哪些锁"。
  • 数据库选择检测加回滚,因为它有事务、恢复廉价。⚠️ 所以你的事务会被无理由回滚,必须写重试。
  • 死锁只占真实并发 bug 的三分之一。 另外三分之二是原子性违反(TOCTOU:检查和使用之间被插了一脚)和顺序违反(假设 A 先于 B 却没写出来),它们不挂死,只是偶尔算错,因此更难发现
  • 优先级反转:低优先级任务拿着锁却抢不到 CPU,把高优先级任务间接堵死。1997 年火星探路者号因此反复重启。⭐ 两个各自正确的机制,组合起来会产生谁都没预料到的行为。
  • 三条出路:工具换模型(消息传递)、换语言(Rust 在编译期解决)。

思考题

  1. 第二节表里"一次性拿全部锁"能破坏"持有并等待"。写一个具体场景,说明为什么它在真实代码里很难做到。
  2. trylock 拿不到就全放掉重来,可能造成活锁——两个线程一直在"拿、放、重试"的循环里同步撞车。加一点什么就能大幅降低活锁概率?(提示:网络协议里有现成的答案。)
  3. 第四节那个原子性违反的例子,如果 proc_info 改成 const 或者是不可变对象,bug 还在吗?这说明什么样的设计天然对并发友好?
  4. 银行那个比方里,“优先级继承"对应什么?(提示:甲拿着钥匙,行长在等。经理该怎么做?)

延伸