跳到主要内容

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

接上一节,我们刚刚提到了系统可能会出现任务卡死的情况——也就是所谓的「Hung Task」。

但这里有一个很有意思的细节:内核是怎么知道任务卡住了?毕竟,如果 CPU 都在忙着跑死循环代码,谁还有空来当「裁判」?

答案藏在一个叫做 khungtaskd 的内核线程里。它是内核专门为了抓现行而养的一条「警犬」,周期性地醒来,四处巡视,看看有没有任务在 D 状态(不可中断睡眠)里待得太久。

现在,我们要把这个机制彻底扒开看清楚。就像 Fine-tuning 一台赛车一样,内核提供了一整套 sysctl 参数,允许你调整这台检测器的灵敏度和行为。

假设你在 x86_64 的虚拟机上,我们来看一下这些参数的默认值:

$ sudo sysctl -a|grep hung_task
kernel.hung_task_all_cpu_backtrace = 0
kernel.hung_task_check_count = 4194304
kernel.hung_task_check_interval_secs = 0
kernel.hung_task_panic = 0
kernel.hung_task_timeout_secs = 120
kernel.hung_task_warnings = 10
$

这一串输出背后,每一个开关都值得深究。我们逐个拆解,看看它们怎么决定系统的生死。


阈值与代价:配置 Hung Task 检测器

以下这些参数都依赖于内核配置项 CONFIG_DETECT_HUNG_TASK 被开启。如果你在嵌入式设备上跑内核,这一节的内容特别关键——因为资源有限,你得在「检测准确性」和「系统开销」之间做取舍。

hung_task_timeout_secs

这是最核心的一个参数:判定「卡死」的时间标准

当一个任务处于不可中断睡眠(Uninterruptible Sleep,也就是你在 ps 命令里看到的 D 状态)的时间超过这个秒数,内核就会判定这是一个 Hung Task,并触发警告。

默认值是 120 秒

这是一个「定性」的参数。如果你的系统对响应时间要求极高,比如某个实时任务必须在 10ms 内响应,那你显然不能等 120 秒才报警。你可以把它调小,甚至调到 1 秒。但要注意,把调得太低可能会导致正常的 I/O 等待被误报为卡死。

它的合法范围是 {0:LONG_MAX/HZ}

hung_task_warnings

警告次数上限

即使检测到了卡死,内核也不一定愿意一直打印日志刷屏。这个参数定义了系统最多报告几次警告。默认值是 10。每次检测到卡死任务,这个计数器就减 1。

当它减到 0 时,内核就会闭嘴——即使系统里还有任务卡着,它也不再打印新警告了。

这就像汽车的故障灯,闪了几下之后为了不让你心烦就自动灭了。但这有一个隐患:如果是持续性的死锁,你可能只看到开头的几次日志,后面就以为系统安静了。

如果你不想让它闭嘴,可以把它设为 -1,表示允许无限次警告。

hung_task_panic

是否由「警告」升级为「处决」

默认值是 0。这意味着当检测到 Hung Task 时,内核仅仅打印一条报警信息(KERN_WARNING),然后让该任务继续在 D 状态里待着。

但如果你把它设为 1,性质就变了:一旦发现 Hung Task,内核会立刻调用 panic(),直接重启或停机。

这个开关通常用于高可用性(HA)集群环境:与其让服务器半死不活地卡着,不如直接重启让它恢复服务。

hung_task_check_count

检测范围的上限

khungtaskd 在扫描的时候,不会真的去遍历几万个任务——那太慢了。它只检查这个参数指定数量的任务。

这其实是一个性能优化的手段。在资源受限的嵌入式系统上,遍历任务链表本身就是一个重活。这个值允许你限制检测器的工作量。

有意思的是,这个值是架构相关的。

  • 在我的 x86_64 虚拟机上,它是 4,194,304(约 420 万)。
  • 但在树莓派(ARM-32)上,它可能只有 32,768

这反映了不同平台对「遍历开销」的不同容忍度。

hung_task_check_interval_secs

检测频率

通常这个值是 0。这意味着检测的间隔时间是由 hung_task_timeout_secs 决定的——也就是「一旦超时就检测」。

但如果你把它设为一个正数(比如 5),那么内核会每隔 5 秒强制扫描一次,不管有没有任务超时。这个值会覆盖掉 timeout 的逻辑。

合法范围是 {0:LONG_MAX/HZ}。一般来说,保持默认的 0 就行,让超时逻辑自然触发更合理。

hung_task_all_cpu_backtrace

全场照相功能

默认值是 0。当设为 1 时,一旦检测到 Hung Task,内核会向所有 CPU 核心发送 NMI(不可屏蔽中断),强制每颗 CPU 都打印一遍当前的堆栈回溯(stack backtrace)。

