第17讲 · 并发 Bug 和应对:死锁、数据竞争与 Harness Engineering
2026 南京大学《操作系统原理》第十七讲 · 课堂实录重构
| 主讲 | 蒋炎岩(Yanyan Jiang) |
| 视频 | https://www.bilibili.com/video/BV1Jg96BjE68/ |
| 课程主页 | https://jyywiki.cn/OS/2026/ |
| 本讲讲义 | https://jyywiki.cn/OS/2026/lect17.md |
| 讲义 / 幻灯片作者 | 蒋炎岩,https://jyywiki.cn/ |
| 讲义 / 幻灯片许可 | CC BY-NC 4.0 |
| 图片来源 | 书中插图为课堂演示与讲义渲染图;演示类截图为视频画面 |
对应讲次:第17讲(南京大学 操作系统原理)
制作模型:deepseek-flash(coding-agent / pi;据 ~/.pi/agent/settings.json)
编者说明:本书由课堂录像的语音字幕重构而成,术语、章节骨架与参考链接已按课程官方讲义(
lect17.md)校准。讲师的口语风格、类比与案例被尽量保留;编辑性的补充一律以「编者说明」或「编者拓展」标出。
引子:并发编程已经「讲完」了
(参考时间: 00:00)
并发机制已经全部介绍完:spawn/join(入门到放弃)、lock/unlock(互斥)、wait/broadcast(同步 1)、P/V(同步 2)——「理论上说你们已经可以用这些机制实现各种各样的并发程序了」。
但并发编程依然很难,因为:
- 人类本质上是 sequential creature:我们的训练过程都在物理世界中完成,「脑子在脑壳里飞不出来」,视角非常小;
- 我们学编程时一开始就是顺序执行,这太自然了,以至于一旦变成并发,就容易产生「程序是顺序执行」的幻觉;
- 而「理解所有并发程序的行为」是 NP-Hard 的(要枚举指数多个可能)。
所以今天讲 Learning from mistakes——人类擅长归类总结。
📷 并发编程的机制全景:spawn/join、lock/unlock、wait/broadcast、P/V
回到原视频 (00:01:00)
一、死锁(Deadlock)
A deadlock is a state in which each member of a group is waiting for another member, including itself, to take action.
「实际中也是可能发生的」——讲师展示了去年 11 月同学投稿的一张真实照片:两辆公交车头对头连在一起,谁也动不了(循环等待)。
1.1 AA-Deadlock:同一线程锁同一把锁两次
(参考时间: 00:04)
void T_1() {
mutex_lock(&A);
printf("acquired A\n");
mutex_lock(&A); // 同一个线程再次 acquire 同一把锁
printf("acquired A again\n"); // 永远到不了这里
// ...
}
pthread_mutex 不可重入(没有「锁计数器」),所以第二次 mutex_lock 永久阻塞。程序卡住了(而且你只看到一条日志)。
「看起来很傻,你觉得自己不会犯这错误?不,你会犯的!」真实系统里有多层函数调用(递归)、隐藏的控制流(callback、信号、中断)——比如你在 lock(A) 之后按了 Ctrl-C,信号 handler 里为了保护并发数据结构又 lock(A),就形成了 AA 死锁。「一旦程序开始并发、开始异步,控制流你就不可能完全想清楚了。」
编者说明:讲师特别提醒大家观察 AI 写代码的过程——「AI 在向你输出信息」:库函数的使用、编码的 best practice、用到的命令行工具……「如果你来做,你会不会这样做?你是不是能做得比 AI 好?」当然,你也可以直接打断它、骂它(它会说 you are absolutely right)。
📷 AA-Deadlock:同一线程二次 lock 同一把锁,程序卡死
回到原视频 (00:06:00)
1.2 ABBA-Deadlock
(参考时间: 00:10)
void T_philosopher() {
mutex_lock(&avail[lhs]);
mutex_lock(&avail[rhs]); // 所有人先拿左手,再等右手 → 循环等待
}
T1 持有 A 等 B,T2 持有 B 等 A——循环等待。「做过 lab 就知道,拆锁时还是很容易写出死锁的;真实代码中的惨剧更是不计其数。」
📷 ABBA-Deadlock:T1 持 A 等 B,T2 持 B 等 A
回到原视频 (00:11:00)
1.3 死锁产生的四个必要条件
(参考时间: 00:12)
经典论文 System deadlocks(1971)——把锁理解成「桌上有一把钥匙」:
- Mutual-exclusion(互斥):一把钥匙只能被一个人拿到,拿到才能继续;
- Wait-for(持有并等待):拿到钥匙的人还想要更多的钥匙;
- No-preemption(不可抢占):不能抢别人的钥匙;
- Circular-chain(循环等待):形成循环等待。
「必要条件」的意思:打破任何一个,就不会发生死锁。
📷 死锁的四个必要条件(System deadlocks, 1971)
回到原视频 (00:13:00)
编者拓展|「打破」每一条意味着什么:讲师批评了百度百科「站着说话不腰疼」的描述,并逐条解析: - 打破互斥:颠覆锁的定义——例如引入
send/receive与一个T_waiter通信(这正是分布式系统 master-worker 的结构); - 打破持有并等待:一把大锁保平安(性能低),或 Transactional Memory(极难实现); - 打破不可抢占:要能回滚持有锁线程执行过的操作——又是 transaction memory; - 打破循环等待:Lock Ordering(看起来最实用)。
顺便:讲师拿「百度百科的死锁词条」和分词器里最长中文 token 举例(「微信公众号天天中彩票」「给主人留下些什么吧」……)吐槽中文语料的「天崩开局」。
📷 打破四个必要条件的代价:互斥→send/receive,持有等待→大锁/事务内存
回到原视频 (00:20:00)
1.4 Lock Ordering:实际可行的方案
(参考时间: 00:24)
给所有锁编号,严格按照从小到大的顺序获得锁。
📷 Lock Ordering:给所有锁编号,严格从小到大获取
回到原视频 (00:24:30)
证明(草图):任意时刻系统里的锁是有限的,所以总有一个线程持有编号最大的锁——它总是可以继续运行(不必再等任何别的锁),最终会释放所有锁。「这个证明可以扩展到 lock group(总是由一个线程获得组号最大的锁)。」
编者拓展|现实中的 lock ordering:Linux 内核
mm/rmap.c的文件头就写明了获取锁的顺序。Unreliable Guide to Locking 则吐槽:「教科书说只要按同一顺序加锁就不会死锁;但实践会告诉你这不可扩展——当我创建一个新锁时,我不了解整个内核,无法知道它该放在 5000 把锁的层级里的哪个位置。」最好的锁是被封装的:永远不在头文件里暴露、也永远不围绕「非平凡函数调用」持锁。【LockDoc(EuroSys'19)】统计发现:只有 53% 有文档记录的锁,其访问是真正带着正确的锁的——「文档和实现不一致」。「今天不会再发生了——有大上下文的大模型能一次把所有相关的地方都修好。」
📷 Linux 内核 mm/rmap.c 的锁顺序文档
回到原视频 (00:27:30)
二、软件工程的本质:Harness Engineering
(参考时间: 00:29)
讲师把流行的 Harness Engineering(驾驭工程)「反过来写」:软件工程的本质就是 Harness Engineering。核心原则:做最坏的假设,做最防御性的编程——「Wes 代码永远是错的」,「除非你竭尽所能证明它是对的,我才能相信它 somehow 是对的」。
具体实践:
- C 的
goto release:函数里所有资源(buffer、锁)都在release处统一释放检查——「return 是非常危险的,因为栈帧没了、资源还留着」; - RAII(C++/Rust 的核心范式):在代码块里定义局部对象,构造函数加锁、析构函数解锁——无论你用
return还是别的方式离开作用域,都会保证解锁; - Lock Ordering 规定:项目必须明确锁的获取顺序。
但还要做更坏的假设:没有任何程序员是信得过的。于是:
每次
acquire/release都打印一条日志,然后分析:「如果任何线程既有 A → B 又有 B → A,直接报错。」
LD_PRELOAD=./locktrace.so ./a.out | python3 check.py
lockdep:用 LD_PRELOAD 拦截 pthread_mutex_lock/unlock,在运行时构建锁的依赖图(邻接矩阵)——每次加锁时记录「已持有锁 → 新获取锁」的有向边,加边前检查是否形成环,发现环就报告潜在 ABBA 死锁。
📷 lockdep:用 LD_PRELOAD 拦截 lock/unlock,维护锁依赖图并查环
回到原视频 (00:38:30)
📷 lockdep 提前发现哲学家就餐的潜在 ABBA,而正常运行可能「看起来没问题」
回到原视频 (00:41:30)
编者说明:「这就是麻烦:ABBA 死锁可能 99.99999% 的运行都是先 A 后 B,但某条极罕见的路径(比如
Ctrl-C)里先 B 后 A,且这段代码只占 0.001% 的执行时间。你反复测试都碰不上——但当你有 1 亿用户时,总有一个人会触发它。」
三、不上锁不就不会死锁了吗?→ 数据竞争
(参考时间: 00:42)
数据竞争(Data Race):不同线程同时访问同一内存,且至少有一个是写。
- 为什么「至少有一个是写」?如果两个线程都只读,它们读到的值一定相同;一旦有写,就会「赛跑」,谁快谁慢导致非确定性结果(乃至宽松内存模型的怪行为)。
- C/C++ 里 data race 是 undefined behavior——「用就是你错」。它可能把你的内存 corrupt、让 PC 跳到别处、执行任意代码……「理论上如果你机器上接了核弹发射器,你可以把核弹都发射出去」。
- Java Memory Model 试图「定义」race 的可能行为(给出最小的保底,避免最糟糕的安全漏洞)。
你以为你不会犯?实际上你的 bug 几乎都是这两种情况的变种:
// Case 1:上错了锁
void T_1() { mutex_lock(&A); sum++; mutex_unlock(&A); }
void T_2() { mutex_lock(&B); sum++; mutex_unlock(&B); } // 看起来互斥了,其实没有
// Case 2:忘记上锁
void T_1() { mutex_lock(&A); sum++; mutex_unlock(&A); }
void T_2() { sum++; } // 某条罕见路径忘了锁
编者拓展|数据竞争最深的坑:「内存」可以是地址空间里的任何内存(全局、堆、栈);「访问」可以是任何代码——包括别人写的库函数,甚至一条
ret指令。讲师讲了一个他埋过的「最高光 bug」:多处理器上把一个线程从一个 CPU 移到另一个 CPU 时,x86 在中断返回时会借用这个线程的栈(
iret会从栈上 pop 出 PC 等),而这条指令执行完之前,线程的数据结构已经被挪到另一个 CPU 了——于是形成了「栈与栈」的 data race。这个问题无法用锁解决(iret之前不能持锁),最终促使人发明了 Read-Copy-Update(RCU) 机制。「Linux 内核大概花了十年把很多锁迁移到 RCU。」
3.1 用工具检测数据竞争
(参考时间: 00:53)
happens-before race 的检测思路:不仅记录所有 lock/unlock,还记录所有 read/write;把每个线程的事件排成全序,用「unlock happens-before 下一次 lock」建立线程间的边——如果在图里能找到一对「write-read」或「write-write」没有 happens-before 关系,就找到了 data race。
这就是 ThreadSanitizer(TSAN):-fsanitize=thread。讲师现场让 AI 编译带 race detector 的程序(AI 甚至尝试关 ASLR、用 QEMU user mode 绕过树莓派不支持 TSAN 的问题),并说:
「它是一个真正的专家,对这些东西了解比我还好。它会带着你一步步解决问题。」
📷 TSAN:用 happens-before 检测数据竞争
回到原视频 (00:56:00)
编者说明:TSAN 也不是万能的——触发 race 依然可能需要特定的线程调度。
四、伤人性命的并发 Bug:Therac-25
(参考时间: 01:01)
Therac-25 事故(1985–1987):一台放射治疗仪,有一个事件驱动的并发 bug,导致至少 6 人死亡。
- 它有两个模式:Electron(低剂量,用于定位/拍片) 和 X-Ray(高剂量,真正灼烧肿瘤);
- X-Ray 模式需要一个 beam flattener(反射镜/均整器) 移动到射线源下方——而这个移动是异步的、需要约 8 秒;
- Bug:在 Electron 模式选了 X-Ray 后,flattener 开始移动;迅速切回 Electron 再切回 X-Ray——此时系统报警 Malfunction 54,而操作员下意识按下 Continue——于是在 flattener 还没到位时就开始照射,患者接受了过量辐射。
📷 Therac-25 模拟器:异步的 beam flattener 移动 + 快速切换模式
回到原视频 (01:04:00)
📷 Therac-25 模拟器:ELECTRON 模式、Flattener RETRACTED(异步移动中)
回到原视频 (01:06:30)
编者拓展|「软件定义」的悲剧:讲师用一句话概括原因——「我们的状态没有被完整地建模在软件系统里」。软件是物理世界过程在信息世界里的投影,而这个投影是有损的:程序里只有「flattener 的状态」和「照射状态」几个变量,但 flattener 到底在哪里,没有被完整考虑。
更关键的是:在更早的产品 Therac-20 中,这个 assert 是由「电路互锁(interlock)」强制实现的——直接停机(需要手工重启),所以是安全的。一旦把所有逻辑都改由软件实现,问题就来了。
这甚至不是 Therac-25 最后一个杀人 bug:修复后还有一个——「如果操作员在计数器溢出的准确瞬间发出命令,机器会跳过设置某些 beam 附件」——「又是一个按下
Ctrl-C时的那种 race condition」。编者说明:讲师借此引向「软件定义时代的安全问题」:自动驾驶、具身智能的「底线安全」越来越难——「AI 到底该不该主动刹车呢?」
五、都上锁了,怎么还有 bug?Atomicity / Order Violation
(参考时间: 01:09)
即使你给每一次共享内存访问都上锁(消除 data race),依然没有解决所有并发问题。根本原因是:我们太容易做「顺序执行」的假设——函数返回天然带同步、函数自带「all or nothing」的原子性(这是我们对函数的 expectation)。
Therac-25 也是这样:如果模式切换瞬间完成就没有问题;但模式切换是异步的、在未来才生效的,而我们形成了「立即生效」的肌肉记忆。再加上所有并发控制机制都是「后果自负」的(忘锁、同步没搞好都是你错),于是有两类并发 bug:
5.1 原子性违反(Atomicity Violation, AV)
「ABA」:代码被别人「强势插入」。 即便分别上锁(消除 data race),依然是 AV——比如:
- Diablo I 里复制物品的例子;
- Therac-25 中「移动 Mirror + 设置状态」;
- TOCTTOU(Time-Of-Check-To-Time-Of-Use):
操作系统的状态也是共享状态! 经典例子是
sendmail:它以 root 运行,会先检查用户的 mailbox 是不是符号链接,若不是就写邮件进去。攻击者可以在检查与打开之间,把自己的 mailbox 换成一个指向/etc/passwd的符号链接——于是 root 程序就把邮件内容(一行合法的用户记录)写进了/etc/passwd,攻破系统。「理论上说无法防御——你没有系统调用能原子地『检查是不是符号链接并打开』,只能改程序架构(比如不在 root 权限下运行)。」
📷 TOCTTOU:检查与使用之间的时间窗口让攻击者得手
回到原视频 (01:19:00)
5.2 顺序违反(Order Violation, OV)
「BA」:事件未按预定顺序发生。 例子:
- concurrent use-after-free:
free必须等到所有可能的use都结束(用一个信号量 P 3 次);如果你放的时机不对(放完还访问),就是 OV; - 甚至没有 UAF 时,推测执行(speculative execution) 也会带来 GhostRace:CPU 会把分支两条路径都执行、再抹掉预测错误的那条,但投机执行会留下 cache footprint 等侧信道,可能泄露信息。
5.3 实证研究:97% 的非死锁并发 bug 都是 AV/OV
(参考时间: 01:14)
Learning from mistakes(ASPLOS'08, Most Influential Paper Award) 收集了 105 个真实系统的并发 bug(MySQL、Apache、Mozilla、OpenOffice),结论:
97% 的非死锁并发 bug 都是原子性或顺序错误。
📷 ASPLOS'08 实证研究:97% 的非死锁并发 bug 是原子性/顺序错误
回到原视频 (01:14:30)
「人类的确是 sequential creature。」
编者拓展|Threads cannot be implemented as a library:这篇论文认为「线程」作为一种库机制是后果自负的,很难用对。讲师的态度是:锁是可以把程序写对的,但你很容易搬起石头砸到自己的脚——所以未来应该用更高级的语言/框架来描述「你想做什么」,让编译器/运行时保证生成的代码不会有并发问题(Rust 的所有权、RAII、managed runtime……)。
六、让你的程序「可审计」:Informal Semantics
(参考时间: 01:25)
最后,讲师给了一个很有未来感的想法:
我们完全可以把一个程序的完整执行对齐到时间轴上——因为程序的执行本质就是指令;每一次函数调用、每一次
lock/unlock,都可以标注「这个函数做了什么」。
- 让每个函数在调用时输出自己「做什么」(甚至可以是 embedding space),返回时可以审计这段时间系统做了什么;
- 站一个大语言模型监工:检测 AV / 顺序错误 / 不安全的访问(例如「内核里那个函数知道打开了一个符号链接」,外部模型就能拦截它);
- 程序有 formal semantics(指令/语句/函数的执行都 well-defined,正因为这种 determinism 给了我们控制计算机的绝对控制力);我们也可以给每个函数一个 informal semantics,让它报告自己做了什么,再把这些报告串联起来——帮 AI debug、帮 AI 审计。
📷 把程序执行对齐到时间轴:每一步「做了什么」都可审计
回到原视频 (01:26:00)
总结
- 并发编程很难:人类是 sequential creature,容易产生「顺序执行」的幻觉;理解所有行为是 NP-Hard。
- 死锁(Deadlock):AA 型(同线程二次 lock)与 ABBA 型(循环等待);四个必要条件(互斥、持有并等待、不可抢占、循环等待)——打破任何一个即可避免;最实用的是 Lock Ordering。
- 软件工程的本质是 Harness Engineering:做最坏的假设、最防御性的编程(
goto release、RAII、明确 lock ordering),并用工具自动化检查(lockdep 用LD_PRELOAD+ 依赖图查环)。 - 数据竞争(Data Race):不同线程同时访问同一内存且至少一个写;C/C++ 里是 undefined behavior。
-fsanitize=thread(TSAN)用 happens-before 检测它。 - Therac-25:事件驱动的并发 bug,至少 6 人死亡——「软件定义」让硬件互锁变成了有损的软件模型;类似悲剧还有第二个。
- 即使都上锁也不够:原子性违反(AV,ABA,如 TOCTTOU / Diablo 复制) 与 顺序违反(OV,BA,如 concurrent UAF / GhostRace) 才是 97% 非死锁并发 bug 的根源。
- 未来:让程序「可审计」——每个函数都报告自己做了什么(informal semantics),由 AI 监工、检测 AV/顺序错误、拦截不安全访问。
附:官方参考与延伸阅读
本讲的讲义见课程主页,以下链接按书中出现顺序整理:
阅读材料:教科书 Operating Systems: Three Easy Pieces 第 32 章(Concurrency Bugs)。
版权说明:本讲讲义与幻灯片作者为蒋炎岩,采用 Creative Commons BY-NC 4.0 许可,来源于 https://jyywiki.cn/OS/2026/。视频画面与字幕版权归原作者所有。本书为课堂内容的学习性重构。