跳到主要内容

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。 但在内核里,这个选项被无情地阉割了。为什么?因为内核开发者认为递归锁通常会掩盖设计上的错误——如果你需要递归加锁,往往说明你的锁职责划分不够清晰。

回到机制上: 真正的互斥锁不仅禁止双重加锁,还禁止「解锁你没有持有的锁」。这听起来像是废话,但在复杂的错误处理路径里,很容易出现 unlocklock 不配对的情况,最终导致逻辑错乱。

真实案例: 曾经有一个关于 EDAC(Error Detection and Correction)驱动的 Bug(Commit 链接见原文)。代码逻辑是这样的:

  1. 函数 edac_device_reset_delay_period() 先获取了一个 mutex
  2. 随后它调用了 edac_device_workq_teardown()
  3. 坑点来了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_KERNELkzalloc()。 结果呢?内核的配置项 CONFIG_DEBUG_ATOMIC_SLEEP 抓住了这个错误,抛出了警告,并在 Call Trace 里清晰地指出了罪魁祸首。如果没有这个调试选项,你的系统可能会莫名其妙地冻结,因为调度器试图把一个「不许睡」的进程调度走,结果把 CPU 的状态搞得一团糟。

规则三:自旋锁的加锁/解锁配对

这条规则实际上是规则一的「自旋锁版本」:

  1. 不能重复加锁同一个自旋锁。
  2. 不能释放你没有持有的自旋锁。
  3. 退出前必须解锁。

虽然这些规则听起来像常识,但请记住:常识往往是最容易被违背的。特别是在错误处理分支,或者是那个「这就写完一个函数」的凌晨三点。


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);

价值在哪?

  1. 意图清晰:代码阅读者明确知道这是在加锁,而不仅仅是关抢占。
  2. 调试友好:配合 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 结构,但却错误地使用了另一个可以由用户空间随意指定的结构体里的锁。

这导致了什么?

  1. 数据竞争:攻击者可以利用这个漏洞,在不同的 CPU 核上制造针对 struct pid 的竞争条件。
  2. 引用计数篡改:通过精心构造的时序,攻击者可以破坏 struct pid 的引用计数。
  3. 提权:Jann Horn 利用这个破坏,构建了一条完整的利用链,最终在 Debian Linux 系统上拿到了 Root Shell。

修复方式: 看一眼那张 Commit 的截图(原文图 8.7),修复其实只有一行字:使用正确的自旋锁

这就是我们在本章反复强调的:锁是安全最后的防线。当你选错了锁,或者漏了锁,你不仅仅是在制造并发 Bug,你可能是在给黑客留门。

案例 2:关中断太久,网络就瘫痪了

来源:Alibaba Cloud, Jan 2020。

症状:系统网络出现严重的抖动。

排查过程: 这跟并发有什么关系?当然有。

回扣机制:为什么我们要慎重对待自旋锁? 还记得我们在前面提到过,spin_lock_irq()spin_lock_irqsave() 这类 API 在获取锁的同时,会禁用本地 CPU 的硬件中断

这就像是你告诉接电话的秘书:「不管谁打来电话都别接,除非我处理完这件事。」 如果这件事只花几微秒,没问题。 但如果这件事花了 50 毫秒呢?

想象一下网络包处理的场景:

  1. 网卡收到包,发出硬件中断。
  2. CPU 应该立刻响应,把包读走。
  3. 但如果 CPU 正在执行一段持有 spin_lock_irqsave() 的代码,中断被禁用了。
  4. 网卡的中断处理程序被延迟执行。

真实的坑: 阿里云的工程师发现,网络抖动是由 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_structfile 结构)随时可能被另一个进程释放。如果你不增加它的引用计数,你刚拿到指针,它可能就被释放了——经典的 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 以及所有这些调试工具——因为人类的大脑天生就不擅长模拟并发时序。

下一章,我们将进入另一个维度的调试:追踪内核的执行流。如果说这一章是在教你怎么修补漏水的屋顶,那么下一章就是教你怎么装上摄像头,看清楚水流到底是哪里渗进来的。