这非常暴力,但也极其有用。

想象一下,某个线程死锁了,但你不知道是谁占了锁。开启这个选项后,你不仅能看到受害者(Hung Task)的堆栈,还能看到所有其他 CPU 正在干嘛——很可能其中一个 CPU 正抓着锁不放。

这需要 CONFIG_SMPCONFIG_TRACE_IRQFLAGS_SUPPORT 支持。


更隐蔽的死锁:Workqueue 停顿检测

解决了任务卡死的问题,是不是就万事大吉了?

未必。

内核里还有一个重灾区:Workqueue(工作队列)。驱动开发者最喜欢用它来把耗时工作推迟到进程上下文里执行。内核内部维护了一堆内核线程(Worker Threads)来默默消化这些工作项。

问题来了:如果你提交的一个工作项一直躺在队列里不被执行,或者执行得太慢,这算不算故障?

算,而且很致命。 这可能导致存储设备响应超时、网络丢包,或者你看着风扇狂转但系统毫无反应。

为了抓这种「怠工」行为,内核提供了 Workqueue Stall Detection(工作队列停顿检测)。

启用检测:CONFIG_WQ_WATCHDOG

这需要在编译内核时开启配置项:

CONFIG_WQ_WATCHDOG = y

你可以在 make menuconfig 里找到它: Kernel hacking -> Debug Oops, Lockups and Hangs -> Detect Workqueue Stalls

一旦开启,如果某个工作队列的线程池在处理工作项时卡住了,或者进度慢得离谱,内核就会打印 KERN_WARN 级别的报警,并附上工作队列的内部状态信息。

阈值:workqueue.watchdog_thresh

这个超时阈值默认是 30 秒

它由内核启动参数 workqueue.watchdog_thresh 控制,也可以通过对应的 sysfs 文件动态修改。

如果某个工作项在这个时间里还没被执行完,就会触发报警。

如果你想关闭这个检测(比如你确实有个合法的长耗时任务),把这个值设为 0 即可。


故意搞破坏:实测 Workqueue Stall

光说不练假把式。让我们亲手制造一次 Workqueue Stall,看看系统是怎么反应的。

⚠️ 实验警告 千万别在生产环境上跑这一段代码! 这会让你的某个 CPU 核心瞬间 100% 也就是死循环,可能会直接把系统搞卡死。 请务必在有多核 CPU 的测试机上做实验,至少留一个核能维持系统响应。

我们准备了一个简单的内核模块,核心逻辑就在工作队列的处理函数里。

代码逻辑很简单:我们在默认的内核工作队列(system_wq)里提交一个任务,然后在这个任务里故意写一个死循环,把 CPU 吃死。

来看这段代码(ch10/workq_stall/workq_stall.c):

/* [ ... 省略头文件包含 ... ] */

static void workq_func(struct work_struct *work)
{
pr_info("%s: workqueue handler start\n", KBUILD_MODNAME);

/* 正常工作完成后,故意卡住 CPU */
mdelay(100); // 模拟一点正常工作耗时

/* --- 下面是 BUG 代码 --- */
/* 这是一个死循环,专门用来触发 Workqueue Stall */
while (1)
cpu_relax(); // 空转,吃死 CPU
/* --- BUG 代码结束 --- */

pr_info("%s: workqueue handler done\n", KBUILD_MODNAME);
}

/* [ ... 模块初始化代码,提交工作 ... ] */

这里的关键是那个 while(1) 循环。 cpu_relax() 只是告诉 CPU 这里是个忙等待,但在没有其他更高优先级线程时,这个核就会一直在这里空转,永远跑不到函数结尾,也永远处理不了队列里的下一个任务。

把这个模块 insmod 进去,几秒钟内(取决于你的 watchdog_thresh 设置),你的控制台就会像疯了一样输出红色的报警信息:

BUG: workqueue lockup - pool cpu0, time=30024ms, last=42s ago!
...
WARNING: CPU: 0 PID: 1234 at kernel/workqueue.c:5806 check_flush_dependency+0x...
...
Workqueue: events workq_func
RIP: 0010:check_flush_dependency+0...
...
Call Trace:
check_flush_dependency
process_one_work
worker_thread
...

这时候,如果你在另一个终端打开 tophtop,你会看到一个名为 kworker/0:1 之类的内核线程占用了 100% 的 CPU。

这正是我们预期的结果:

  1. 我们把工作提交给了系统默认的工作队列(events)。
  2. 工作队列的某个内核线程(kworker)接手了这项工作。
  3. 工作函数死循环,导致这个 kworker 线程卡死。
  4. 内核的 Watchdog 检测到这个 kworker 陷入了「停顿」状态。
  5. 内核愤怒地打印出堆栈,告诉你到底是谁在捣乱(workq_func)。

