跳到主要内容

10.4 检测内核中的死锁与 CPU 停顿

上一节结尾我们聊到了 Panic 处理器里的那条「红线」:别太复杂,否则连「临终遗言」都留不下。但有时候,内核的死法更阴险——它没有立刻崩溃,也没有大喊大叫,只是突然不说话了。

这就是我们在这一节要处理的「假死」现场。

死锁究竟是什么?

死锁的意思很直白:系统或者某个 CPU 核心,进入了长时间无响应的状态。这比直接 Panic 更麻烦,因为它可能发生在生产环境的某个深夜,机器看似还在运行,但早就卡死了。

为了抓住这种幽灵般的故障,我们需要更高级的监控手段。在看具体的内核检测机制之前,我们先来花点时间把底层的「看门狗」概念捋清楚——它是所有检测机制的物理基础。

关于看门狗的简短说明

看门狗本质上是一个监控程序。它的逻辑非常简单:我隔一段时间给你发个心跳(ping),如果你在规定时间内没回应,我就认为你挂了,然后强制重启系统。

在 Linux 生态里,看门狗分几种形态:

  1. 硬件看门狗:这是直接焊在板子上的独立芯片或者模块。它连在系统的复位电路上,一旦触发,物理上直接拉复位引脚。驱动这东西通常很依赖具体的板子,内核为此提供了一个通用框架,方便驱动开发者去对接各种乱七八糟的硬件芯片。
  2. 软件看门狗:这就是内核里的 softdog 驱动。它没有硬件那么暴力,但在很多场景下够用了。它在内核配置里的位置是 Device Drivers | Watchdog Timer Support,对应的配置项是 CONFIG_SOFT_WATCHDOG

不过,光有内核模块还不够。通常我们需要一个用户空间的守护进程来配合它。这个守护进程的工作就是定期去喂狗(通常就是往 /dev/watchdog 里写点东西,或者发 ioctl)。如果这个守护进程因为系统卡死而无法工作,喂狗超时,系统就会被重启。

在很多生产环境的内核里(比如我们在本书中构建的 5.10.60 定制内核),我们会把 softdog 编译成模块:

CONFIG_SOFT_WATCHDOG=m

一旦加载,你就会在系统中看到一个名为 softdog 的模块。

动手:跑一下 softdog 和用户态 watchdog

光说不练假把式。我们在 x86_64 的 Ubuntu 虚拟机里把这些组件跑起来看看。

首先,加载内核的 softdog 模块,然后手动启动用户态的 watchdog 守护进程(为了演示方便,我用 --verbose 模式跑起来):

$ sudo modprobe softdog
$ sudo watchdog --verbose &
[...]
watchdog: String 'watchdog-device' found as '/dev/watchdog'
watchdog: Variable 'realtime' found as 'yes' = 1
watchdog: Integer 'priority' found = 1
[1]+ Done watchdog --verbose

现在我们确认一下它们是不是真的在跑:

$ ps -e | grep watch
111 ? 00:00:00 watchdogd
10106 ? 00:00:00 watchdog

这里有两行记录: 第一行 watchdogd 是内核线程(属于 softdog 驱动的一部分)。 第二行 watchdog 是我们刚才启动的用户态守护进程。

顺带一提,如果你的系统用的是 systemd,它本身也有内置的看门狗功能(在 /etc/systemd/system.conf 里配置)。

⚠️ 调试警告: 看门狗在服务器上很好用,但在调试内核时是个噩梦。想象一下,你正对着 KGDB 这种交互式调试器单步执行代码,结果因为步子太慢触发了看门狗,系统直接给你重启了——这谁受得了?所以,当你准备深度调试内核时,记得把这些看门狗关掉。

好了,既然看门狗的基础已经打好了,我们可以来看看内核怎么利用这个机制(特别是 NMI)来构建更高级的检测器——针对硬锁和软锁的检测。


利用内核的硬锁与软锁检测器

软件(甚至硬件)是不完美的。我敢打赌你肯定遇到过那种「神秘的假死」:系统没 Panic,日志也没更新,鼠标动不了,键盘没反应,屏幕定格了。

