写在前面:这是本系列的第十七篇。
理论上说,我们可以用并发编程 API(线程、互斥锁、条件变量、信号量)实现几乎一切实用的并发程序。然而,并发程序"难写"也是出了名的。时至今日,我们还没有一种完美、便捷的技术能帮助我们绝对安全地构建多核软件系统。
并发 Bug 那种"若隐若现"、"难以复现"的特性,导致它们经常逃脱测试人员的掌控。本讲内容:我们将直面并发编程中最致命的错误模式------数据竞争、死锁、原子性违反和顺序违反。

课前回顾:线程库与同步互斥
- 线程库 (人和物理世界):
spawn,join - 互斥 (厕所门):
lock(lk),unlock(lk) - 同步 (等待条件发生):
wait(cv, mutex); broadcast(cv) - 信号量 (袋子和球):
P(sem), V(sem)
并发编程之所以困难,是因为人类天生就是"Sequential Creature"(顺序生物)。我们习惯了单线程式的思考,在写并发代码时,大脑极其容易产生"指令是按代码顺序执行"的幻觉。试过 RTS(即时战略)游戏里的多线微操吗?并发编程比那难一万倍。
致命的并发 Bugs:Killed by a Machine
如果并发写错了,不仅是程序崩溃,甚至可能伤人性命。
Therac-25 医疗事故 (1985-1987)
这是软件工程史上最著名、也是最惨痛的并发 Bug,导致至少 6 名患者因过量辐射死亡。
Therac-25 是一台放射治疗机,它有两种模式:
Electron (Low): 电子束低剂量模式,直接照射。XRay (High): X 射线高剂量模式,必须在物理光束前面加上一个金属靶(Beam Flattener),把高能电子打碎成 X 射线,否则会把人直接烧穿。
它的核心安全逻辑其实非常简单:
c
assert mode in [Electron(Low), XRay(High)]
assert beam_flattener in [On, Off]
// 绝对不能在没有降能靶的情况下,开启高剂量照射!
assert not (mode == XRay(High) and beam_flattener == Off)
悲剧是如何发生的?("软件定义"的代价)
- 操作员一开始选错了,选了
X-Ray (High)模式。 - 机器收到指令,开始缓慢地移动巨大的金属降能靶(
beam flattener),这大约需要 8 秒钟。 - 就在这 8 秒内,操作员发现按错了,迅速 把模式切换到了
Electron (Low)。 - 接着,操作员凭借单身二十年的手速,再次迅速切换 回了
X-Ray (High)模式,并按下了发射键! - 此时,由于并发的竞态条件,软件里的状态被覆盖错乱了:机器以为降能靶已经就位,但实际上沉重的物理降能靶根本没来得及移过去!
- 触发了
Malfunction 54报错。遗憾的是,习惯了系统乱报错的操作员,下意识地按下了Continue......
Root Cause 分析:
在纯粹的顺序程序中,状态的修改是立即生效 的。但在真实的物理/并发世界中,状态的改变(移动降能靶)需要时间 。操作员手速过快,触发了非确定性的状态交织。
在更早的产品中,这种互锁是由纯物理电路强制保证的,绝不可能绕过。但进入了"软件定义"时代,如果没有严谨的并发控制,底线安全就成了空中楼阁。
数据竞争 (Data Race)
并发的最底层魔鬼:我们如何才能避免两个线程"同时"读写?
- 定义: 不同的线程同时 访问同一内存 ,且至少有一个是写操作。
- 发生数据竞争时,两个内存访问在"赛跑",谁跑赢了谁就先执行。
但这并不是最可怕的!
最可怕的是,在现代 CPU 的 Weak Memory Model (弱内存模型) 下,"跑赢"并没有想象中那么简单。它甚至允许不同核心上的观察者,看到完全相反的比赛结果!
从 C11 标准开始,官方明确规定:Data Race is Undefined Behavior (数据竞争是未定义行为)。一旦发生,编译器怎么优化、CPU 怎么乱序,后果自负。
那些年我们写过的 Data Race 代码
所谓 Ad-hoc synchronization(比如用一个 while(!flag) 死循环当锁),本质上都是 Data Race。
只有通过标准的锁机制,我们才能保证 Serializability(可串行化):让可能竞争的内存访问要么互斥,要么同步,强行退回顺序执行。
新手最常见的两种数据竞争变种:
- Case 1: 上错了锁
c
void T_1() { spin_lock(&A); sum++; spin_unlock(&A); }
void T_2() { spin_lock(&B); sum++; spin_unlock(&B); }
(注:保护同一个共享变量 sum,却用了两把不同的锁,防了个寂寞)
- Case 2: 忘记上锁
c
void T_1() { spin_lock(&A); sum++; spin_unlock(&A); }
void T_2() { sum++; }
为什么不要笑?因为在真实系统几百万行代码里,这些变量可能藏在堆、栈、框架底层、甚至是你看不见的汇编指令里。要完全消灭并发 Bug,必须尽可能减少共享状态的访问。
死锁 (Deadlock)
维基百科:A deadlock is a state in which each member of a group is waiting for another member, including itself, to take action.
(死锁:一组实体中的每一个都在等待另一个实体采取行动,导致所有人都被永久卡住。)
AA-Deadlock (我锁我自己)
c
lock(&lk);
...
// 极度复杂的嵌套后,或者在中断处理程序里
lock(&lk); // 试图再次获取同一把锁,当场死锁!
你觉得自己不会犯这种错?在真实的 5~6 层函数调用嵌套、隐藏的控制流里,你早就忘了最外层有没有加锁了。
ABBA-Deadlock (哲学家就餐)
就是我们之前遇到的哲学家吃饭问题:
c
void T_philosopher() {
P(&avail[lhs]);
P(&avail[rhs]);
...
}
- T1: 拿到 0,等 1
- T2: 拿到 1,等 2
- T3: 拿到 2,等 3
- T4: 拿到 3,等 4
- T5: 拿到 4,等 0
- 形成了一个完美的闭环,所有人都在等右手边的人放下叉子。
死锁产生的四个必要条件 (Coffman Conditions, 1971)
把锁看成袋子里的球:
- Mutual-exclusion (互斥): 一个口袋一个球,得到球才能继续。
- Wait-for (请求并保持): 得到球的人还在贪婪地想要更多的球,且不肯放下手里的球。
- No-preemption (不可剥夺): 不能强行抢走别人手里的球。
- Circular-chain (环路等待): 形成 A \\rightarrow B \\rightarrow C \\rightarrow A 的循环等待关系。
只要打破任何一个条件,死锁就不会发生!
在实际系统中如何避免死锁?
理论上说得好听("合理规划资源分配"),但在动辄千万行的内核代码中,该怎么做?
- Lock Ordering (全局锁排序):
强行给所有的锁编个号,强制所有人必须按照从小到大的顺序去获取锁。这样由于永远有一个线程能拿到编号最大的锁,环路就被打破了。
但这在工程上依然极其痛苦。
Linux 内核黑客指南(Unreliable Guide to Locking)里明确吐槽:书本上教你全局排序,但实践中根本行不通(This approach doesn't scale)。当我新建一把锁时,我怎么可能弄清楚它在内核那 5000 把锁的层级里到底该排第几?
终极最佳实践:
最好的锁是被完全封装(Encapsulated)的。绝不暴露在头文件里,绝不带着锁去调用其他文件里复杂的函数。只要你在这个文件内部把锁加上、立刻用完解开,使用者甚至不需要知道你用了锁,死锁自然也就无从谈起。
动态检测:LockDep
为了对抗不靠谱的程序员,现代操作系统引入了动态分析工具,比如 Linux 内核的 LockDep。
它的核心思想是:每次 acquire/release 都在后台打个日志。在运行时动态构建一张"锁依赖图",一旦发现既有 A \\rightarrow B 的申请顺序,又有 B \\rightarrow A 的申请顺序,立刻警告可能存在死锁(哪怕当时还没死锁)。
原子性违反与顺序违反
并不是全加上了一把大锁,就能天下太平。
并发控制的机制完全是 "后果自负" 的:
- 互斥锁
lock/unlock用于保证原子性。忘记上锁 \\rightarrow 原子性违反 (Atomicity Violation, AV)。 - 条件变量
wait/signal用于保证先后顺序。忘记同步 \\rightarrow 顺序违反 (Order Violation, OV)。
顶级实证研究 (ASPLOS'08, Most Influential Paper Award):
在收集的 105 个真实系统(MySQL, Apache, Mozilla 等)并发 Bug 中,97% 的非死锁并发 Bug 都是原子性或顺序错误!
再次印证:人类真的是彻头彻尾的 Sequential Creature。
原子性违反 (Atomicity Violation, ABA 问题)
即便你对每一个单独的操作都上了锁(消除了数据竞争),依然可能被别人"强势插入"。
- 比如暗黑破坏神(Diablo I)里利用并发漏洞复制物品。
- 又或者像 Therac-25 那样:第一步
设置移动靶子 (A)和第三步发射射线 (A)之间,被用户的第二步切换低频模式 (B)强势插入了。
TOCTTOU 漏洞:操作系统的状态也是共享状态
(Time-of-check to time-of-use)。这在安全领域臭名昭著。你在检查文件权限(Check)时是合法的,但在你真正打开文件(Use)的微小间隙,黑客并发地把那个文件替换成了系统密码本的软链接。
顺序违反 (Order Violation, BA 问题)
事件没有按照预定的顺序发生。比如经典的 Concurrent Use-after-Free :
你以为线程 1 会先用完指针,线程 2 再去 free 它。结果线程 2 跑太快了,先把指针给 free 了,随后线程 1 再去解引用,直接段错误崩溃。
真实世界的魔鬼:GhostRace (Spicy 🌶️)
这是发表在顶会 USENIX Sec'24 的超硬核研究。
假设程序员写出了天衣无缝的代码:正确的互斥、正确的同步,逻辑无懈可击。
c
void T_A() {
lock(&obj->lock);
free(obj->something);
obj->something = NULL;
unlock(&obj->lock);
}
void T_B() {
lock(&obj->lock);
if (obj->something) {
// 利用投机执行 (Speculative Execution) 改变缓存状态
}
unlock(&obj->lock);
}
在严格的理论层面,T_B 绝不可能在 obj->something 被 free 后还能进入 if。
但是!现代 CPU 有投机执行(预测执行)的功能。CPU 为了快,在等锁的时候,它"猜"自己能拿到锁,于是提前执行了 if 里的代码,并把数据加载到了 CPU 缓存里。
哪怕后来 CPU 发现自己猜错了,把执行结果撤销了,但缓存里的痕迹留下来了(Spectre 漏洞的变种)。黑客借此实现了完美的侧信道攻击。
面对这样深不可测的底层世界,除了保持敬畏,唯有不断扎实底层的基本功,才能在风暴来临时看清魔鬼的真容。