这就是 Workqueue Stall Detector 的威力——它能把那种「线程还在但就是不干活」的故障给精准定位出来。


本章回响

这一章,我们终于凑齐了内核故障监控的最后一块拼图。

从最开始的 Kernel Panic(彻底崩溃),到 Lockup(CPU 疯跑不响应),再到现在的 Hung Task(任务睡死)和 Workqueue Stall(工作队列怠工)。我们实际上是在构建一个多维度的监控体系:

  • Panic 解决的是「内核数据结构坏了,没法继续活」的问题;
  • Soft/Hard Lockup 解决的是「CPU 还在转,但逻辑已经卡死」的问题;
  • Hung Task 解决的是「任务在等永远等不到的资源」的问题;
  • Workqueue Stall 解决的是「任务队列堵住了,积压严重」的问题。

还记得我们在章节引子里提到的那种情况吗?——系统看起来是 Up 的,网络还能 ping 通,但就是完全没响应。 现在的你应该知道,这大概率是 Soft Lockup 或者 Hung Task,而不是简单的 Panic。你不再是看到一个黑屏就傻眼的人了,你有工具(sysctlkexecMagic SysRq)去深入窥探内核死前的最后状态。

这非常重要。因为只有知道它是怎么死的,你才能在下一次重启前,把那个 Bug 给找出来,修好它,避免它再次发生。

下一章,我们将把这些监控手段用到极致。我们将把视角从「内核自己出错」转向「外界破坏」——也就是硬件故障、内存位翻转(Bit Flip)以及如何利用 GDB 和 Kdump 这些终极武器,对着内核的尸体进行现场解剖。那将是硬核中的硬核。

准备好了吗?我们继续。


练习题

练习 1:understanding

题目:在标准的内核 Panic 输出中,信息 'Kernel panic - not syncing: ...' 包含了 'not syncing' 字样。请问这个短语在内核源码中具体代表了什么操作行为?为什么在致命错误发生时要采取这种行为?

答案与解析

答案:该短语表示内核故意停止将缓冲区的数据刷新(同步)到磁盘。这是因为当内核发生 Panic 时,系统已处于不稳定状态,尝试进行磁盘 I/O 操作可能会导致数据进一步损坏或错误覆盖。

解析:这是一个考察对基础概念理解的问题。根据文中 'Why Is the Phrase "not syncing" in the Kernel Panic Message?' 小节的解释:当系统检测到无法恢复的错误时,内存中的文件系统缓冲区可能包含不一致的数据。如果此时强制执行磁盘同步,可能会破坏文件系统的完整性。'not syncing' 明确告知用户,内核为了保护数据安全,放弃了保存未写入磁盘的数据。

练习 2:application

题目:假设你正在编写一个内核模块,该模块需要在系统崩溃时执行一些紧急处理(如记录特定的硬件状态到特定寄存器),但该处理过程可能包含休眠操作。请问你应该使用 atomic_notifier_chain_register() 还是 blocking_notifier_chain_register() 来注册你的回调函数?请解释原因。

答案与解析

答案:两者都不应该直接使用,或者需要非常谨慎地处理,但在架构上,Panic 发生时通常要求非阻塞操作。

更准确地说:如果必须注册到 Panic 链,你必须确保代码不包含休眠操作。因为 Panic 发生时,系统会禁用本地中断并禁止抢占,运行在原子上下文中。虽然 panic_notifier_list 本身是 ATOMIC_NOTIFIER_HEAD,但任何试图休眠的操作都可能导致系统死锁或更严重的错误。

解析:本题考察应用场景分析。虽然 'Application' 难度通常鼓励实际编码,但这里是一个关键的陷阱题。

  1. 文中明确指出 panic_notifier_list 是一个 'Atomic' 类型的通知器链(ATOMIC_NOTIFIER_HEAD)。
  2. 文中提到 Atomic 类型的回调运行在原子上下文,不能阻塞。
  3. 文中提到 Panic 函数内部会禁用中断并停止调度。因此,如果回调函数试图休眠,它将永远无法被唤醒,导致系统挂起。
  4. 对于 Panic 处理,最佳实践是快速且非阻塞的。

练习 3:application

题目:假设你的 Linux 服务器在生产环境中偶尔发生内核 Oops,导致服务中断。为了调试,你决定在发生 Oops 时触发 Panic 并重启。同时,为了获得更详细的上下文,你希望在 Panic 时自动打印所有活动任务的回溯和内存信息。请写出需要配置的 /proc/sys/kernel 参数及其对应值的命令(假设使用 shell 命令行)。

答案与解析

答案:配置 Oops 触发 Panic: echo 1 > /proc/sys/kernel/panic_on_oops

