几年前我接过一次教科书级的排查任务:线上数据库节点突然卡死,uptime显示负载飙到 60 多,但 CPU 空闲率接近 100%。top 里一堆进程处于 D 状态,iowait 居高不下,整个系统像被按了暂停键。最痛苦的是,光看到 D 状态根本不知道它们卡在哪,pstack 打不出来,gdb attach 直接卡死,最后靠打印内核堆栈和文件绝对路径才把问题定位到一块即将损坏的磁盘上。
这次经历让我把“打印长时间 D 状态且 iowait 高的堆栈及相关文件绝对路径”这套排查方法彻底玩明白了。今天这篇就系统拆一下,从原理、工具、实操到踩坑一条龙讲清楚,希望能帮你少走弯路。这套方法适合所有 Linux 运维、SRE、内核排查场景,尤其是遇到存储卡顿、NFS 异常、文件系统 hang 住、负载飙高但 CPU 不忙的时候。
1. 先把问题看明白:D 状态和 iowait 到底在告诉你什么
1.1 什么是 D 状态?为什么它比僵尸进程更让人头疼
Linux 进程状态里,D 代表 TASK_UNINTERRUPTIBLE,中文叫不可中断睡眠。这个状态设计的初衷很朴素:进程正在内核里执行一段关键操作,比如等待磁盘 IO 完成、等待 NFS 网络响应、等待某个内核锁释放,这段操作如果被信号打断,会导致数据不一致甚至内核崩溃。所以内核干脆屏蔽了所有信号,包括 kill -9。
这就是 D 状态最恶心的地方:你明明看到它在,但没有任何用户态手段能干掉它。kill -9 杀不掉,pstack 打印不出用户态堆栈,gdb attach 挂上去还会把自己卡死。因为进程卡在内核态,用户态调试器根本插不进手。
判断 D 状态的方式很简单,top 或 ps 里看 STAT 列:
bash复制ps -eo pid,stat,wchan:32,comm
如果 STAT 列出现 D,后面 wchan 列通常会显示一个内核函数名。比如 io_schedule、wait_on_page_bit、rpc_wait_bit_killable,这些函数名就是排查的第一条线索。
一个短期 D 状态很正常,进程读写磁盘偶尔等几百毫秒没什么。但如果进程长时间处于 D 状态,比如我遇到过的卡了十几分钟,那基本可以断定底层 IO 链路出了大问题——磁盘损坏、控制器故障、NFS 服务端无响应、文件系统死锁,甚至内核自身的 bug。
1.2 iowait 高不等于磁盘坏了,关键要看谁在等
iowait 是 CPU 等待 IO 完成的时间占比。它的计算方式是 CPU 处于空闲状态、但系统至少有一个进程在等待 IO 时,这段时间被记为 iowait。所以 iowait 高说明两件事:CPU 确实在闲着,同时确实有人卡在 IO 上。
但很多人容易陷入一个误区:iowait 高就一定是磁盘坏了。我见过不少案例,iowait 高其实是因为内存压力过大导致频繁换页,或者某个进程在反复读取一个被 evict 出 page cache 的大文件,甚至是因为 NFS 挂载点上的一个死循环读操作拖垮了整条链路。iowait 只是现象,真正定位问题必须落到进程,这就是为什么我们要打印 D 状态进程的堆栈。
iostat -x 1 能看设备利用率和平均等待时间,iotop -o 能按 IO 大小排序找出读写最凶的进程。但这些实时工具只能告诉你“谁在产生 IO”,不能告诉你“谁在等 IO 等到卡死”。要回答第二个问题,必须走 /proc/PID/ 这条线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 准备工作:抓 D 状态进程前必须知道的几个细节
2.1 为什么直接打用户态堆栈不够,要打内核栈
很多刚接触排查的朋友第一反应是 pstack 或 jstack。但 D 状态进程卡在内核态,用户态堆栈基本停在 read() 或 write() 系统调用上,信息量等于零。别说 D 状态了,就算是 R 状态(正在运行)的进程,用户态堆栈很多时候也看不出瓶颈所在。
真正有用的是内核态堆栈。内核栈记录了进程在陷入内核后,经过了哪些函数调用、最终停在哪一层。比如堆栈顶部是 io_schedule,说明 submit_bio 下发 IO 后卡等;堆栈顶部是 mutex_lock,说明在等一把内核锁;堆栈里出现 nfs 相关函数,说明卡在 NFS 的 RPC 等待上。这些信息是用户态工具完全给不了的。
打印内核栈的标准接口是 /proc/PID/stack。但这里有个硬性前提:内核需要开启 CONFIG_STACKTRACE。绝大多数发行版默认是开的,但如果你的内核是定制编译的,可能没开。没开的话读出来的文件是空的,下面第 4 节会细说怎么绕开。
2.2 需要提前准备的命令和内核开关
抓取前,我建议把命令先测一遍,别等出了事才发现权限不够或者工具缺失。下面是我常用的一套:
bash复制# 查看进程状态、等待的内核函数
ps -eo pid,ppid,stat,wchan:32,comm
# 打印指定进程的内核堆栈
cat /proc/<PID>/stack
# 查看进程打开的 fd 和相关文件路径
ls -l /proc/<PID>/fd
# 查看进程 IO 统计
cat /proc/<PID>/io
/proc/PID/stack 和 /proc/PID/io 都需要 root 权限,普通用户看不到。另外还要确认内核 /proc/sys/kernel/stack_tracer_enabled 是否为 1。这个开关控制 stack 文件是否输出完整调用栈,不开的话输出内容很有限:
bash复制echo 1 > /proc/sys/kernel/stack_tracer_enabled
还有一个容易被忽略的细节:ls -l /proc/PID/fd 在某些场景下会卡住。因为 D 状态进程的 fd 信息读取依赖内核的 dentry 和 inode 锁,如果文件系统自身已经 hang 住,这个命令可能会阻塞。实测下来,用 timeout 2 ls -l /proc/PID/fd 给命令加个超时更稳妥。
2.3 用 sysrq 一键抓取全系统 D 状态进程
如果系统里 D 状态进程特别多,一个个 cat /proc/PID/stack 效率太低。这时可以用内核的 SysRq 机制,一条命令把整个系统所有 D 状态进程的堆栈一次性打出来:
bash复制echo w > /proc/sysrq-trigger
这个操作会触发内核把当前所有处于 D 状态和 R 状态的进程堆栈写到内核日志里,通过 dmesg 查看。输出里会带上进程名、PID 和完整内核栈,信息密度非常高。
不过要注意,这个命令在生产环境要慎用。某些内核版本在 SysRq-w 触发时会有短暂的性能抖动,而且 dmesg 刷屏会影响日志采集。我一般是在进程数超过 20 个、明显是系统级故障时才用。少数受影响进程的话,逐个 cat /proc/PID/stack 更精准。
3. 实操:把“凶手”揪出来并找到它依赖的文件
3.1 发现嫌疑进程的几种途经
先说怎么快速找到哪些进程在 D 状态。方法很多,优先级从高到低排列:
第一招,top 按状态排序。启动 top 后按 f,选择 S(进程状态)列作为排序字段,或者直接观察 %WA(iowait)超过 30% 时的进程列表。不过 top 在大量进程卡死时刷新会变慢,体验不好。
第二招,一行命令列出所有 D 状态进程:
bash复制ps -eo pid,stat,wchan:32,comm | awk '$2 ~ /^D/'
awk 匹配以 D 开头的状态字段,可以快速把嫌疑名单拉出来。我一般还会加个 -o etimes 看进程已运行时长,用来区分是刚刚进入 D 状态还是已经卡了很久。这个 etimes 字段输出的是进程启动到现在的秒数,和 D 状态持续时间不是一回事,但结合 top 里进程的更新时间,基本能判断卡了多久。
第三招,直接看 /proc/*/stat 循环采样。写个 for 循环,隔几秒采样一次,统计状态字段里始终是 D 的进程,这些就是长时间 D 状态的“老赖”,优先级最高。
3.2 通过 /proc/PID/stack 打印内核堆栈
拿到 PID 之后,核心操作就是读 /proc/PID/stack。这是最关键的一步,直接决定排查方向,输出类似于下面这样:
bash复制$ cat /proc/12345/stack
[<0>] io_schedule+0x12/0x20
[<0>] folio_wait_bit_common+0x1b0/0x380
[<0>] filemap_fdatawait_range+0x99/0xf0
[<0>] ext4_filemap_fdatawait_range+0x2b/0x40
[<0>] ext4_sync_file+0x81/0x140
[<0>] __x64_sys_fsync+0x3a/0x80
[<0>] do_syscall_64+0x5c/0x90
[<0>] entry_SYSCALL_64_after_hwframe+0x72/0x0
看到这个栈,脑子里要快速做一层映射。栈顶是 io_schedule,说明进程在等待块设备 IO 完成;folio_wait_bit_common 和 filemap_fdatawait_range 说明在等待文件数据回写;ext4_sync_file 说明是 fsync 触发。组合起来就是:某个进程调用了 fsync,数据卡在 ext4 回写阶段,底层 IO 一直没返回,于是进程在 D 状态里耗着。
不同堆栈的定位方向差异很大,我做了一个速查表方便对照:
| 堆栈顶部函数 | 可能的瓶颈 | 排查方向 |
|---|---|---|
io_schedule |
块设备 IO 未完成 | iostat -x 1 查看设备健康度,检查磁盘/SAS 卡 |
mutex_lock / rwsem_down_* |
内核锁竞争 | perf lock 分析锁竞争,检查文件系统是否死锁 |
wait_on_page_bit |
page cache 回写/内存回收 | 检查内存压力、脏页比例 |
rpc_wait_bit_killable |
NFS RPC 无响应 | nfsstat -m、检查 NFS 服务端 |
__lock_page_killable |
页缓存预读竞争 | 检查是否大量读同一文件 |
blk_mq_wait_for_tags |
块设备队列已满 | 检查磁盘队列深度、NVMe 固件 |
3.3 通过 /proc/PID/fd 定位相关文件的绝对路径
堆栈定位了内核层面的卡点,但你还需要知道进程在操作哪个文件。/proc/PID/fd 目录下全是符号链接,指向进程打开的文件,用 ls -l 直接读:
bash复制$ ls -l /proc/12345/fd
total 0
lrwx------ 1 root root 64 Feb 25 10:30 0 -> /dev/null
lrwx------ 1 root root 64 Feb 25 10:30 1 -> /data/logs/app.log
lrwx------ 1 root root 64 Feb 25 10:30 2 -> /data/logs/app.log
lrwx------ 1 root root 64 Feb 25 10:30 3 -> /data/db/record.dat
lrwx------ 1 root root 64 Feb 25 10:30 4 -> /data/db/record.dat
这个方法在绝大多数场景下都好用,但有两个例外需要注意:一是进程打开的 fd 可能已经 unlink 掉了,符号链接后面会出现 (deleted) 后缀,说明文件被删了但 fd 还开着,这本身就是个线索——很可能是日志轮转或临时文件泄漏;二是 D 状态进程可能卡在未打开文件的内部操作上,比如空闲回收、日志刷盘,这种 fd 目录里看不出什么有效信息,需要配合堆栈里的函数名判断是哪个文件系统的操作。
还有一个更细的方法:/proc/PID/io 里的 rchar 和 wchar 字段能看累计读写的字节数。如果短时间内采样两次,差值很大,基本能确定这个进程是 IO 大户。结合 fd 目录里的路径,就能锁定是哪个文件在被疯狂读写。
3.4 串起来:一份完整的排查记录
用一个虚构但高度还原的案例把整套流程串起来。假设 app-server 进程 PID 是 23456,top 显示 D 状态已经超过 10 分钟,iostat 显示 sdb 设备等待时间高达 8000ms。依次执行:
第一步,确认状态和等待点:
bash复制$ ps -eo pid,stat,wchan:24,comm | grep 23456
23456 D fsync_bdev app-server
wchan 列显示 fsync_bdev,说明进程卡在块设备的 fsync 上,方向直接指向存储层。
第二步,打印完整内核栈确认函数调用链:
bash复制$ cat /proc/23456/stack
[<0>] io_schedule+0x12/0x20
[<0>] wait_on_page_bit+0x14/0x20
[<0>] filemap_fdatawait_range+0x99/0xf0
[<0>] ext4_sync_file+0x81/0x140
[<0>] __x64_sys_fsync+0x3a/0x80
[<0>] do_syscall_64+0x5c/0x90
[<0>] entry_SYSCALL_64_after_hwframe+0x72/0x0
堆栈里没有存储驱动层(如 scsi、sd、virtio_blk)的函数,而是停在 io_schedule,说明请求已经下发但没返回。
第三步,查 fd 找到相关文件绝对路径:
bash复制$ ls -l /proc/23456/fd | grep -E '\.(dat|db|log)'
lrwx------ 1 root root 64 Feb 25 10:30 13 -> /data/mysql/ibdata1
lrwx------ 1 root root 64 Feb 25 10:30 14 -> /data/mysql/mysql-bin.000123
lrwx------ 1 root root 64 Feb 25 10:30 15 -> /data/mysql/undo_001
此时基本可以断定:应用进程对 MySQL 数据目录下的 InnoDB 文件执行 fsync,IO 下发到 sdb 设备后没有返回。结合 iostat 显示的异常等待时间,最终定位到 sdb 磁盘硬件问题。换盘后一切恢复正常。
4. 实战中常见的幺蛾子与排查技巧实录
4.1 为什么有时候 cat /proc/PID/stack 没有输出
这是我被问到最多的一个问题。明明确认进程是 D 状态,但 /proc/PID/stack 读出来是空的。原因主要有三类:
第一类,内核没开 CONFIG_STACKTRACE。这个没法在运行期打开,只能重编内核或者在启动参数里加 stacktrace。但别慌,虽然 stack 文件是空的,wchan 仍然能输出等待函数名。wchan 是内核在进程切换时记录的,不依赖 CONFIG_STACKTRACE,有它在至少能判断个大概方向。
第二类,权限不够。/proc/PID/stack 有严格的权限控制,必须是 root 或者进程的 owner。很多人用普通用户跑排查命令,踩了这个坑。
第三类,内核在 CONFIG_STACKTRACE_VERBOSE 关闭时,即使有 stack 输出,信息也比较精简。如果实在需要完整栈,可以考虑临时加载 bpf_stack_map 或者用 eBPF 工具采集。
4.2 权限、容器、内核版本带来的坑
容器场景是重灾区。你在容器里 cat /proc/PID/stack,看到的是容器内 PID 对应的宿主进程视图,如果 PID 命名空间隔离,还看不到宿主机真实 PID。我踩过最深的坑是:在容器里排查一个进程的 D 状态,/proc/PID/stack 里显示的内核栈指向了容器 namespace 相关的函数(比如 mnt_want_write),但实际瓶颈在宿主机存储层。所以容器场景下,一定要跑到宿主机上去抓真实 PID,再查看 /proc/真实PID/stack。
另外,不同内核版本对 /proc/PID/stack 的输出格式差异很大。CentOS 7 上的 3.10 内核输出格式和 Ubuntu 20.04 的 5.4 内核就完全不一样。3.10 内核的函数名前经常带 [<ffffffff8108c5e0>] 这类地址前缀,5.4 内核则更简洁。关键是别被格式差异带偏,核心是看函数名和调用层级。
还有一点容易被忽略:/proc/PID/stack 读到的堆栈是所有 CPU 上该进程的历史栈快照,不是实时的。也就是说,即使进程刚刚从 D 状态退出,这个文件里可能还保留着之前的栈。所以排查时要结合 wchan 和当前状态一起判断,不要死磕 stack 文件。
4.3 从堆栈到定位问题的最终判断
很多人卡在这一步:堆栈打印出来了,函数也认得了,但接下来怎么判断是磁盘坏了还是文件系统 bug 还是 NFS 问题?我的经验是把堆栈和几个外部信号交叉验证。
堆栈里能看到存储驱动(sd_*、virtio_blk、nvme_*)相关的函数,说明问题大概率出在设备层,配合 smartctl -a 看磁盘健康状态,dmesg 里有没有 IO error、blk_update_request I/O error 之类的关键词。
堆栈里只到 io_schedule,往下没有存储驱动痕迹,有两种可能:一是请求在通用块层排队还没下发,二是设备完成中断丢失。这种时候需要看 /sys/block/sdX/stat 里的 io_in_flight 字段,如果持续大于 0 且不变,说明请求挂在设备层;如果为 0,说明块层排队堵住了。
堆栈里出现 nfs 相关函数,比如 nfs_await_for_request、rpc_wait_bit_killable,直接查 nfsstat -m 看挂载状态,service rpcbind status 看 RPC 服务是否正常。很多时候是 NFS 服务端挂死导致客户端 D 状态。
堆栈里出现 kworker 或 flush- 开头的线程,而且是在 wb_workfn 或 flusher 函数里,说明是后台回写线程卡住,而不是业务进程本身。这种要重点检查磁盘是否是直写模式、脏页比例是否过高,cat /proc/sys/vm/dirty_ratio 和 dirty_expire_centisecs 这两个参数要重点看。
4.4 采集堆栈时的两个实用技巧
最后分享两个压箱底的技巧。
第一个技巧,如果 D 状态进程卡在 NFS 或虚拟化存储(比如云盘)上,ls -l /proc/PID/fd 可能会长时间无响应。这时候不要死等,用 timeout 2 ls -l /proc/PID/fd 加超时,先拿到 fd 编号列表,路径等进程恢复后再补。fd 编号本身就有参考价值,比如 fd 是 13-15 这个区间的,大概率是新增的动态文件,和固定日志文件能区分开。
第二个技巧,如果内核没有 CONFIG_STACKTRACE,但又想看堆栈,可以用 /proc/PID/wchan 加 /proc/PID/syscall 组合。/proc/PID/syscall 能看到进程当前系统调用号和参数,比如显示 syscall 72(fsync)加 fd 号 13,再配合 /proc/PID/fd/13 就能定位到文件。虽然信息不如完整堆栈详细,但在关键时候能救急。
5. 结尾:写在最后的一点经验
这套排查方法论我前前后后用过不下十次,有两次成功在业务无感知的情况下把磁盘故障提前揪出来,也有一次因为过度依赖 stack 文件而忽略 wchan 线索,多花了半小时定位。回过头来看,最核心的经验就是一条:D 状态和 iowait 只是症状,堆栈和文件路径才是病因;排查时不要在“iowait 高”这个现象上纠结太久,尽早打到进程和文件层面,问题往往很快浮出水面。
我的建议是,新环境部署完以后,先把这套命令跑通一遍,确认 stack 文件可读、sysrq 可用、fd 目录有权限,做成一个一键排查脚本放在运维机里。等真正出事的时候,你就知道提前跑通这些基础步骤有多值钱了。排查工具再好也只是辅助,真正能救你的是对“进程在内核里到底在等什么”这一底层逻辑的理解。遇到实在分析不了的堆栈,也别慌,把 /proc/PID/stack、wchan、fd 列表、dmesg 四样东西备齐再发帖求助,基本已经包含了你能给出的最大信息量了。
