南京大学 操作系统 (JYY) 学习笔记:并发 Bug 的地狱——数据竞争、死锁与原子性违反

写在前面:这是本系列的第十七篇。

理论上说,我们可以用并发编程 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)

把锁看成袋子里的球:

  1. Mutual-exclusion (互斥): 一个口袋一个球,得到球才能继续。
  2. Wait-for (请求并保持): 得到球的人还在贪婪地想要更多的球,且不肯放下手里的球。
  3. No-preemption (不可剥夺): 不能强行抢走别人手里的球。
  4. 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->somethingfree 后还能进入 if

但是!现代 CPU 有投机执行(预测执行)的功能。CPU 为了快,在等锁的时候,它"猜"自己能拿到锁,于是提前执行了 if 里的代码,并把数据加载到了 CPU 缓存里。
哪怕后来 CPU 发现自己猜错了,把执行结果撤销了,但
缓存里的痕迹留下来了
(Spectre 漏洞的变种)。黑客借此实现了完美的侧信道攻击。

面对这样深不可测的底层世界,除了保持敬畏,唯有不断扎实底层的基本功,才能在风暴来临时看清魔鬼的真容。

相关推荐
艾莉丝努力练剑2 小时前
【Linux:动静态库】Linux 动静态库与可执行文件
linux·运维·服务器·学习·面试·文件系统·动静态库
软件技术NINI2 小时前
盗墓笔记html+css+js 2页
css·笔记·html
宁风NF2 小时前
JavaScript:内存、垃圾回收、性能优化
开发语言·前端·javascript·学习·性能优化·es6
智者知已应修善业2 小时前
【用D触发器组成000-001-010-011-100】2025-6-7
驱动开发·经验分享·笔记·硬件架构·硬件工程
编程圈子3 小时前
电机驱动开发学习22. 霍尔电角度标定(自动/手动标定与Flash固化)
驱动开发·学习
纪念 2293 小时前
数据库基础
数据库·笔记·学习方法
AnsonNie3 小时前
处理提示“wsl: 检测到 localhost 代理配置,但未镜像到 WSL。NAT 模式下的 WSL 不支持 localhost 代理。”【笔记】
笔记
山城码农笑松哥3 小时前
Redis 8.8 新特性实操笔记:原生数组、INCREX 限流、XNACK 流处理
redis·笔记·junit
m4Rk_3 小时前
【论文阅读】Agent 记忆机制(26):Infini Memory——将长期记忆维护成可读写的主题文档
论文阅读·人工智能·学习·开源·github
xqqxqxxq3 小时前
SQL 字段约束笔记
android·笔记·sql