在上下文切换的文章中,学习并分析了系统 CPU 使用率高的问题,剩下的等待 I/O 的 CPU 使用率(以下简称为 iowait)升高,也是最常见的一个服务器性能问题。今天就来看一个多进程 I/O 的案例,并分析这种情况。
1. 进程状态
当 iowait 升高时,进程很可能因为得不到硬件的响应,而长时间处于不可中断状态。从 ps 或者 top 命令的输出中,可以发现它们都处于 D 状态,也就是不可中断状态(Uninterruptible Sleep)。
1.1 进程状态介绍
top 和 ps 是最常用的查看进程状态的工具。
$ topPID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
28961 root 20 0 43816 3148 4040 R 3.2 0.0 0:00.01 top620 root 20 0 37280 33676 908 D 0.3 0.4 0:00.01 app1 root 20 0 160072 9416 6752 S 0.0 0.1 0:37.64 systemd1896 root 20 0 0 0 0 Z 0.0 0.0 0:00.00 devapp2 root 20 0 0 0 0 S 0.0 0.0 0:00.10 kthreadd4 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 kworker/0:0H6 root 0 -20 0 0 0 I 0.0 0.0 0:00.00 mm_percpu_wq7 root 20 0 0 0 0 S 0.0 0.0 0:06.37 ksoftirqd/0
- R:是 Running 或 Runnable 的缩写,表示进程在 CPU 的就绪队列中,正在运行或者正在等待运行。
- D:是 Disk Sleep 的缩写,也就是不可中断状态睡眠(Uninterruptible Sleep),一般表示进程正在跟硬件交互,并且交互过程不允许被其他进程或中断打断。
- Z:是 Zombie 的缩写,它表示僵尸进程,也就是进程实际上已经结束了,但是父进程还没有回收它的资源(比如进程的描述符、PID 等)。
- S:是 Interruptible Sleep 的缩写,也就是可中断状态睡眠,表示进程因为等待某个事件而被系统挂起。当进程等待的事件发生时,它会被唤醒并进入 R 状态。
- I:是 Idle 的缩写,也就是空闲状态,用在不可中断睡眠的内核线程上。前面说了,硬件交互导致的不可中断进程用 D 表示,但对某些内核线程来说,它们有可能实际上并没有任何负载,用 Idle 正是为了区分这种情况。要注意,D 状态的进程会导致平均负载升高, I 状态的进程却不会。
- T或者t:Stopped 或 Traced 的缩写,表示进程处于暂停或者跟踪状态。向一个进程发送 SIGSTOP 信号,它就会因响应这个信号变成暂停状态(Stopped);再向它发送 SIGCONT 信号,进程又会恢复运行(如果进程是终端里直接启动的,则需要你用 fg 命令,恢复到前台运行)。
- X:是 Dead 的缩写,表示进程已经消亡,所以你不会在 top 或者 ps 命令中看到它。
1.1.1 D 不可中断状态
该状态是为了保证进程数据与硬件状态一致,而且分成两个场景
- 正常场景:
- 不可中断状态在很短时间内就会结束。所以,短时的不可中断状态进程,我们一般可以忽略。
- 异常场景
- 系统或硬件发生了故障,进程可能会在不可中断状态保持很久,甚至导致系统中出现大量不可中断进程。这时,就得注意下,系统是不是出现了 I/O 等性能问题。
1.1.2 Z 僵尸进程
这是多进程应用很容易碰到的问题。主要也分为两个场景
- 正常场景
- 当一个进程创建了子进程后,它应该通过系统调用 wait() 或者 waitpid() 等待子进程结束,回收子进程的资源;而子进程在结束时,会向它的父进程发送 SIGCHLD 信号,所以,父进程还可以注册 SIGCHLD 信号的处理函数,异步回收资源。
- 异常场景
- 如果父进程没这么做,或是子进程执行太快,父进程还没来得及处理子进程状态,子进程就已经提前退出,那这时的子进程就会变成僵尸进程。换句话说,父亲应该一直对儿子负责,善始善终,如果不作为或者跟不上,都会导致“问题少年”的出现。
通常,僵尸进程持续的时间都比较短,在父进程回收它的资源后就会消亡;或者在父进程退出后,由 init 进程回收后也会消亡。
僵尸进程的危害:大量的僵尸进程会用尽 PID 进程号,导致新进程不能创建,所以这种情况一定要避免。
2. 案例分析:多进程应用场景
2.1 环境准备
- 机器配置:4 CPU,8GB 内存,Ubuntu 22.04.5,1台机器。
- 预先安装 docker、sysstat、dstat 等工具。dstat 是一个新的性能工具,它吸收了 vmstat、iostat、ifstat 等几种工具的优点,可以同时观察系统的 CPU、磁盘 I/O、网络以及内存使用情况。
安装过程:略
2.2 部署测试应用
注意:这里部署完了一定要立刻进行接下来的步骤,不然立刻关闭这个容器,否则时间长了会把系统的负载打满,导致无法操作。
root@yunwei-virtual-machine:~# docker run --privileged --name=app -itd feisky/app:iowait
27b316cda218482c9870ad54908ec4a4f4daf0ec52d8b488e064c1530b617d74
root@yunwei-virtual-machine:~# docker ps -l
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
27b316cda218 feisky/app:iowait "/app" 20 seconds ago Up 5 seconds app
然后通过ps命令查看应用是否部署成功,如下图就表示部署成功了:
从上图,可以发现多个 app 进程已经启动,并且它们的状态分别是 Ss+、D+、S+。其中,S 表示可中断睡眠状态,D 表示不可中断睡眠状态,我们在前面刚学过,那后面的 s 和 + 是什么意思呢?s 表示这个进程是一个会话的领导进程,而 + 表示前台进程组。
2.3 什么是进程组和会话
- 进程组表示一组相互关联的进程,比如每个子进程都是父进程所在组的成员;
- 而会话是指共享同一个控制终端的一个或多个进程组。
比如,我们通过 SSH 登录服务器,就会打开一个控制终端(TTY),这个控制终端就对应一个会话。而我们在终端中运行的命令以及它们的子进程,就构成了一个个的进程组,其中,在后台运行的命令,构成后台进程组;在前台运行的命令,构成前台进程组。
2.4 查看系统资源使用情况
这里汇总一下,上图的问题:
- 第一行 load average: 4.59, 2.10, 1.35。
- 光是1分钟的负载,就已经打满了(机器4C),这个时候系统的性能很明显已经受到的影响。而且随着时间推移,负载还在不断上升。
- 第二行 有1个正在运行的进程,但是有12个僵尸进程,这个僵尸进程也是随着时间的推移在不断增多。
- 第三四五六行 用户态和内核态的cpu使用率都很低,但是iowait有3个CPU>95%,一个73.8,这很明显不正常。
- 进程状态:CPU使用率最高的进程也只占用了1%,但有好几个进程处于 D 状态,它们可能在等待 I/O,但光凭这里并不能确定是它们导致了 iowait 升高。
那么现状总结下来就是:
- iowait 太高了,导致系统的平均负载升高,甚至超过了系统 CPU 的个数。
- 僵尸进程在不断增多,说明有程序没能正确清理子进程的资源。
2.5 iowait分析
推荐使用dstat ,它的好处是可以同时查看 CPU 和 I/O 这两种资源的使用情况,便于对比分析。
root@yunwei-virtual-machine:~# dstat 1 10 # 间隔1秒输出10组数据
You did not select any stats, using -cdngy by default.
--total-cpu-usage-- -dsk/total- -net/total- ---paging-- ---system--
usr sys idl wai stl| read writ| recv send| in out | int csw0 1 85 13 0| 57M 18k| 0 0 | 0 0 | 320 3670 3 0 97 0| 411M 0 | 606B 338B| 0 0 |1172 16420 2 10 88 0| 499M 0 | 846B 248B| 0 0 |1212 19030 2 0 98 0| 319M 4096B| 858B 330B| 0 0 | 913 13351 3 0 96 0| 372M 4096B| 786B 162B| 0 0 |1109 14631 7 0 92 0| 472M 32k| 546B 162B| 0 0 |1469 18190 3 0 97 0| 435M 0 | 546B 162B| 0 0 |1218 17380 4 4 92 0| 568M 0 | 666B 146B| 0 0 |1481 21200 2 0 97 0| 348M 0 | 906B 383B| 0 0 |1021 14000 4 6 89 0| 471M 0 | 846B 146B| 0 0 |1339 1843
从 dstat 的输出,我们可以看到,每当 iowait 升高(wa)时,磁盘的读请求(read)都会很大。这说明 iowait 的升高跟磁盘的读请求有关,很可能就是磁盘读导致的。
2.6 查找导致iowait升高的进程
继续在刚才的终端中,运行 top 命令,观察 D 状态的进程:
观察一会儿按 Ctrl+C 结束
然后随便找一个D状态进程的PID,使用pidstat -d来分析io使用情况:
root@yunwei-virtual-machine:~# pidstat -d -p 22540 1 3 # 每隔一秒输出3组数据
Linux 6.8.0-60-generic (yunwei-virtual-machine) 2025年06月18日 _x86_64_ (4 CPU)16时51分57秒 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
16时51分58秒 0 22540 65536.00 0.00 0.00 0 app
16时51分59秒 0 22540 0.00 0.00 0.00 0 app
16时52分00秒 0 22540 65536.00 0.00 0.00 0 app
Average: 0 22540 43690.67 0.00 0.00 0 app
通过结果可以发现,就是这个app进程在读取磁盘,并且每秒最多有64m的读取。
2.7 查找进程到底在执行什么样的IO操作
回顾一下进程用户态和内核态的区别:
- 进程想要访问磁盘,就必须使用系统调用,所以接下来,重点就是找出 app 进程的系统调用了。
strace 正是最常用的跟踪进程系统调用的工具。所以,我们从 pidstat 的输出中拿到进程的 PID 号,比如 6082,然后在终端中运行 strace 命令,并用 -p 参数指定 PID 号:
$ strace -p 6082
strace: attach: ptrace(PTRACE_SEIZE, 6082): Operation not permitted
这里居然报错了,显示“不允许操作!”。
$ ps aux | grep 6082
root 6082 0.0 0.0 0 0 pts/0 Z+ 13:43 0:00 [app] <defunct>
然后通过ps查看进程,发现进程已经变成了僵尸进程(Z),僵尸进程是已经退出运行的进程,是没有办法分析它的系统调用的。
但此时系统的iowait依然还是处于一个异常状态,而使用top、pidstat这类的工具,也无法查出更多有用的信息,怎么办?使用基于事件记录的动态追踪工具。
2.7.1 使用perf查看调用栈信息
root@yunwei-virtual-machine:/tmp# perf record -g # 生成调用用栈文件,约15s后CTRL C退出。
root@yunwei-virtual-machine:/tmp# ll -rth
-rw------- 1 root root 61M 6月 26 16:33 perf.dataroot@yunwei-virtual-machine:/tmp# perf report # 查看调用栈
接着,找到我们关注的 app 进程,按回车键展开调用栈,你就会得到下面这张调用关系图:
这个图里的 swapper 是内核中的调度进程,可以先忽略掉。
可以发现, app 的确在通过系统调用 sys_read() 读取数据。并且从 new_sync_read 和 blkdev_direct_IO 能看出,进程正在对磁盘进行直接读,也就是绕过了系统缓存,每个读请求都会从磁盘直接读,这就可以解释我们观察到的 iowait 升高了。
看来,罪魁祸首是 app 内部进行了磁盘的直接 I/O 啊!
下面的问题就容易解决了。我们接下来应该从代码层面分析,究竟是哪里出现了直接读请求。查看源码文件 app.c,你会发现它果然使用了 O_DIRECT 选项打开磁盘,于是绕过了系统缓存,直接对磁盘进行读写。
open(disk, O_RDONLY|O_DIRECT|O_LARGEFILE, 0755)
直接读写磁盘,对 I/O 敏感型应用(比如数据库系统)是很友好的,因为你可以在应用中,直接控制磁盘的读写。但在大部分情况下,我们最好还是通过系统缓存来优化磁盘 I/O,换句话说,删除 O_DIRECT 这个选项就是了。
3. 僵尸进程
僵尸进程是因为父进程没有回收子进程的资源而出现的,那么,要解决掉它们,就要找到它们的根儿,也就是找出父进程,然后在父进程里解决。
3.1 使用pstree查看进程关系
# -a 表示输出命令行选项
# p表PID
# s表示指定进程的父进程
$ pstree -aps 3084
systemd,1└─dockerd,15006 -H fd://└─docker-containe,15024 --config /var/run/docker/containerd/containerd.toml└─docker-containe,3991 -namespace moby -workdir...└─app,4009└─(app,3084)
运行完,你会发现 3084 号进程的父进程是 4009,也就是 app 应用。这种怎么办呢?查看 app 应用程序的代码,看看子进程结束的处理是否正确,比如有没有调用 wait() 或 waitpid() ,抑或是,有没有注册 SIGCHLD 信号的处理函数。找到并修复就好了。
4. 小结
iowait 高不一定代表 I/O 有性能瓶颈。当系统中只有 I/O 类型的进程在运行时,iowait 也会很高,但实际上,磁盘的读写远没有达到性能瓶颈的程度。
因此,碰到 iowait 升高时,需要先用 dstat、pidstat 等工具,确认是不是磁盘 I/O 的问题,然后再找是哪些进程导致了 I/O。
等待 I/O 的进程一般是不可中断状态,所以用 ps 命令找到的 D 状态(即不可中断状态)的进程,多为可疑进程。但这个案例中,在 I/O 操作后,进程又变成了僵尸进程,所以不能用 strace 直接分析这个进程的系统调用。
这种情况下,我们用了 perf 工具,来分析系统的 CPU 时钟事件,最终发现是直接 I/O 导致的问题。这时,再检查源码中对应位置的问题,就很轻松了。
而僵尸进程的问题相对容易排查,使用 pstree 找出父进程后,去查看父进程的代码,检查 wait() / waitpid() 的调用,或是 SIGCHLD 信号处理函数的注册就行了。