1. 问题背景:服务器卡死时,D状态和iowait为何总是同时出现
干过运维或内核排查的朋友,一定遇到过这种场景:业务突然超时,登录服务器执行uptime一看,load average飙到几十甚至上百;再看top,一堆进程的状态列挂着大写D,wa那一栏的CPU使用率也居高不下。这时候你心里基本有数——不是CPU不够,不是内存不够,是IO卡死了。
D状态的全称是uninterruptible sleep,中文叫不可中断睡眠。进程一旦进入D状态,就意味着它在内核态里等待某个IO事件完成,而且这个等待不能被信号打断。什么情况下会这样?最常见的就是磁盘读写、NFS网络文件系统读写、内存页换入换出、以及部分驱动在等待硬件响应。正常情况下IO很快,D状态一闪而过,几乎看不见。但一旦底层资源出问题,比如磁盘坏道导致读写卡死、NFS服务器宕机、iSCSI链路中断,这些进程就会长期挂在D状态,而且是真真正正的“拉不起来”——你kill -9都没用,因为信号根本没机会被处理。
而iowait又是另外一回事。它表示CPU在等待IO完成时处于空闲状态的时间占比。如果系统里大量进程都在D状态等IO,CPU无事可做,iowait自然就飙高。所以这两个指标经常是“成对出现”的。反过来,iowait高不一定是D状态多——一个进程的密集同步IO也能把iowait拉高,但它本身可能不进入D状态。这个区别很重要,排查时要分清楚。
我要解决的问题很明确:当确认有进程长时间处于D状态、并且系统iowait明显偏高时,怎样准确地打印出这些进程的内核堆栈,以及它们正在读写的文件绝对路径。堆栈告诉你“卡在内核哪段代码里”,路径告诉你“卡在哪个文件上”,两者组合起来,基本就能定位到故障根因。
这篇文章适合谁看?一类是像我一样做Linux系统运维和故障排查的,另一类是搞内核调试、驱动开发、存储相关工作的朋友。前者主要靠这套方法快速止损,后者则能从中获得内核态问题的定位思路。下面我会从原理到实操,把整套排查链路讲清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 定位D状态进程:先搞清楚“谁卡住了”
2.1 用命令快速筛选D状态进程
这一步没有太高深的技巧,核心就是把D状态的进程找出来。我常用的命令是:
bash复制ps -eo state,pid,ppid,comm,wchan:30 | awk '$1=="D"'
这条命令的作用是列出所有状态为D的进程,同时显示当前内核等待点(wchan)的前30个字符。wchan字段是排查D状态的第一手线索,它直接告诉你进程在内核态的哪个函数里等待。
输出大致长这样:
code复制D 12345 1 kworker/u8:2 -
D 23456 1234 mysqld wait_on_page_bit
D 34567 2345 java do_task_dead
如果wchan显示的是wait_on_page_bit,那基本可以断定进程在等页缓存回写或读取;如果是bio_create或者submit_bh,说明在提交块设备IO;如果是rpc_wait_bit_killable,那就是在等NFS的RPC响应。这些信息虽然粗,但能给你一个大方向。
另外可以用top交互模式,按f键把STATE列打开,再按x高亮当前排序列,然后按N按PID排序,一眼就能看到哪些进程卡在D状态。注意top里的D状态会闪烁,因为top本身有采样周期,如果IO时好时坏,你会看到进程状态在D和S之间切换。这种情况说明问题呈间歇性,排查难度会更高。
还需要注意一点:D状态不等于D状态进程。ps里看到的大写D是整体状态,但有些进程的子线程才是真正卡住的,父进程反而是S状态。比如Java应用,主进程可能状态正常,但某个业务线程卡在文件读写上。这时候就要用ps -eLf查看线程级别的状态:
bash复制ps -eLf | awk '$2=="D" || $8=="D"'
或者:
bash复制top -H -p <pid>
以线程维度确认哪一个具体线程卡死,后续分析堆栈时也要注意线程ID和进程ID的区别。
2.2 区分iowait和D状态:先判断根因方向
很多新手一看到iowait高就慌,觉得一定是磁盘坏了。其实iowait高只是一个现象,根源可能有好几个方向。
第一,确实是本地磁盘IO卡死。这个最常见,磁盘坏道、RAID卡固件bug、SSD掉盘都可能导致IO请求迟迟不完成。此时D状态进程的wchan通常会落在wait_on_page_bit、submit_bh_csum、blkdev_issue_flush这样的函数上。
第二,网络文件系统问题。NFS、CIFS这类网络文件系统,一旦对端无响应,本地进程会一直等RPC超时。这个等待时间很长,默认NFS可能等几分钟才报错,期间进程一直卡D。wchan里会看到rpc_wait_bit_killable、xprt_wait_for_reply之类的标志。遇到这种情况,你花再多时间排查本地磁盘也没用,得去看网络和NFS服务端。
第三,内存回收问题。当系统内存严重不足,kswapd在内核态做内存回收时,如果存储设备性能跟不上,也会出现D状态。这种情况wchan可能是__alloc_pages_slowpath或者pageout。
第四,驱动层面的问题。某些硬件驱动的IO等待路径设计得不好,或者固件有bug,也会导致进程D住。这种排查起来最麻烦,通常需要结合dmesg看内核日志。
所以拿到“D状态+iowait高”这个初步结论后,不要急着去打印堆栈。先大致看一遍wchan分布,如果所有D状态进程都挂在同一个函数上,问题很集中;如果分布很散,可能系统级资源出现了问题。
3. 打印D状态进程堆栈:从/proc接口到内核转储
3.1 基础方案:读取/proc/PID/stack与wchan
确认D状态进程PID后,我一般会立刻执行三条命令:
bash复制cat /proc/<pid>/stack
cat /proc/<pid>/wchan
cat /proc/<pid>/syscall
cat /proc/<pid>/stack会输出内核堆栈的符号地址序列,也就是进程在内核态中从系统调用入口到当前阻塞点的函数调用链。这条输出对定位“卡在哪个内核函数”非常直接,比如你可能看到:
code复制[<0>] wait_on_page_bit+0x90/0x100
[<0>] filemap_fault+0x3a4/0x4e0
[<0>] __do_fault+0x3e/0x120
[<0>] __handle_mm_fault+0xcc0/0x1410
[<0>] handle_mm_fault+0xe0/0x1a0
[<0>] __do_page_fault+0x218/0x4d0
[<0>] do_page_fault+0x3a/0x90
[<0>] page_fault+0x66/0x90
这段堆栈告诉你进程在做内存映射文件读取时,触发了缺页中断,然后在wait_on_page_bit上等待页缓存就绪。结合业务来看,就是某个程序在读文件时,内核需要从磁盘加载数据页,但磁盘IO迟迟不返回,于是进程就一直卡着。
/proc/<pid>/wchan更简洁,只显示一个当前等待的函数名,适合快速判断。/proc/<pid>/syscall则能显示进程当前正在执行的系统调用号,以及系统调用的参数。通过它你可以知道进程卡在哪个syscall上,比如read、write、fsync、fdatasync等。但注意,syscall参数里通常是文件描述符,不是文件路径,所以还需要再进一步转换。
读取这些接口的前提是内核开启了对应的配置。/proc/<pid>/stack需要内核配置CONFIG_STACKTRACE,大部分发行版内核都开了,但容器环境里因为权限隔离,可能读不到宿主机上其他进程的信息。另外,读取这个文件需要root权限,普通用户只能看到自己的进程。
3.2 进阶方案:用crash工具分析内核转储
如果问题进程长期卡D,但你通过/proc/<pid>/stack只能看到内核态栈,无法看到用户态完整调用链,这时候就需要更重的手段——抓内核转储,用crash工具离线分析。
在内核问题排查中,crash是绕不开的利器。它可以读取/var/crash下的vmcore文件,也可以直接对运行中的内核做实时分析。实时分析命令是:
bash复制crash /usr/lib/debug/boot/vmlinux-$(uname -r) /proc/kcore
当然,多数生产环境不会随便装crash,因为依赖调试符号包,体积很大。我的常规做法是:如果现场紧急,先通过/proc接口拿初步信息;当问题严重且反复出现,再考虑挂载kexec抓取vmcore。
用crash分析D状态进程的方法也很直接。进入crash后:
code复制crash> ps -D
crash> bt <pid>
ps -D直接列出D状态的进程,bt打印完整内核栈和用户栈。crash的优势在于能完整回溯调用链,还能检查struct task_struct里的字段,比如进程的IO上下文、持有锁的情况、等待队列等,信息量比/proc接口大得多。
但这里有个现实的问题:crash分析依赖内核镜像和调试符号,很多环境没配。所以如果你只是想快速找到“卡在哪个文件上”,下一节的绝对路径定位方法更实用。
3.3 常见坑:为什么/proc/PID/stack里看不到函数名
用/proc/<pid>/stack时,我踩过不少坑,最常见的是输出里只有十六进制地址,没有符号名。比如:
code复制[<0>] 0xffffffffaaaaaaaa
[<0>] 0xffffffffbbbbbbbb
这种情况通常是内核的kptr_restrict参数被设置成了1或2,导致非特权进程无法读取符号地址。解决办法是:
bash复制sysctl -w kernel.kptr_restrict=0
但生产环境改内核参数要谨慎,这个操作本身会带来安全风险。另一个临时方案是用root身份执行,部分系统root不受kptr_restrict限制。如果还是不行,可以装linux-headers或者kernel-debuginfo包,为当前内核装上符号表。
另外还有个坑:某些云服务器的内核是魔改过的,/proc下的stack文件可能完全不存在,或者读取时报No data available。这时候可以退而求其次,用/proc/<pid>/wchan配合/proc/<pid>/stack的地址段粗略推断调用位置,或者直接上ftrace和perf来trace。
4. 找到相关文件的绝对路径:从fd到inode的关键链路
4.1 文件描述符与绝对路径的关系
拿到D状态进程的PID后,最核心的一步是找到它正在读写的文件。Linux里,用户态进程访问文件都是通过文件描述符(fd)进行的。内核的struct file结构体描述了文件当前的状态(包括打开的flag、当前位置、inode指针等),而/proc/<pid>/fd/目录下,每个fd都会以一个符号链接的形式存在,链接的目标就是文件的路径。
所以定位文件绝对路径的基础命令是:
bash复制ls -l /proc/<pid>/fd/
输出大致是:
code复制lrwx------ 1 root root 64 Jul 12 10:23 3 -> /var/lib/mysql/ibdata1
lrwx------ 1 root root 64 Jul 12 10:23 4 -> /var/log/mysql/error.log
符号链接右侧就是打开文件的路径。这个方法在99%的情况下有效,但有例外:一是文件已经被删除,链接目标会变成/path/to/file (deleted);二是文件是匿名inode,比如socket、pipe、eventfd,链接目标显示为socket:[12345]、pipe:[6789]。
对于D状态进程,重点要看的是普通文件。如果发现(deleted)字样,说明有进程在文件被删除后仍然持有fd,磁盘空间没有被真正释放。这种情况也经常导致后续IO问题,排查时要留意。
4.2 readlink命令与批量获取绝对路径
ls -l虽然直观,但输出格式包含很多干扰信息,不适合脚本化处理。我更喜欢用readlink直接获取路径:
bash复制readlink /proc/<pid>/fd/3
输出就是纯粹的路径字符串,非常干净。如果想把一个进程打开的所有普通文件绝对路径都列出来,可以用一段简单的shell脚本。下面是我实际工作里常用的写法:
bash复制for fd in /proc/<pid>/fd/*; do
target=$(readlink "$fd")
if [[ "$target" != socket:* && "$target" != pipe:* && "$target" != anon_inode:* ]]; then
echo "$target"
fi
done | sort -u
这里过滤掉socket、pipe和anon_inode,因为这些不是普通文件,对定位D状态根因没有直接帮助。输出结果按路径排序并去重,一眼就能看到进程在读写哪些文件。
要注意的是,readlink读取/proc/<pid>/fd下的符号链接,在极少数情况下会失败,报No such file or directory。原因是进程恰好在调用close()关闭这个fd,或者fd已经被复用。这种情况重试一次即可,不必纠结。
4.3 lsof与/proc接口的对比:什么时候该用谁
很多人第一反应是用lsof,这是对的。但lsof在D状态进程排查中有个问题:它可能卡住。原因是lsof在遍历fd时,会尝试读取每个进程的fd目录和内存映射信息,如果系统本身IO已经卡死,lsof自己也会陷入漫长的等待。
我实际遇到过几次,输入lsof -p <pid>之后,终端就卡住了,进退两难。相比之下,直接ls -l /proc/<pid>/fd更轻量,因为/proc是内存文件系统,不涉及磁盘IO,即使底层磁盘彻底卡死,ls也能快速返回。
所以我的建议是:先试ls -l /proc/<pid>/fd,能用就不要用lsof。如果确实需要lsof的全量信息(比如查看内存映射文件),可以加超时保护:
bash复制timeout 10 lsof -p <pid>
timeout 10保证最多等10秒,防止命令挂死。同理,strace -p <pid>也不建议在排查D状态时使用,因为它会尝试ptrace附加到目标进程,在IO卡死的场景下,ptrace本身可能就会失败或卡顿。
4.4 特殊情况:socket和inode对应的文件路径
前面提到过,D状态进程打开的fd有可能是socket。这种情况在NFS、iSCSI、数据库主从复制等场景中非常常见。socket的链接目标显示为socket:[inode编号],比如socket:[284712345]。
如果D状态进程卡在socket上,说明它在等待网络IO完成。结合前面wchan里的rpc_wait_bit_killable或者tcp_retransmit_skb之类的函数,就能判断是在等哪个网络服务。此时可以用ss -tulnp查看对应socket的本地和远端地址,再进一步找到对端服务。
还有一种情况是进程在等一个eventfd或者epoll,这种通常和异步IO框架有关。比如用io_uring或libaio的程序,等待IO完成事件的机制。此时堆栈里会出现io_cqring_wait、do_epoll_wait之类的函数。
5. 如何把堆栈和文件路径串成一条完整的证据链
5.1 实操:一次完整的D状态排查过程
前面讲的都是单个工具的使用,真正生产排查时,要把这些手段组合起来。我复盘一次典型的D状态排查过程,供参考。
假设业务反馈MySQL响应超时,登录到数据库服务器,top显示wa高达80%,有两个mysqld进程处于D状态。
第一步,确认D状态进程和wchan:
bash复制ps -eo state,pid,comm,wchan:30 | awk '$1=="D"'
输出:
code复制D 12345 mysqld wait_on_page_bit
D 23456 mysqld do_task_dead
第二步,读取内核堆栈:
bash复制cat /proc/12345/stack
看到的关键栈帧是wait_on_page_bit、filemap_fault、__do_fault,说明在读取文件时缺页,等待页面从磁盘加载。
第三步,查fd找到文件路径:
bash复制ls -l /proc/12345/fd/ | grep -v 'socket\|pipe\|anon_inode'
输出里发现:
code复制10 -> /data/mysql/ibdata1 (deleted)
到这里基本就有结论了:MySQL的数据文件在磁盘上被删除了,但mysqld进程还在用旧fd读写这块空间。为什么会出现(deleted)?很可能是运维或DBA误删了文件,空间没释放,导致后续对该文件的IO请求落在已经被删除的inode上,部分存储环境下这种行为会引发IO异常。更麻烦的是,由于fd还持有旧inode,新创建的同名文件跟旧fd毫无关系,MySQL的写入还是会写到那个“幽灵”inode上。
这个案例里,堆栈告诉你卡在wait_on_page_bit,fd告诉你卡在/data/mysql/ibdata1 (deleted),两者结合,根因瞬间清晰。
第四步,验证空间是否被占满:
bash复制df -h /data
lsof +L1 | grep deleted
df看文件系统整体空间,lsof +L1列出所有被删除但仍被占用的大文件。这两个一跑,问题就完全实锤了。
5.2 堆栈方向对比:不同D状态场景的特征解析
同样是D状态,堆栈不同,排查方向完全不同。我把日常工作中最常遇到的几类场景列成一张表,方便对照参考。
| 场景 | 典型wchan/栈帧 | 判断方向 | 推荐路径验证方式 |
|---|---|---|---|
| 本地磁盘读写IO卡死 | wait_on_page_bit / blkdev_issue_flush |
磁盘坏道、RAID卡异常、SSD掉盘 | 查看对应fd指向的磁盘文件,用iostat -x确认具体磁盘 |
| NFS远程读写超时 | rpc_wait_bit_killable / xprt_wait_for_reply |
NFS服务端无响应、网络丢包 | 查看socket fd,用ss看对端IP和端口 |
| 内存回收导致阻塞 | __alloc_pages_slowpath / pageout |
内存不足,回收线程卡在存储IO | 结合free看内存情况,确认是否大量Swap |
| 文件系统锁冲突 | __mutex_lock_slowpath / rwsem_down_read_failed |
文件系统内部锁竞争,多进程叠加IO | 同时查看多个D状态进程的fd是否有重叠文件 |
| io_uring异步IO等待 | io_cqring_wait / io_sq_thread |
异步IO框架本身等待 | 查看eventfd,确认io_uring队列实例 |
这张表的价值在于,拿到堆栈后能快速缩小排查范围,而不是盲目去翻日志或重启服务。比如看到rpc_wait_bit_killable,你再怎么查本地磁盘都是浪费时间,直接顺着网络方向走。
5.3 避免误判:堆栈瞬间状态与持续状态的差异
这里要特别强调一个容易误判的点:/proc/<pid>/stack显示的只是“当前瞬间”的内核堆栈。如果IO是间歇性卡顿,你cat的时候可能正好赶上进程不在D状态,那么堆栈显示的就是普通内核栈,看不出问题。
解决方法是多采样几次,观察趋势。可以用一个简单的循环:
bash复制for i in {1..5}; do echo "--- $i ---"; cat /proc/<pid>/stack; sleep 1; done
如果每次采样的栈帧都在变化,说明进程在正常运行,只是偶发卡顿;如果每次采样都停在同一个栈帧上,那说明进程是真的卡死了。同理,D状态也应该多次确认,ps的一两次采样可能不准。
还有一个容易忽略的问题:/proc/<pid>/stack里的栈帧是内核栈,不包含用户态调用链。如果你想知道用户态哪个函数发起的系统调用,需要结合/proc/<pid>/syscall里的系统调用号和参数,或者用perf在用户态抓取调用链。但实际情况中,D状态卡死往往发生在内核态,用户态调用链已经不太重要,优先看内核栈和文件路径就足够解决问题了。
6. 实战经验:这些坑我替你踩过了
6.1 权限与安全限制导致的读取失败
我在容器环境里排查过不少D状态问题,第一道坎就是权限。容器里即使你是root,看到的/proc也是被隔离过的,宿主机上其他进程的/proc/<pid>根本不存在。这时候只能在宿主机上执行排查,或者让宿主机管理员配合。
另外,kptr_restrict和yama的ptrace_scope都会影响信息读取。ptrace_scope=1时,非root用户无法ptrace其他用户启动的进程,strace和gdb都会受限。而kptr_restrict=1时,/proc/<pid>/stack的输出可能被脱敏,只剩地址没有符号。
如果遇到这些限制,我的处理顺序是:先确认内核参数,再考虑用root执行。如果root也读不到,检查是不是SELinux或AppArmor策略拦截了。生产环境里,这种底层权限问题往往比技术本身更让人头疼。
6.2 文件路径定位不到时的兜底方案
有时候D状态进程的fd目录下,普通文件路径全是 (deleted),或者干脆全是socket,根本找不到目标文件路径。此时可以用/proc/<pid>/io来确认IO字节数,再结合/proc/<pid>/mountinfo反查挂载点。
/proc/<pid>/io会输出进程的rchar、wchar、read_bytes、write_bytes等指标。如果read_bytes在持续增长,说明这个进程确实在进行读操作,但路径信息丢失。这时候可以退而求其次,用lsof +L1全系统扫描已删除但仍被占用的文件,并结合/proc/<pid>/fdinfo查看fd对应的文件偏移和flags。
bash复制cat /proc/<pid>/fdinfo/10
fdinfo里的pos字段表示当前文件偏移,把这个偏移和文件大小做对比,能判断进程在文件中的读写位置,这在数据库损坏修复时很有帮助。
6.3 如何判断是否为内核bug而不是硬件故障
最后说一个判断维度的经验。当你通过堆栈和文件路径定位到某个具体设备的IO一直不返回时,不要急着下“硬件坏了”的结论。先用dmesg看有没有对应的错误日志,再用smartctl检查磁盘健康状态,用iostat -x 1看磁盘的svctm、await、%util指标。
我遇到过一种情况:堆栈卡在wait_on_page_bit,fd指向一个普通数据文件,磁盘%util接近100%,但smartctl完全健康。后来发现是RAID卡缓存策略设置为write-through,导致随机写性能雪崩,进程全堵在磁盘IO上。把缓存策略改成write-back后,问题立刻消失。
所以,堆栈和路径是“标”,硬件和驱动状态是“本”。拿到证据链后,还要结合系统底层的健康状态做综合判断,才能真正解决线上故障,而不是治标不治本。
7. 输出一份清晰的排查报告:模板与示例
7.1 排查报告应包含的要素
D状态+iowait排查,最后往往要输出一份报告,给团队或上级说明情况。报告不需要长篇大论,但必须包含下面几个要素:
- 现象描述:什么时间、什么业务、什么指标异常
- D状态进程清单:PID、进程名、状态持续时间
- 内核堆栈摘要:主要栈帧和等待点
- 文件路径清单:进程打开的普通文件路径,标注哪些是deleted
- 根因分析:堆栈和路径之间的逻辑关系
- 处置建议:下一步操作或修复方案
这里有一个技巧:报告里一定要附上原始命令和输出,方便其他人复核。因为排查现场很多信息是转瞬即逝的,你事后凭记忆补的报告,可信度远低于“当时截下来的原始输出”。
7.2 一个实际的报告示例片段
下面是我处理过的一个真实案例精简版。现象是某后台批处理任务全部卡住,服务器load高、wa持续80%以上。
D状态进程:
bash复制ps -eo state,pid,comm,wchan:30 | awk '$1=="D"'
D 28765 java wait_on_page_bit
D 28766 java wait_on_page_bit
D 28788 java wait_on_page_bit
堆栈信息:
text复制[<0>] wait_on_page_bit+0x90/0x100
[<0>] filemap_fault+0x3a4/0x4e0
[<0>] __do_fault+0x3e/0x120
文件路径:
bash复制ls -l /proc/28765/fd/ | grep -v 'socket\|pipe'
12 -> /data/tmp/input_20230618.dat (deleted)
根因分析:Java批处理任务正在读取临时文件/data/tmp/input_20230618.dat,该文件由于磁盘空间不足被某清理脚本删除,但Java进程仍持有旧fd读取数据。文件系统的inode空间释放与磁盘回写冲突,导致页面I/O长时间无法完成,进而引发整机iowait飙高。
处置建议:重启相关Java进程(重启后fd会释放),释放被删除文件占用的inode资源,并禁止在线清理被进程持有的数据文件。
这个报告的核心信息量不大,但每条都是可追溯、可复现的。排查报告就该这样写。
8. 最后的排查小技巧
有一次排查生产环境时,发现多个D状态进程的堆栈都在wait_on_page_bit,但怎么都定位不到磁盘设备。后来我发现问题出在cgroup的blkio限制上:某个cgroup的读带宽被限流到极低值,导致该cgroup里的进程大量阻塞在IO上,但宿主机其他cgroup一切正常。
所以提醒一句:排查D状态时,除了进程级别的堆栈和文件路径,如果系统里启用了cgroup或systemd的服务资源限制,一定要查一下对应的IO限制配置,systemctl status <服务名>或cat /sys/fs/cgroup/blkio/<路径>/blkio.throttle.read_bps_device都是必看的。这类“看不见的限流”最容易让人绕远路。
另外,如果你在排查过程中发现D状态进程的fd指向的文件路径位于/var/log或/tmp,大概率是日志或临时文件占用导致的空间问题;如果指向数据库文件,先确认数据库层的表空间管理是否异常;如果指向NFS挂载点,优先查NFS服务端和网络质量。
这套方法在一次又一次的故障中越来越顺手,希望你下次遇到“load高、wa高、进程D住”的现场时,能靠着堆栈和绝对路径两条线,三分钟内锁定问题源头。