这就是 Lockup。

内核有办法检测这类问题。我们刚才提到的看门狗,在这里扮演了关键角色。内核利用 NMI Watchdog(不可屏蔽中断看门狗)以及 perf 子系统来实现硬锁和软锁的检测。

相关的配置项藏在 Kernel hacking | Debug Oops, Lockups and Hangs 菜单里。看图 10.8(这里假设你看到了那堆配置选项),你可能会问:既然你说这是「生产内核」,为什么没把 panic_on_oops 或者 panic on soft/hard lockup 选上?

这是个好问题。 本书的「生产内核」主要是为了演示,不是真的放在机房里跑业务。如果你在做真实的产品,是否开启自动重启是个架构决策:当系统挂了,你是希望它立刻重启以求自愈,还是希望它停在原地等你来排查尸体? 如果是前者,就打开 panic_on 相关的选项,并配合 panic=n 启动参数(表示 panic 后 n 秒自动重启)。

在我们的配置里,检测功能是打开的,但不会立刻 panic。表 10.3 总结了这里涉及的关键配置、启动参数和 sysctl 控制项。

你可以用 sysctl 看一下你当前系统的设置(注意 nmi_watchdog 对应硬锁,soft_watchdog 对应软锁,不是softdog 模块):

$ sudo sysctl -a | grep watchdog
kernel.nmi_watchdog = 0
kernel.soft_watchdog = 1
kernel.watchdog = 1
kernel.watchdog_cpumask = 0-5
kernel.watchdog_thresh = 10

在这个例子里,nmi_watchdog 是 0,因为这是虚拟机,通常没有硬件看门狗支持。而 soft_watchdog 总是可用的。

现在,让我们搞清楚这些术语到底是什么意思。


什么是软锁?

软锁是一种特定的内核 Bug。

想象一下,某个任务在内核模式下陷入了一个死循环,或者因为某种原因赖在 CPU 上不下来,并且持续了很长时间。结果就是,其他任务根本没有机会在那个 CPU 核心上调度运行。这就是软锁。

时间界限: 软锁的超时时间默认是 20 秒。这个值是怎么算的?它是 watchdog_thresh 值的两倍。 而硬锁的超时时间就是 watchdog_thresh 本身,默认 10 秒

你可以查看并修改这个值:

$ cat /proc/sys/kernel/watchdog_thresh
10

如果想改成 5 秒(意味着软锁 10 秒触发),直接往里写整数就行。写 0 则禁用检测。

当软锁被检测到时会发生什么?

这取决于几个配置项:

  1. 如果 kernel.softlockup_panic sysctl 是 1,或者启动参数里有 softlockup_panic=1,内核会直接 Panic
  2. 如果没开启 Panic(默认情况),内核会打印一条警告信息,并打印出卡住任务的堆栈。

⚠️ 注意: 如果是第二种情况(只报警没 Panic),那个导致问题的 Bug 任务会继续赖在那里,继续霸占 CPU。系统并不会自动恢复。


实战:在 x86_64 上触发一次软锁

光看定义没感觉,我们来人为制造一场灾难。

思路很简单:找个倒霉的 CPU 核心,让它进入内核态,然后在这个核心上跑一个极度消耗 CPU 的死循环。

我修改了一下之前《Linux Kernel Programming Part 2》书中的一个演示模块(kthread_simple),往里面加了点恶意代码。具体的代码在 ch10/kthread_stuck 目录下。

加载这个模块(不传参数默认就是测试软锁):

[ ... 模块加载过程 ... ]

运行超过 20 秒后,内核的软锁看门狗就会反应过来,直接输出一个 BUG() 消息!看图 10.9,你会看到控制台被 BUG: soft lockup ... 这类 KERN_EMERG 级别的消息刷屏了。

除了那个显眼的 BUG 提示,看门狗还会调用 dump_stack() 等例程,把当前的现场状态全部吐出来:

  • 内存里的模块列表
  • 上下文信息
  • CPU 寄存器快照
  • 机器指令
  • 最关键的:内核模式下的调用栈