配置 Panic 打印任务和内存信息 (panic_print 掩码计算): echo 17 > /proc/sys/kernel/panic_print (或者 0x11)

解析:这是一道实际应用题。

  1. 触发 Panic: 根据知识点 panic_on_oops,将其设置为 1 可以让 Oops 升级为 Panic。
  2. 打印信息: 根据文中 Table 10.2 (panic_print):
    • 显示任务状态 (Show all tasks info): bit 4 (值为 16)
    • 显示内存信息 (Show memory info): bit 0 (值为 1)
    • 为了同时获得这两个信息,需要进行按位或运算:16 | 1 = 17。
  3. 组合起来就是设置这两个参数。

练习 4:thinking

题目:文中提到了 'Hard Lockup(硬锁)' 和 'Soft Lockup(软锁)' 的区别,主要在于是否禁用中断以及 NMI Watchdog 的作用。请结合 kdump (Dump-capture kernel) 机制,分析以下场景:

如果 CPU 禁用了中断并陷入死循环,触发 NMI Watchdog 导致 Hard Lockup 警告,此时主内核已经无法正常调度运行。在这种极端情况下,kdump 机制是如何工作的?(注:提示关注 NMI 的作用)

答案与解析

答案:即使主内核因中断被禁用而死循环(Hard Lockup),NMI(不可屏蔽中断)仍然具有比普通中断更高的优先级,能够强制打断 CPU 的执行流。NMI Watchdog 正是利用 NMI 来检测硬锁的。当配置了 kdump 时,触发 Panic 的路径(即使是 NMI 触发的 Panic)最终会执行 kexec 机制,利用 CPU 的响应在 NMI 上下文中强制加载并启动到捕获内核,从而保留主内核崩溃时的内存镜像。

解析:这是一道深度思考题,需要综合 Hard Lockup、NMI 和 Kdump 的知识。

  1. Hard Lockup 的本质: CPU 处于死循环且中断被禁用,普通中断(包括时钟中断)无法执行,因此无法进行任务调度,普通软件看门狗无法工作。
  2. NMI 的特殊性: NMI 是不可屏蔽中断,即使 CPU 标志寄存器中关闭了中断(IF=0),NMI 依然会被处理器响应。NMI Watchdog 利用性能计数器产生 NMI。
  3. Kdump 的介入: 当 NMI 触发 Panic 例程后,虽然主内核逻辑已死,但 Panic 处理流程(特别是 kexec 加载部分)利用了 NMI 的上下文或者 CPU 仍能执行低级指令的特性,将控制权移交给了预加载的捕获内核。这使得系统能越过死锁的主内核,完成内存转储。

要点提炼

本章核心聚焦于建立内核故障的「死后验尸」与实时监控能力。首先, Panic 是内核遇到无法修复错误时的彻底放弃行为,系统会通过 panic() 函数停止调度并进入死循环,期间不仅会打印包含寄存器、堆栈和 KASLR 偏移量的最后遗言,还可能触发键盘灯闪烁(panic_blink)等视觉警报,且内核参数 panicpanic_print 可分别控制其重启时机和详细信息的输出粒度。

为了在系统死机时获取关键诊断信息,通过 netconsole 结合网络接收端(如 netcat)来远程捕获日志是标准做法,这解决了本地终端因系统僵死无法输出日志的问题。此外,利用 Magic SysRq 机制(通过 /proc/sysrq-trigger 或组合键)可以在系统无响应时强制触发崩溃或执行紧急操作(如同步磁盘、转储任务信息),是调试死锁或假死状态的强力后门。

针对更隐蔽的系统假死,内核提供了基于 NMI 和看门狗的 Lockup 检测机制。 Soft Lockup 指任务在内核态死循环导致其他进程无法调度(默认 20 秒触发),而 Hard Lockup 则涉及中断被关闭的极端死循环(默认 10 秒触发),这两者均可通过内核参数配置为直接触发 Panic 或仅打印警告,帮助开发者区分是调度器失效还是中断被屏蔽。

除了 CPU 层面的死锁,Hung Task 检测器专门负责发现陷入不可中断睡眠(D 状态)过久的任务(默认 120 秒),通常由等待 I/O 或锁资源导致。与此同时,Workqueue 停顿检测机制则用于监控内核工作队列中的任务是否被无限期延迟执行,防止驱动或子系统利用工作队列推迟的任务因死循环而阻塞整个队列的运行。

开发者还可以通过注册 panic_notifier_list 原子通知链,在内核崩溃的瞬间插入自定义的处理逻辑(如点亮故障灯或记录关键硬件状态)。但需要注意的是,这种 Panic 处理回调运行在极不稳定的原子上下文中,严禁执行任何可能引起睡眠或阻塞的操作,否则可能导致系统连崩溃转储都无法生成。