8.4 实战中的锁缺陷案例
上一节我们聊完了 KCSAN,它像一个不知疲倦的守夜人,帮我们盯着那些稍纵即逝的数据竞争。
但这只是工具。真实世界里的 Bug 往往比教科书上的死锁模型要狡猾得多——它们藏在复杂的调用链里,披着合理代码的外衣。这一节,我们不讲新机制,而是把镜头拉近,去看看几个真实发生的内核 Bug。
你会看到,即使是经验丰富的开发者,也会在「持有自旋锁时调用阻塞函数」这种基础问题上栽跟头;你也会看到,一个看似不起眼的锁错误,如何被安全研究员利用成系统的提权漏洞。
这不仅仅是看故事,而是为了建立一种直觉:当你在关键区写下一行代码时,你知道它背后的代价吗?
8.4.1 LDV 规则:那些被写进血汗手册的错误
Linux Driver Verification (LDV) 项目维护了一套内核开发规则库。与其说是规则,不如说是前人用无数个 Panic 换来的「血泪史」。让我们看看其中关于锁的几条核心规则。
规则一:互斥锁的双重加锁
这条规则听起来很简单:不要试图两次锁定同一个互斥锁。
这不仅仅是代码风格问题,而是内核机制决定的。内核的 mutex 实现并不支持递归锁(Recursive Locking)。如果你这么写了:
mutex_lock(&my_mutex);
// ... 做一些事 ...
mutex_lock(&my_mutex); // 试图再次加锁
后果是灾难性的:第二次加锁会直接导致(自)死锁。
这里有个微妙的历史差异: 用户空间的 POSIX 线程其实支持递归锁,只要你在初始化时把类型设为
PTHREAD_MUTEX_RECURSIVE。 但在内核里,这个选项被无情地阉割了。为什么?因为内核开发者认为递归锁通常会掩盖设计上的错误——如果你需要递归加锁,往往说明你的锁职责划分不够清晰。回到机制上: 真正的互斥锁不仅禁止双重加锁,还禁止「解锁你没有持有的锁」。这听起来像是废话,但在复杂的错误处理路径里,很容易出现
unlock和lock不配对的情况,最终导致逻辑错乱。
真实案例: 曾经有一个关于 EDAC(Error Detection and Correction)驱动的 Bug(Commit 链接见原文)。代码逻辑是这样的:
- 函数
edac_device_reset_delay_period()先获取了一个mutex。 - 随后它调用了
edac_device_workq_teardown()。 - 坑点来了:
teardown函数内部试图获取同一个mutex。
结果就是死锁。修复方案很简单:调整调用顺序,在释放锁之后再调用 teardown。
规则二:在自旋锁中阻塞
这是新手最容易掉进去的坑,也是老手偶尔会滑进去的陷阱。
规则很明确:当你持有自旋锁时,绝对不能进行可能引起睡眠的内存分配。
这意味着,你在自旋锁保护的临界区内调用 kmalloc() 时,必须带上 GFP_ATOMIC 标志,而不是默认的 GFP_KERNEL。
spin_lock(&my_lock);
// ❌ 危险!这可能会触发页面回收,导致进程睡眠,从而调度走
// ptr = kmalloc(size, GFP_KERNEL);
// ✅ 正确做法:告诉内核这是原子上下文,别睡
ptr = kmalloc(size, GFP_ATOMIC);
spin_unlock(&my_lock);
真实案例:
在一个无线网络驱动里,有人就这样干了(Commit: 5b0691508aa9)。他在持有自旋锁的情况下调用了带 GFP_KERNEL 的 kzalloc()。
结果呢?内核的配置项 CONFIG_DEBUG_ATOMIC_SLEEP 抓住了这个错误,抛出了警告,并在 Call Trace 里清晰地指出了罪魁祸首。如果没有这个调试选项,你的系统可能会莫名其妙地冻结,因为调度器试图把一个「不许睡」的进程调度走,结果把 CPU 的状态搞得一团糟。
规则三:自旋锁的加锁/解锁配对
这条规则实际上是规则一的「自旋锁版本」:
- 不能重复加锁同一个自旋锁。
- 不能释放你没有持有的自旋锁。
- 退出前必须解锁。
虽然这些规则听起来像常识,但请记住:常识往往是最容易被违背的。特别是在错误处理分支,或者是那个「这就写完一个函数」的凌晨三点。
8.4.2 Local Locks:让意图更清晰
在继续看 Bug 之前,我们需要先提一下内核 5.8 引入的一个新同步原语:Local Locks。
这不是什么魔法,它其实是一个包装器。
背景: 在内核里,我们经常需要针对「每个 CPU」的数据进行保护。传统的做法是直接禁用内核抢占或硬件中断。
preempt_disable(); // 禁止抢占
// ... 访问 per-CPU 数据 ...
preempt_enable(); // 恢复抢占
问题来了:
当你看到代码里只有 preempt_disable() 时,你很难一眼看出来:「这段代码到底是为了防止抢占,还是为了保护某个变量?」
Local Locks 就是为了解决这个问题。它把这些底层的操作封装成了真正的「锁」API。
// 使用 Local Lock
local_lock(&my_lock);
// ... 访问受保护的数据 ...
local_unlock(&my_lock);
价值在哪?
- 意图清晰:代码阅读者明确知道这是在加锁,而不仅仅是关抢占。
- 调试友好:配合 Lockdep 使用时,Local Locks 能像普通锁一样被追踪。如果漏了解锁,或者锁依赖逻辑有死锁风险,Lockdep 能像抓 Mutex 错误一样抓出它。
这是一个从 PREEMPT_RT(实时 Linux)项目引入到主线的好例子,它证明了:好的封装不仅能减少代码行数,还能减少理解成本。
8.4.3 来自 Bugzilla 的「尸检报告」
如果说 LDV 规则是教科书,那内核 Bugzilla 就是医院的病理科。这里全是真实的病例。
技巧:去 Bugzilla 搜索特定的警告字符串,比如 Lockdep 抛出的经典报错:
possible circular locking dependency detected
(原文图 8.7 展示了搜索结果)。
看到这个输出,你就知道有死锁隐患了。Lockdep 不仅告诉你「有死锁」,还会打印出一张复杂的锁依赖关系图,指出是哪两个锁形成了环。
额外建议:
除了 Lockdep,打开内核配置项 CONFIG_DEBUG_ATOMIC_SLEEP 也是抓 Bug 的利器。它会在代码试图在原子上下文睡眠时立即报错,而不是等到系统死锁了你才去猜。
8.4.4 来自社区博客的深度剖析
光看 Bugzilla 的标题是不够的。让我们深入几个经典的博客文章,看看那些把开发者逼疯的 Bug 是长什么样的。
案例 1:一个简单的内存漏洞如何导致系统沦陷
来源:Jann Horn (Google Project Zero), Oct 2021。
这是一个令人毛骨悚然的故事。它告诉我们:一个微不足道的锁使用错误,可能就是攻破整座堡垒的裂缝。
漏洞背景:
问题出在 TTY(伪终端)驱动的代码里(drivers/tty/tty_jobctrl.c:tiocspgrp())。
Bug 本质:
代码里用错了自旋锁。它本应该使用某个特定的自旋锁来保护 struct pid 结构,但却错误地使用了另一个可以由用户空间随意指定的结构体里的锁。
这导致了什么?
- 数据竞争:攻击者可以利用这个漏洞,在不同的 CPU 核上制造针对
struct pid的竞争条件。 - 引用计数篡改:通过精心构造的时序,攻击者可以破坏
struct pid的引用计数。 - 提权:Jann Horn 利用这个破坏,构建了一条完整的利用链,最终在 Debian Linux 系统上拿到了 Root Shell。
修复方式: 看一眼那张 Commit 的截图(原文图 8.7),修复其实只有一行字:使用正确的自旋锁。
这就是我们在本章反复强调的:锁是安全最后的防线。当你选错了锁,或者漏了锁,你不仅仅是在制造并发 Bug,你可能是在给黑客留门。
案例 2:关中断太久,网络就瘫痪了
来源:Alibaba Cloud, Jan 2020。
症状:系统网络出现严重的抖动。
排查过程: 这跟并发有什么关系?当然有。
回扣机制:为什么我们要慎重对待自旋锁?
还记得我们在前面提到过,spin_lock_irq() 和 spin_lock_irqsave() 这类 API 在获取锁的同时,会禁用本地 CPU 的硬件中断。
这就像是你告诉接电话的秘书:「不管谁打来电话都别接,除非我处理完这件事。」 如果这件事只花几微秒,没问题。 但如果这件事花了 50 毫秒呢?
想象一下网络包处理的场景:
- 网卡收到包,发出硬件中断。
- CPU 应该立刻响应,把包读走。
- 但如果 CPU 正在执行一段持有
spin_lock_irqsave()的代码,中断被禁用了。 - 网卡的中断处理程序被延迟执行。
真实的坑:
阿里云的工程师发现,网络抖动是由 Slab 统计代码引起的。这段代码在一个循环里遍历 dentry 对象链表,而这个过程中一直持有 spin_lock_irq()。
spin_lock_irq(&lock); // 关中断
// 遍历一个超级大的链表
list_for_each_entry(...) {
// ... 统计工作 ...
}
spin_unlock_irq(&lock); // 开中断
当系统里的 dentry 对象非常多时,这个循环会跑很久。这就导致了硬件中断被长时间关闭,网络包处理被严重延迟,最终表现为丢包和抖动。
教训:
当你使用 spin_lock_irq() 时,你的临界区必须极其短小精悍。
绝不要在持锁期间遍历长链表,也不要做任何复杂度是 O(n) 的操作。
怎么测量?
如果你怀疑系统里有这种「长持有」的情况,可以用 eBPF 工具 criticalstat 来抓取。
# 测量禁用抢占超过 5000 微秒(5ms)的代码路径
sudo criticalstat-bpfcc -p -d 5000 2>/dev/null
这个工具会直接打印出那个「赖着不释放锁」的函数调用栈,让无所遁形。
案例 3:在原子上下文睡觉,引用计数泄露
来源:Ryan Eberhardt, Nov 2020。
这篇文章叫《我的第一个内核模块:一场调试噩梦》,标题就很诚实地表达了作者的痛苦。
Bug 1:在 RCU 临界区睡眠
rcu_read_lock(); // 进入 RCU 读侧临界区(原子上下文)
// ...
msleep(10); // 💥 爆炸!这里不能睡眠
rcu_read_unlock();
分析:
msleep() 会引发进程调度。但在 RCU 读锁期间,CPU 是不允许被调度走的。这违反了内核的原子性规则。
修复:如果必须延时,用 udelay() 或 mdelay(),它们是忙等待,不会触发调度。
Bug 2:不拿引用计数就访问全局结构
内核里的全局数据结构(比如 task_struct 或 file 结构)随时可能被另一个进程释放。如果你不增加它的引用计数,你刚拿到指针,它可能就被释放了——经典的 Use-After-Free。
// ❌ 错误:直接使用
printk("%d\n", task->pid);
// ✅ 正确:先拿引用,用完再放
get_task_struct(task);
printk("%d\n", task->pid);
put_task_struct(task);
作者的调试方法论: Ryan 在文章里提到一个很「土」但很管用的办法:二分注释法。 既然不知道哪里挂了,就把代码块全注释掉,运行——没问题了? 好,取消注释一半,再运行——挂了? 那问题就在这一半里。 在极度复杂的并发 Bug 面前,这种笨办法往往比高科技工具还要快。
本章回响
这一章我们走得很远。
从最基本的数据竞争定义,到 KCSAN 这种动态分析神器的实现原理,再到真实世界里的几个惨痛案例。如果我们要把这一切浓缩成一句话,那大概是:
并发编程里没有「小事」。
一个用错的自旋锁,一次在持有锁时的内存分配,或者一个被遗忘的引用计数,在单线程思维下只是逻辑瑕疵,但在多核内核里,它们就是系统崩溃、数据泄露甚至提权漏洞的源头。
还记得我们在本章开头问的问题吗?—— 「为什么内核的并发 Bug 这么难查?」
现在你有了答案。 因为它们往往不是逻辑错误,而是时序错误。代码顺序没变,只是 CPU 执行的快慢变了一点,世界就崩塌了。这也是为什么我们需要 KCSAN、Lockdep 以及所有这些调试工具——因为人类的大脑天生就不擅长模拟并发时序。
下一章,我们将进入另一个维度的调试:追踪内核的执行流。如果说这一章是在教你怎么修补漏水的屋顶,那么下一章就是教你怎么装上摄像头,看清楚水流到底是哪里渗进来的。