如果你用了我们那个便捷的 PRINT_CTX() 宏,你会看到类似这样的输出:

002) [lkd/kt_stuck]:3530 | .N.1 /* simple_kthread() */

注意那个 .N.1 格式的字符串。 第一个字符是 .(点),这表示硬件中断是开启的

这符合我们的预期:在这个测试里,我们用的是普通的 spin_lock(),并没有关中断。这跟接下来要讲的硬锁形成了鲜明对比。

别忘了自旋锁!

你可能会问:既然是死循环,为什么还要拿个自旋锁? 这就是测试的精髓所在。

自旋锁——特别是 spin_lock_irqsave() 这种变种——不仅仅是自旋等待,它还会关闭硬件中断。关中断有个副作用:它也顺便禁止了内核抢占。这意味着,一旦你进去了,基本上就没人能打断你了(除了 NMI)。

这正是模拟硬锁所需要的条件。

但这有个有趣的矛盾:既然中断都关了,内核看门狗怎么能检测到它卡住了呢?

答案就在于 NMI(不可屏蔽中断)。NMI 的定义就是「连中断关了我也得进来」,它利用硬件性能计数器来周期性地检查 CPU 是否还在跳动。所以,即便你在代码里把中断全关了死循环,NMI 依然能破门而入,发现你正在挂机。

(当然,再次强调:我们在第 8 章讲过,持有自旋锁时必须快进快出。这里故意反其道而行之,纯粹是为了教学演示。)

如果你想深入研究软锁检测的源码,可以去 kernel/watchdog.c 里的 watchdog_timer_fn() 函数看看。

另外,如果你试图去 rmmod 这个恶意模块,而且没按规矩先发信号让内核线程退场,过个大概 2 分钟,rmmod 这个进程自己都会被检测成「Hung Task」(被卡住的任务)。我们马上就会讲到这个检测机制。


什么是硬锁?

硬锁比软锁更严重。

如果说软锁是「我不让其他人跑,但我还能响应中断」,那硬锁就是「彻底封门」。 一个 CPU 核心在内核模式下陷入死循环,并且关闭了中断。这意味着,在这个核心上,连硬件中断都无法处理。

时间界限: 硬锁的超时时间默认是 10 秒(watchdog_thresh)。

当硬锁被检测到时会发生什么?

同样分两种情况:

  1. Panic:如果设置了 nmi_watchdog=1 启动参数,或者 kernel.hardlockup_panic 为 1,内核会直接 Panic。
  2. 警告:默认情况下,它只会打印警告和堆栈。如果启动参数里有 hardlockup_all_cpu_backtrace=1,它还会打印所有 CPU 的堆栈回溯。

同样,如果没 Panic,那个 Bug 代码会继续卡死那个核心。


RCU 与 CPU 停顿

还有一个常见的「假死」源头,来自 RCU(Read-Copy-Update)机制。

你可能听说过 RCU,它是内核里一种强大的无锁同步机制。但在使用 RCU 时,如果 CPU 陷入了长时间的中断关闭或不可抢占状态,就会导致 RCU CPU Stall

这就好比 RCU 机制在等你读数据,结果你一直不撒手,它超时了就会报警。

快速理解 RCU 的核心逻辑

想象几个读者(R1, R2, R3)正在读一份共享数据。 这时候来了个写者,写者并不直接改数据,而是复制一份,修改副本,然后把指针原子地指向新数据。 旧的数据怎么办?必须等所有读完了的读者(R1, R2, R3)都确认读完了,才能释放旧数据。

怎么确认读者读完没? RCU 的实现方式是:等待所有当前读者主动让出 CPU(比如调用调度器)。 写者会设置一个宽限期,默认长达 60 秒(CONFIG_RCU_CPU_STALL_TIMEOUT)。如果这么长的时间读者都还没跑完,或者 CPU 一直不调度,内核就会打印 RCU Stall 警告。


实战:触发硬锁 / RCU 停顿

这一步比软锁麻烦点,条件比较苛刻:

  1. 必须是实体机:硬锁检测依赖 NMI,虚拟机通常没这东西。
  2. 开启 NMI:启动参数里要加 nmi_watchdog=1
  3. 配置检查CONFIG_RCU_CPU_STALL_TIMEOUT 要在 3 到 300 之间。

