1. 一个凌晨的告警:D状态任务为什么让运维睡不着
先讲个我自己的经历。某次凌晨两点,监控突然弹了一串告警,大量业务节点出现超时,登上去一看dmesg里刷满了这样的内容:
INFO: task kworker/u4:1:36708 blocked for more than 120 seconds. Not tainted 5.10.0-60.el7.x86_64 #1 "echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.这条报错来自 Linux 内核的hung_task机制,也就是标题里说的“内核自检”的一部分。它意味着内核检测到某个任务已经连续在不可中断睡眠状态(也就是常说的 D 状态)下停留了超过 120 秒,而且没有任何被调度回 CPU 执行的迹象。当时的业务表现是 NFS 客户端挂载目录无响应,所有访问这个目录的进程全部卡住,直接拖垮了一大片业务。
很多同学第一次接触 hung_task 都是从这类线上事故开始的,而且大概率是带着恐慌情绪接触的。因为这条报错看起来非常严重,但实际上它既不是内核 panic,也不一定是死锁,它更像是一扇车窗上的水雾——真正的问题在玻璃那边,内核通过“起雾”来提醒你赶紧处理。能不能看懂这条信息,直接决定了你能不能快速定位业务卡死的原因。
这篇文章我想把 hung_task 的完整机制讲透:它到底检测什么、怎么检测、参数怎么调、线上遇到后怎么一步步排查。不打算写成教科书式源码逐行解读,而是结合事故排查的经验,把内核源码和实际运维场景串起来。如果你维护的是 Linux 服务器、Kubernetes 节点,或者在做存储、内核、嵌入式方向,这篇内容应该能帮你省掉不少撞墙的时间。
2. 从调度状态说起:为什么“不可中断睡眠”会变成问题
要理解 hung_task,得先理解任务在什么情况下会进入 D 状态。Linux 下进程的状态大多大家都很熟,R 是运行态,S 是可中断睡眠,Z 是僵尸,D 是不可中断睡眠。但对 D 状态的“不可中断”到底意味着什么,很多人其实是模糊的。
2.1 可中断睡眠和不可中断睡眠的差异
普通进程等磁盘 IO、等网络数据时,大多会进入 S 状态。这个状态下进程会被挂起,等条件满足时被唤醒。它的特点是:如果有信号递达,内核会把进程唤醒去处理信号,kill命令能把它叫醒。
但有些场景下,内核不希望进程被信号叫醒,因为一旦中途被打断,数据一致性可能出问题。比如进程正在等待底层存储设备返回某个关键 IO 结果,如果这个时候被信号打断,代码根本不知道该回到哪个点重试,也不知道数据到底写进去没有。于是内核干脆把进程置为 D 状态——也就是TASK_UNINTERRUPTIBLE,这个状态下,普通的信号都会被忽略,进程只能靠它等的那件事真正完成才能被唤醒。
用生活的类比来说,S 状态是你坐在餐厅里等菜,服务员喊“你的菜好了”你能听到,中途朋友打电话叫你走你也能接,然后决定是走是留。D 状态则是你进了手术室,麻醉已经打上了,这个时候别说朋友叫你,就算天塌下来,你也得等手术做完才能动弹。对外界来说,你就是“完全失联”的。
2.2 D 状态不代表有问题,但“一直 D”一定有问题
这里必须澄清一个容易误伤的认知:进程短暂处于 D 状态是极其正常的,谁还没有个读写磁盘的时候。哪怕是小到一次cat /proc/loadavg,底层也可能触发短暂的 IO 等待。关键是时间尺度。
如果 D 状态持续几十秒甚至上百秒,基本可以断定这个任务等的东西迟迟没来。可能的情况有几种:一是底层存储没响应,比如磁盘故障、SCSI 总线错误、Raid 卡卡死;二是远端资源不可达,比如 NFS 服务端挂死、网络分区导致 RPC 请求迟迟得不到回应;三是内核里某个锁被别的任务持有且长期不释放,或者是驱动代码有 bug,中断没触发、唤醒丢失。
当这个等待时间超过内核设定的阈值,也就是默认的 120 秒,内核的 hung_task 机制就会介入,把这个任务的所有信息、调用栈、内核栈全部输出到日志里,提醒系统管理员“这里有一台只进不出的机器”。
2.3 为什么内核要专门设计一套检测机制
你可能会问:进程卡住了,用户态自然会感知到超时,为什么内核要单独搞一个自检?原因很简单:用户态感知到的永远只是“这个进程不响应了”,但不知道它到底死在哪一环。而且很多卡在 D 状态的任务,比如内核线程,根本没有用户态对应物,你甚至没法用kill -9去处理它。
hung_task 的价值,不在于它能“修复”问题——它本身不负责恢复,也不直接杀掉卡死的任务,它解决的是“可观测性”问题。它负责把不可见的内核卡顿情况暴露出来,让运维和开发能拿到现场证据。内核在这里扮演的角色,就像是一个一直盯着仪表盘的副驾驶,发现你盯着前方一动不动超过两分钟,就拍你肩膀喊一声“前面有情况”。
还有一个容易混淆的概念是 softlockup。softlockup 检测的是 CPU 是否长时间被某个任务霸占,也就是某个进程在内核态死循环不释放 CPU;而 hung_task 检测的是任务长时间不被调度,CPU 是空闲的,但任务在等一个永远等不到的事件。两者一个有 CPU 空转、一个没 CPU 可跑,检测思路完全不同。很多新手把这两个机制混为一谈,排查方向就会跑偏。
3. khungtaskd 的检测逻辑:从定时唤醒到报警落地的完整路径
hung_task 在内核里的具体实现,集中在kernel/hung_task.c这一个文件里。代码量不大,但涉及的设计思路很值得梳理。下面我用源码路径加逻辑拆解的方式讲讲它到底是怎么运作的。
3.1 检测线程是怎么被创建和驱动起来的
系统启动时,hung_task_init会被调用(实际上它是一个late_initcall),在这个初始化函数里内核会创建了一个专用的内核线程,名字叫khungtaskd,这个线程的职责就是反复执行检测逻辑。
这个线程没有用普通的定时器,而是用一个wait_event_freezable配合超时时间,按照一定周期睡眠后再醒来扫描任务列表。这里的周期不是拍脑袋定的,而是跟超时时间有联动关系,具体规则我放在参数那一节详细说。核心是:khungtaskd 每隔一段时间就遍历一次系统中的所有任务,把状态异常且超时的找出来。
这里有一个值得注意的设计细节:khungtaskd 用的是wait_event_freezable,也就是说系统休眠时这个线程是可以被冻结的。不然每次电脑合盖、服务器挂起再唤醒,这个线程会因为错乱的时间戳而产生大量误报。
3.2 内核是怎么判定一个任务“hung”了的
每次遍历任务时,内核会逐个检查任务的state字段。判定逻辑可以概括成三步:
第一步,看状态位。只有任务处于TASK_UNINTERRUPTIBLE(D 状态)才会被纳入检查范围。如果你看到进程是 S 状态,哪怕它一年不醒,hung_task 也只会路过并不会管它。毕竟大部分 S 状态是有意为之,比如 daemon 在等事件,这属于正常休眠。
第二步,看唤醒标志。内核有一个特殊的状态组合叫TASK_KILLABLE,它的意思就是“我可以被信号杀掉,但我不会被普通唤醒打断”。这类任务虽然底层的 state bit 也是不可中断睡眠,但它允许致命信号递达。hung_task 发现任务带上了TASK_WAKEKILL标志就会跳过,因为这类任务即便卡很久,系统管理员还能用kill -9救场,不算完全的“死局”。
第三步,看时间戳。内核在任务每次被调度出 CPU 时都记录一个时间点,也就是last_switch_time,这个值记录的是“这个任务最后一次被切换出去的时间”。如果当前时间 - 最后切换时间 > 超时阈值,并且该任务不满足豁免条件,就判定为 hung。
这一段对应到源码,核心逻辑简洁到了近乎粗暴的程度:
static void check_hung_task(struct task_struct *t, unsigned long timeout) { unsigned long switch_count = t->nvcsw + t->nivcsw; if (t->flags & PF_FROZEN) return; if (!switch_count || switch_count != t->last_switch_count) { t->last_switch_count = switch_count; return; } if (time_after(jiffies, t->last_switch_time + timeout * HZ)) { hung_task_show_callchain(t); ... } }注意这里还藏了一个很巧妙的细节:它不只是看“时间是否超了”,还看“这个任务有没有发生过调度”。如果一个任务在最近一段时间里虽然也处于 D 状态,但中间有短暂的切换记录(比如被打断处理信号),那就说明它还在活动,不算卡死。只有从那一刻起,任务的切换次数完全没变、CPU 再也没有碰过它,并且时间超过了阈值,才会走到报警逻辑。
3.3 命中之后,内核到底打印了什么信息
当一个任务被判定为 hung,内核会执行hung_task_show_callchain,把以下信息一股脑输出到内核日志:
- 任务名和 PID
- 任务状态、父进程 PID
- 内核栈回溯(也就是这个任务当时卡在哪个内核函数里)
- 每个 CPU 上当前正在执行的任务(这个信息在排查时非常有用)
- 是否带
hung_task_panic标志来决定是否直接触发 panic
实际打印出来的内容排布大概是这样的:
INFO: task dbus-daemon:919 blocked for more than 120 seconds. Not tainted 3.10.0-1160.6.1.el7.x86_64 #1 "echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message. dbus-daemon D 12816 1 919 0x80000000 Call Trace: [<ffffffff8b1ab5e9>] __schedule+0x2a9/0x8d0 [<ffffffff8b1abed1>] schedule+0x31/0x80 [<ffffffff8b1af4a6>] rwsem_down_read_failed+0x156/0x1f0 ...开头那句"echo 0 > /proc/sys/kernel/hung_task_timeout_secs" disables this message.就是提示你如果不想看到这个报错可以怎么暂时关掉。但注意“暂时”两个字:关掉检测不等于解决问题,这行提示是给紧急情况止血用的,正常业务环境不建议长期关闭。
4. sysctl 参数不是摆设:四个关键旋钮的取舍逻辑
hung_task 机制的运行参数都在/proc/sys/kernel/下,可以通过 sysctl 动态调整。它们分别控制超时时间、检测频率、告警次数和故障行为。理解这四个参数,才能做到“让监控服务人而不是折磨人”。
4.1 超时时间和检测周期是联动关系
最重要的参数是kernel.hung_task_timeout_secs,默认 120。这个值的含义是:一个任务连续 D 状态超过多少秒,就判定为 hung。它是判定阈值,但并不是说每过 120 秒检查一次。实际检测周期由kernel.hung_task_check_interval_secs决定,影子默认值是 0,此时内核会把检测周期定为超时时间的一半,也就是 60 秒检查一次。如果你把hung_task_check_interval_secs设成一个非零值,则按这个值作为检查周期。
举个例子,我把超时时间设为 300,检测周期保持默认 0,那么实际就是每 150 秒检测一次;如果我把检测周期显式设为 60,那就每 60 秒看一遍,发现某任务连续 300 秒没有切换才报警。前者省 CPU,后者更灵敏但检测更频繁。常规服务器负载下,遍历任务列表的开销可以忽略不计,但如果跑的是几万核的 HPC 集群,检测成本就值得琢磨了。
4.2 打印次数和 panic 开关的取舍
kernel.hung_task_warnings控制内核最多打印多少次这种告警。默认值 0 表示不限制次数,也就是说只要一直有任务卡死就会一直打日志。这在一台日志收集系统里很容易把磁盘打爆,所以在生产环境建议设一个上限,比如 10 次,避免告警风暴演变成磁盘故障。
kernel.hung_task_panic是最需要谨慎对待的参数。把它设为 1 后,一旦检测到 hung 任务就立刻触发内核 panic,整机重启。这套机制在部分高可用场景下是合理的,比如双机热备里,一台机器卡死了不如赶紧重启让流量切走。但在没有外围高可用机制的单机上,设为 1 等于自杀式监控,业务还没等来救援,机器先把自己重启了。
我见过不少容器化节点把hung_task_panic设成 1,理由是 kubelet 会重新拉起 Pod。看起来没问题,但如果卡死的根因是底层存储故障,每台节点都 panic、都重启,然后全部挂载同一个故障存储,再一起 panic,形成“重启风暴”,整个集群都会被打崩。所以我的建议是:panic 开关一定要配合根因隔离能力使用,没有隔离手段之前,保持默认 0。
4.3 参数调整实例:从默认到适合业务形态
下表是我在不同场景下的推荐配置,供参考:
| 参数 | 默认值 | 通用业务建议 | 强 HA 集群建议 | 低频 IO 场景建议 |
|---|---|---|---|---|
| hung_task_timeout_secs | 120 | 300~600 | 60~120 | 600~1800 |
| hung_task_check_interval_secs | 0 | 0(跟随半周期) | 30 | 0 |
| hung_task_warnings | 0(不限) | 5~10 | 5~10 | 10 |
| hung_task_panic | 0 | 0 | 可选 1 | 0 |
注意这些数值不是拍脑袋拍的。通用业务场景里,文件系统、数据库、容器网络都可能因为底层抖动出现几十秒的 D 状态,如果把阈值定太死,正常抖动就会触发报警,反而掩盖了真正的问题。低频 IO 场景里,比如冷数据备份集群,一次大规模归档任务可能让进程在磁盘等待上停留很长时间,这时候阈值就得放大到分钟级别,否则整个备份过程会一直响起误报。
还有一个关键细节:hung_task_timeout_secs设为 0 会直接禁用整个 hung_task 检测机制。有些优化教程会让你这么做来“消除误报”,我非常不建议,除非你已经确认这个系统上没有长期阻塞的风险——但说实话,敢这么拍胸脯的人我还没见过。日志轰炸的正确止血方式是调大超时和限制告警次数,而不是彻底关掉这双眼睛。
5. 线上排查链路:从一条日志到揪出底层根因
日志打出来了,下一步怎么查?我按照自己的排查习惯,整理成一套可复用的链路,这套链路帮我处理过不少实际事故,每次走完基本都能定位到根因所在层次。
5.1 第一步:从调用栈判断卡在哪一层
拿到日志后,先看 Call Trace 里的最后一个调用,或者说看它卡在哪个内核函数。hung_task 打印的调用栈是任务被调度出 CPU 时的栈回溯,虽然不代表此刻它正在执行,但能反映它最后一次进入等待时路径。
举个例子,如果栈底是rwsem_down_read_failed、down_read、mutex_lock这一串,说明它是在等一个内核信号量或读写锁。这类阻塞通常意味着另一个任务持有锁不放,这时候重点要查的是另一个任务的栈,看它是真死锁还是持有锁之后做慢速 IO 去了。
如果栈底是wait_on_page_bit、filemap_read这类,说明是在等内存页回写或读盘,问题大概在文件系统和底层 IO。如果栈底是nfs4_wait_clnt_recover或者rpc_wait_bit_killable,说明是在等 NFS 的 RPC 响应,那就该把目光移到网络和 NFS 服务端了。
5.2 第二步:结合/proc/PID/stack和 sysrq 确认阻塞现场
/proc/<pid>/stack是查看内核栈的快捷方法,不过普通用户可能没有权限,需要 root。更常用的手段是echo w > /proc/sysrq-trigger,这个指令会让内核把当前所有处于不可中断睡眠的任务的调用栈全部输出到dmesg,看整个系统的 D 状态任务分布情况。一次性看到哪些进程卡在哪个函数里,比单看一条日志更容易判断是不是“集体等待同一个资源”。
配合ps -eo state,pid,comm,wchan:36命令能看到任务在内核里的等待点,也就是 wchan。虽然它显示的是符号名,不是完整调用栈,但已经能帮我们快速筛选出异常进程。
5.3 第三步:区分三宗最典型的根因
结合我经手过的案例,线上最常见的 hung_task 根因分三类:
第一类是存储设备无响应。这种最直观,调用栈一般落在 io_schedule、wait_on_page_bit 这一类。查看底层存储确认磁盘是否故障、RAID 卡是否在重建,或者用iostat -x 1看%util和avgqu-sz是否异常。真实案例里,一块盘扇区重映射导致的 IO 长时间卡死,会让所有等待该盘的任务全部 D 住。
第二类是网络文件系统远端不可达。NFS 挂载点一旦服务端响应超时,内核里的 NFS 客户端任务会长期阻塞。这种情况要检查网络连通性、NFS 服务端状态,并且确认挂载参数里是否启用了soft、timeo、retrans这些选项。很多团队图省事直接挂hard模式,结果服务端一挂,客户端所有进程全卡 D 状态。
第三类是内核资源锁死或驱动 bug。这种最难查,因为调用栈和实际持有的锁可能不在同一个进程上。我通常的做法是配合hung_task_panic在测试环境触发一次,抓完整的 panic 日志,再用crash工具分析死锁链。
5.4 一个具体的排查案例拆解
之前遇到过一个问题:数据库节点报 hung_task,调用栈指向ext4_filemap_fault和filemap_write_and_wait_range,本质上是在等内存页写回。当时第一反应是存储慢,但 iostat 看下来存储压力很低。后面把排查重点放到内存回收上,才发现是内存碎片化严重,写回线程长时间拿不到足够的内存页,导致写回任务被卡住,进而拖住整个 ext4 的 IO 路径。这个案例挺有代表性,它说明调用栈只是入口,问题根因可能隔了两三层,得沿着 IO 路径一层层剥开。
遇到 hung_task 时,先别急着改参数,按“阻塞点 → 资源状态 → 底层组件”的顺序查,大多数情况都能在半小时内定位到一个可行动的方向。
6. 架构设计与故障演练:把被动告警变成主动防御
hung_task 的告警本质上是一种“滞后指标”——它告诉你已经坏了,而不是快要坏了。想让系统不被这类问题打得措手不及,需要在架构设计和日常演练上多做一步。
6.1 识别天然容易踩线的组件
有些组件天生就容易成为 hung_task 的常客。最常见的是基于网络的文件系统,NFS 是重灾区,CIFS/SMB 也会出现类似问题。再一个是本地存储的磁盘直通场景,特别是底层没有超时机制的老旧磁盘阵列。容器网络里,如果使用了网络存储插件且插件链路较长,也可能因为网络抖动把 IO 路径拖垮。
针对这些组件,我的建议是提前打预防针。比如 NFS 挂载时尽量启用soft模式,或者在客户端和服务端之间加 IO 超时检测;存储层面配置磁盘的 IO 超时参数,服务端和客户端都设置合理的超时上限。做过这些前置处理后,即使底层出现抖动,也往往是进程得到错误码然后重试,而不是无限期卡死。
6.2 监控不能只看 dmesg,要主动采样
dmesg 里的 hung_task 告警是“事后诸葛亮”,但如果能提前收集 D 状态任务的数量和等待点变化趋势,就能在触发阈值之前发现苗头。我喜欢用一个简单脚本定期采样/proc下的进程状态,统计处于 D 状态的任务数和它们的内核栈符号分布,跑一遍就能看到哪些组件在某个时段集中进入阻塞。
内核里也提供了更底层的观测手段,比如 eBPF 可以在调度点挂探针,观测任务从运行到睡眠的切换耗时。这类数据对判断“某条路径是不是越来越慢”非常有价值。如果你所在团队已经维护了内核级监控,完全可以在 hung_task 触发之前,捕捉到 IO 延迟曲线异常抬升的过程。
6.3 演练时如何验证系统的兜底能力
故障演练这一步往往被忽略,但关键时刻它能救命。我建议做两个演练场景。
第一个是模拟存储无响应:通过 fault injection 或者直接给虚拟机做磁盘快照冻结,观察系统在底层 IO 长时间不返回时,应用层是否依然能保持核心功能可用,hung_task 告警是否按预期出现,告警信息里是否包含了足够多的定位线索。第二个是模拟 NFS 服务端故障:关掉 NFS 服务端或切断网络,观察客户端的表现是立即进入重试,还是所有进程都卡进 D 状态,以及监控告警能否在业务影响扩散前触发。
把这两轮演练做完,你会对自己系统的“卡死耐受度”有非常具体的感知。哪些组件的韧性足够,哪些组件一旦底层故障就会全线崩溃,心里就有数了。
6.4 最有效的策略是不让等待发生
说到底,hung_task 检测到的并不是问题的根源,根源是“系统里发生了无界等待”。我个人的经验是:无论调多少参数、写多少巡检脚本,都不如把每一个可能产生无界等待的路径都显式加上超时和重试逻辑。从底层设备驱动到文件系统,再到网络协议栈,每一层都设置合理的超时上限并把它做成可配置项,D 状态的持续时间就会被层层截断,不会被放任到 120 秒以上。
对普通业务开发者来说,最简单的落地方式就是:不要依赖无限阻塞的系统调用。自己去调研一下自己的存储挂载参数、数据库连接超时、网络 RPC 超时设置,很多地方只要显式地配置了超时时间,hung_task 的触发概率就会直线下降。内核的这套自检机制是一道最后防线,但它不该是第一道,更不该是唯一一道防线。