10.4 检测内核中的死锁与 CPU 停顿
上一节结尾我们聊到了 Panic 处理器里的那条「红线」:别太复杂,否则连「临终遗言」都留不下。但有时候,内核的死法更阴险——它没有立刻崩溃,也没有大喊大叫,只是突然不说话了。
这就是我们在这一节要处理的「假死」现场。
死锁究竟是什么?
死锁的意思很直白:系统或者某个 CPU 核心,进入了长时间无响应的状态。这比直接 Panic 更麻烦,因为它可能发生在生产环境的某个深夜,机器看似还在运行,但早就卡死了。
为了抓住这种幽灵般的故障,我们需要更高级的监控手段。在看具体的内核检测机制之前,我们先来花点时间把底层的「看门狗」概念捋清楚——它是所有检测机制的物理基础。
关于看门狗的简短说明
看门狗本质上是一个监控程序。它的逻辑非常简单:我隔一段时间给你发个心跳(ping),如果你在规定时间内没回应,我就认为你挂了,然后强制重启系统。
在 Linux 生态里,看门狗分几种形态:
- 硬件看门狗:这是直接焊在板子上的独立芯片或者模块。它连在系统的复位电路上,一旦触发,物理上直接拉复位引脚。驱动这东西通常很依赖具体的板子,内核为此提供了一个通用框架,方便驱动开发者去对接各种乱七八糟的硬件芯片。
- 软件看门狗:这就是内核里的
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 则禁用检测。
当软锁被检测到时会发生什么?
这取决于几个配置项:
- 如果
kernel.softlockup_panicsysctl 是 1,或者启动参数里有softlockup_panic=1,内核会直接 Panic。 - 如果没开启 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)。
当硬锁被检测到时会发生什么?
同样分两种情况:
- Panic:如果设置了
nmi_watchdog=1启动参数,或者kernel.hardlockup_panic为 1,内核会直接 Panic。 - 警告:默认情况下,它只会打印警告和堆栈。如果启动参数里有
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 停顿
这一步比软锁麻烦点,条件比较苛刻:
- 必须是实体机:硬锁检测依赖 NMI,虚拟机通常没这东西。
- 开启 NMI:启动参数里要加
nmi_watchdog=1。 - 配置检查:
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_lockup(CONFIG_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。
下一章,我们将把视线从「内核自己死了」转移到「内核被外力弄死」——也就是硬件故障、内存损坏以及调试这些核弹级问题的终极手段。