配置好后,sysctl 应该能看到:

# sysctl -a | grep watchdog
kernel.nmi_watchdog = 1
kernel.soft_watchdog = 1
...
kernel.watchdog_thresh = 10

现在,我们再次加载那个恶意模块 ch10/kthread_stuck,这次传个参数 lockup_type=2。 这个参数会让内核线程在持有自旋锁时,调用 spin_lock_irq()把中断和抢占都关掉,然后死循环。

等一会儿,内核日志里就会炸锅。你可能会看到 NMI 中断产生的回溯,或者是 RCU CPU Stall 的警告。

日志大概长这样:

rcu: INFO: rcu_sched detected stalls on CPUs/tasks:
rcu: 3-...0: (1 GPs behind) idle=462/1/0x4000000000000000 softirq=60126/60127 fqs=6463
(detected by 2, t=15003 jiffies, g=127897, q=1345272)
Sending NMI from CPU 2 to CPUs 3:
NMI backtrace for cpu 3
CPU: 3 PID: 16351 Comm: lkd/kt_stuck Tainted: P W OEL 5.13.0-37-generic #42~20.04.1-Ubuntu
[...]

这就是 RCU 机制发现 CPU 被关中断卡太久后发出的咆哮。

你可以通过 kernel.panic_on_rcu_stall sysctl 让这种情况下直接 Panic。

最后,内核其实提供了一个更专业的测试模块 test_lockupCONFIG_TEST_LOCKUP),专门用来测试这些检测机制,比我们自己写的山寨模块要全得多。

表 10.4 总结了所有关于硬锁、软锁的关键参数和配置。


利用内核的 Hung Task 与 Workqueue 停顿检测器

除了 CPU 死循环,还有一种常见的「假死」是任务卡住

比如某个进程进入了 TASK_UNINTERRUPTIBLE(D 状态),并且在睡眠里赖着不醒,持续超过默认的 120 秒。这就是 Hung Task。

配置 Hung Task 检测

还是在 Kernel hacking | Debug Oops, Lockups and Hangs 菜单里:

[*] Detect Hung Tasks
(120) Default timeout for hung task detection (in seconds)
[ ] Panic (Reboot) On Hung Tasks

一旦开启,内核会周期性地扫描所有任务,看看有没有谁处于 D 状态且超时。

相关的参数如下:

  • CONFIG_DEFAULT_HUNG_TASK_TIMEOUT:编译时配置超时时间。
  • kernel.hung_task_timeout_secs:运行时 sysctl,设为 0 禁用。
  • kernel.hung_task_panic:发现时是否 Panic。

⚠️ 调试陷阱: 我们在前面提到,如果你尝试 rmmod 那个恶意模块却没先发信号停止内核线程,rmmod 进程自己可能会被卡住(因为在等待模块引用计数归零),然后被 Hung Task 检测器抓个正着。这就是个典型的踩坑案例:调试死锁的工具本身被死锁了。

除了任务卡住,内核的 Workqueue(工作队列)也有可能会停顿。如果某个工作项挂在队列里太久没执行,也会触发报警。


本章回响

这一章我们建立了一套完整的「故障监控体系」。

从最暴力的 Panic,到利用 NMI 穿透防线的 Hard Lockup 检测,再到能发现任务在睡眠中窒息的 Hung Task 检测。本质上,我们是在给内核装上越来越多的传感器。

还记得我们在章节引子里提到的问题吗——系统没崩溃,但它卡死了,怎么办? 现在答案很清楚了:

  • 如果是 CPU 疯跑不调度,是 Soft Lockup
  • 如果是 CPU 关中断不响应,是 Hard Lockup
  • 如果是任务死等资源不醒来,是 Hung Task
  • 如果是 RCU 宽限期过不去,是 RCU Stall

下一章,我们将把视线从「内核自己死了」转移到「内核被外力弄死」——也就是硬件故障、内存损坏以及调试这些核弹级问题的终极手段。