引入
CPU使用率类型,包括用户CPU、系统CPU(比如上下文切换)、等待IO的CPU(比如等待磁盘的响应)以及中断CPU(包括软中断和硬中断)等。
本节我们来讨论等待IO的CPU使用率(iowait)升高导致的服务器性能问题
进程状态(不可中断进程和僵尸进程)
当iowait升高时,进程可能会因为得不到硬件的响应,而长时间出于不可中断的状态。从ps或者top命令的输出中,你可以发现它们都出于D
状态,也就是不可中断状态(uninterruptible sleep)。
- 对于不可中断(
D
)状态,这其实是为了保证进程数据与硬件状态一致。- 在正常情况下,不可中断状态会在很短时间内就会结束。所以,短时的不可中断状态进程,一般可以忽略
- 但是如果硬件出现了问题,进程可能会在不可中断状态保持很久,甚至导致系统中出现大量不可中断进程。这时,就需要注意下系统是不是出现了IO等性能问题
再看僵尸进程z
,这是多进程应用很容易碰到的问题。
- 正常情况下,当一个进程创建了子进程后,它应该通过系统调用wait()或者waitpid()等待子进程结束,回收子进程的资源;而子进程在结束时,会向它的父进程发送
SIGCHLD
信号,所以,父进程还可以注册SIGCHLD信号的处理函数,异步回收资源 - 如果父进程没有这么做,或者子进程执行太快,父进程还没来得及处理子进程状态,子进程就已经提前退出,那这时的子进程就会变成僵尸进程。
- 通常,僵尸进程持续的时间都比较短,在父进程回收它的资源之后就会消亡;或者在父进程退出后,由init进程回收后也会消亡
- 一旦父进程没有处理子进程的终止,还一直保持运行状态,那么子进程就会一直出于僵尸状态。大量的僵尸进程会用尽PID进程号,导致新进程不能创建,所以这种情况一定要避免
- 即:短暂的僵⼫状态我们通常不必理会,但进程⻓
时间处于僵⼫状态,就应该注意了,可能有应⽤程序没有正常处理⼦进程的退出。
关于进程的状态:
top和ps是最常用的查看进程状态的工具,比如,top的s(即status)列就是表示进程的状态:
- R:是Running或者Runable的缩写,表示进程在就绪队列中,正在运行或者正在等待运行
- 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命令,恢复到平台运行) - 当你用调试器(比如gdb)调试一个程序时,在使用断点中断进程后,进程就会变成跟踪状态,这其实也是一种非特殊的暂停状态,只不过你可以用调试器来跟踪并按照需要控制进程的运行
- 向一个进程发送
- X:即Dead,表示进程已经死亡,在top和ps命令中看不到这个状态
案例分析
这里,使用一个多进程应用的案例,以分析大量不可中断进程和僵尸状态进程的问题
准备
- 机器配置:2CPU,8GB内存
- 预先安装 docker、sysstat、dstat 等⼯具
- dstat是一个新的性能工具,它吸收了vmstat、iostat、ifstat等几种工具的优点,可以同时观察系统的CPU、磁盘、IO、网络以及内存使用状态
apt install docker.io dstat sysstat
注意,以下所有命令都默认以 root ⽤户运⾏
操作和分析
启动应用:
$ docker run --privileged --name=app -itd feisky/app:iowait
使用ps命令确定应用已经正常启动:
$ ps aux | grep /app
root 4009 0.0 0.0 4376 1008 pts/0 Ss+ 05:51 0:00 /app
root 4287 0.6 0.4 37280 33660 pts/0 D+ 05:54 0:00 /app
root 4288 0.6 0.4 37280 33668 pts/0 D+ 05:54 0:00 /app
从上面可以看出,多个app进程已经启动,并且多个app进程已经启动,并且它们的状态分别是Ss+和D+。我们知道:
- S表示可中断的睡眠状态
- D表示不可中断的睡眠状态
那么, 后面的s和+表示什么意思呢?
通过man ps,我们可以知道:
- s:表示这个进程是一个会话的领导进程
- +:表示前台进程组
那么什么是进程组、会话呢?
- 进程组表示一组相互关联的进程,比如每个子进程都是父进程所在组的成员
- 会话是指共享同一个控制终端的一个或者多个进程组
比如,我们通过SSH登录服务器,就会打开一个控制终端(TTY),这个控制终端就对应一个会话,而我们在终端中运行的命令以及它们的子进程,就构成了一个个的进程组,其中,在后台运行的命令,构成后台进程组;在前台运行的命令,构成前台进程组
现在,我们在用top来看一下系统的资源使用情况:
# 按下数字 1 切换到所有 CPU 的使⽤情况,观察⼀会⼉按 Ctrl+C 结束
$ top
top - 05:56:23 up 17 days, 16:45, 2 users, load average: 2.00, 1.68, 1.39
Tasks: 247 total, 1 running, 79 sleeping, 0 stopped, 115 zombie
%Cpu0 : 0.0 us, 0.7 sy, 0.0 ni, 38.9 id, 60.5 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 0.0 us, 0.7 sy, 0.0 ni, 4.7 id, 94.6 wa, 0.0 hi, 0.0 si, 0.0 st
...
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
4340 root 20 0 44676 4048 3432 R 0.3 0.0 0:00.05 top
4345 root 20 0 37280 33624 860 D 0.3 0.0 0:00.01 app
4344 root 20 0 37280 33624 860 D 0.3 0.4 0:00.01 app
1 root 20 0 160072 9416 6752 S 0.0 0.1 0:38.59 systemd
观察上面,发现了四个可疑的地方:
- 第一行的平均负载(load average):过去1分钟、5分钟、15分钟内的平均负载在依次减小,说明平均负载正在升高;而1分钟内的平均负载已经达到了CPU个数,说明系统很可能已经有了性能瓶颈
- 第二行的Tasks:有1个正在运行的进程,但是僵尸进程比较多,而且还在不停的增加,说明有子进程在退出时没有被清理
- 接下来看两个CPU的使用率情况:用户CPU和系统CPU都不高,但是iowait分别是60.5%和94.6%,好像不太正常
- 最后看每个进程的情况,CPU使用率最高的进程只有0.3%,看起来并不高;但是有两个进程出于D状态,它们可能在等待IO,但是只凭这里并不能确定是它们导致了iowait的升高
汇总上面的四个问题,就可以得到很明显的两点:
- iowait太高了,导致系统的平均负载升高,甚至达到了系统CPU的个数
- 僵尸进程在不断增多,说明有程序没能正确清理子进程的资源。
问题是:遇到这两个问题我们应该怎么办呢?
iowait分析
我们先来看一下iowait升高的问题
如果遇到iowait升高,我们就要先弄清楚系统的io情况,那么什么工具可以查询系统的IO情况呢?
- 推荐使用dstat,他可以同时查看CPU和IO这两种资源的使用情况,便于对比分析
- 现在,我们在终端运行dstat命令,观察CPU和IO的使用情况:
# 间隔1秒输出10组数据
$ dstat 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 csw
0 0 96 4 0|1219k 408k| 0 0 | 0 0 | 42 885
0 0 2 98 0| 34M 0 | 198B 790B| 0 0 | 42 138
0 0 0 100 0| 34M 0 | 66B 342B| 0 0 | 42 135
0 0 84 16 0|5633k 0 | 66B 342B| 0 0 | 52 177
0 3 39 58 0| 22M 0 | 66B 342B| 0 0 | 43 144
0 0 0 100 0| 34M 0 | 200B 450B| 0 0 | 46 147
0 0 2 98 0| 34M 0 | 66B 342B| 0 0 | 45 134
0 0 0 100 0| 34M 0 | 66B 342B| 0 0 | 39 131
0 0 83 17 0|5633k 0 | 66B 342B| 0 0 | 46 168
0 3 39 59 0| 22M 0 | 66B 342B| 0 0 | 37 134
从dstat的输出,我们可以看到,每当iowait升高(wait)时。磁盘的读请求(read)都会很大。这说明iowait的升高跟磁盘的读请求有关,很可能就是磁盘读导致的。
那到底是哪个进程在读磁盘呢?不知道你还记不记得,上节在 top ⾥看到的不可中断状态进程,我觉得它就很可疑,我们试着来分析下。
我们继续在刚才的终端中,运⾏ top 命令,观察 D 状态的进程:
# 观察⼀会⼉按 Ctrl+C 结束
$ top
...
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
4340 root 20 0 44676 4048 3432 R 0.3 0.0 0:00.05 top
4345 root 20 0 37280 33624 860 D 0.3 0.0 0:00.01 app
4344 root 20 0 37280 33624 860 D 0.3 0.4 0:00.01 app
我们从 top 的输出找到 D 状态进程的 PID,你可以发现,这个界⾯⾥有两个 D 状态的进程,PID分别是 4344 和 4345。
接着,我们查看这些进程的磁盘读写情况。一般要查看某一个进程的资源使用情况,用pidstat。如果要看IO使用情况,可以加上-d参数。
# -d 展示 I/O 统计数据,-p 指定进程号,间隔 1 秒输出 3 组数据
$ pidstat -d -p 4344 1 3
06:38:50 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
06:38:51 0 4344 0.00 0.00 0.00 0 app
06:38:52 0 4344 0.00 0.00 0.00 0 app
06:38:53 0 4344 0.00 0.00 0.00 0 app
在这个输出中, kB_rd 表示每秒读的 KB 数, kB_wr 表示每秒写的 KB 数,iodelay 表示 I/O 的延迟(单位是时钟周期)。它们都是 0,那就表示此时没有任何的读写,说明问题不是 4344 进程导致的。
可是,⽤同样的⽅法分析进程 4345,你会发现,它也没有任何磁盘读写。
那要怎么知道,到底是哪个进程在进⾏磁盘读写呢?我们继续使⽤pidstat,但这次去掉进程号,⼲脆就来观察所有进程的 I/O使⽤情况:
# 间隔 1 秒输出多组数据 (这⾥是 20 组)
$ pidstat -d 1 20
...
06:48:46 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
06:48:47 0 4615 0.00 0.00 0.00 1 kworker/u4:1
06:48:47 0 6080 32768.00 0.00 0.00 170 app
06:48:47 0 6081 32768.00 0.00 0.00 184 app
06:48:47 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
06:48:48 0 6080 0.00 0.00 0.00 110 app
06:48:48 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
06:48:49 0 6081 0.00 0.00 0.00 191 app
06:48:49 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
06:48:50 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
06:48:51 0 6082 32768.00 0.00 0.00 0 app
06:48:51 0 6083 32768.00 0.00 0.00 0 app
06:48:51 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
06:48:52 0 6082 32768.00 0.00 0.00 184 app
06:48:52 0 6083 32768.00 0.00 0.00 175 app
06:48:52 UID PID kB_rd/s kB_wr/s kB_ccwr/s iodelay Command
06:48:53 0 6083 0.00 0.00 0.00 105 app
观察一会儿可以发现,的确是app进程在进行磁盘读,并且每秒读的数据有32MB,看来就是app的问题。不过,app进程到底在执行什么IO操呢?
这里,我们需要回顾一下进程用户态和内核态的区别。进程想要访问磁盘,就必须使用系统调用。所以接下来,重点就是找出app进程的系统调用呢?
strace是最常用的跟踪进程系统调用的工具。所以,我们从 pidstat 的输出中拿到进程的 PID 号,⽐如 6082,然后在终端中运⾏ strace 命令,并⽤ -p 参数指定 PID 号:
$ strace -p 6082
strace: attach: ptrace(PTRACE_SEIZE, 6082): Operation not permitted
这⼉出现了⼀个奇怪的错误,strace 命令居然失败了,并且命令报出的错误是没有权限。按理来说,我们所有操作都已经是以root ⽤户运⾏了,为什么还会没有权限呢?
一般遇到这种问题,请先检查一下进程的状态是否正常。⽐如,继续在终端中运⾏ ps 命令,并使⽤ grep 找出刚才的6082 号进程:
$ ps aux | grep 6082
root 6082 0.0 0.0 0 0 pts/0 Z+ 13:43 0:00 [app] <defunct>
果然,进程 6082 已经变成了 Z 状态,也就是僵⼫进程。僵尸进程都是已经退出的进程,所以就没法儿继续分析它的系统调用。关于僵尸进程的处理方法,我们一会儿再说,现在还是继续分析iowait的问题。
到这⼀步,你应该注意到了,系统 iowait 的问题还在继续,但是 top、pidstat 这类⼯具已经不能给出更多的信息了。这时,我们就应该求助那些基于事件记录的动态追踪⼯具了。
你可以⽤ perf top 看看有没有新发现。再或者,在终端中运⾏ perf record,持续⼀会⼉(例如 15 秒),然后按 Ctrl+C 退出,再运⾏ perf report 查看报告:
$ perf record -g
$ 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)
直接读写磁盘,对IO敏感型应用(比如数据库系统)是很友好的,因为你可以在应用中,直接控制磁盘的读写。但是在大部分情况下,我们最好还是通过系统缓存来优化磁盘IO。换句话说,删除 O_DIRECT 这个选项就是了。
app-fix1.c 就是修改后的⽂件,我也打包成了⼀个镜像⽂件,运⾏下⾯的命令,你就可以启动它了
# ⾸先删除原来的应⽤
$ docker rm -f app
# 运⾏新的应⽤
$ docker run --privileged --name=app -itd feisky/app:iowait-fix1
最后,再⽤ top 检查⼀下:
$ top
top - 14:59:32 up 19 min, 1 user, load average: 0.15, 0.07, 0.05
Tasks: 137 total, 1 running, 72 sleeping, 0 stopped, 12 zombie
%Cpu0 : 0.0 us, 1.7 sy, 0.0 ni, 98.0 id, 0.3 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 0.0 us, 1.3 sy, 0.0 ni, 98.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
...
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
3084 root 20 0 0 0 0 Z 1.3 0.0 0:00.04 app
3085 root 20 0 0 0 0 Z 1.3 0.0 0:00.04 app
1 root 20 0 159848 9120 6724 S 0.0 0.1 0:09.03 systemd
2 root 20 0 0 0 0 S 0.0 0.0 0:00.00 kthreadd
3 root 20 0 0 0 0 I 0.0 0.0 0:00.40 kworker/0:0
你会发现, iowait 已经⾮常低了,只有 0.3%,说明刚才的改动已经成功修复了 iowait ⾼的问题,⼤功告成!不过,别忘了,僵⼫进程还在等着你。仔细观察僵⼫进程的数量,你会郁闷地发现,僵⼫进程还在不断的增⻓中。
僵尸进程
接下来,我们就来处理僵尸进程的问题。既然僵尸进程是因为父进程没有回收子进程的资源而出现的,那么,要解决掉它们,就要找出父进程,然后在父进程里解决。
找到父进程的最简单的方法就是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 信号的处理函数。
现在我们查看修复 iowait 后的源码⽂件 app-fix1.c ,找到⼦进程的创建和清理的地⽅:
int status = 0;
for (;;) {
for (int i = 0; i < 2; i++) {
if(fork()== 0) {
sub_process();
}
}
sleep(5);
}
while(wait(&status)>0);
循环语句本来就容易出错,你能找到这⾥的问题吗?这段代码虽然看起来调⽤了 wait() 函数等待⼦进程结束,但却错误地把wait() 放到了 for 死循环的外⾯,也就是说,wait() 函数实际上并没被调⽤到,我们把它挪到 for 循环的⾥⾯就可以了。
修改后的⽂件我放到了 app-fix2.c 中,也打包成了⼀个 Docker 镜像,运⾏下⾯的命令,你就可以启动它:
# 先停⽌产⽣僵⼫进程的 app
$ docker rm -f app
# 然后启动新的 app
$ docker run --privileged --name=app -itd feisky/app:iowait-fix2
启动后,再⽤ top 最后来检查⼀遍:
$ top
top - 15:00:44 up 20 min, 1 user, load average: 0.05, 0.05, 0.04
Tasks: 125 total, 1 running, 72 sleeping, 0 stopped, 0 zombie
%Cpu0 : 0.0 us, 1.7 sy, 0.0 ni, 98.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
%Cpu1 : 0.0 us, 1.3 sy, 0.0 ni, 98.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
...
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
3198 root 20 0 4376 840 780 S 0.3 0.0 0:00.01 app
2 root 20 0 0 0 0 S 0.0 0.0 0:00.00 kthreadd
3 root 20 0 0 0 0 I 0.0 0.0 0:00.41 kworker/0:0
好了,僵⼫进程(Z状态)没有了, iowait 也是 0,问题终于全部解决了
总结
进程的不可中断状态是系统的一种保护机制,可以保证硬件的交互过程不被意外打断。所以短时间的不可中断状态是很正常的。但是,当进程长时间都出于不可中断状态时,就需要使用dstat、pidstat等工具,确认是不是磁盘IO的问题,进而排除相关的进程和磁盘设备
上面用了一个多进程的案例,分析了系统等待IO的CPU使用率(也就是iowait)升高的情况。
虽然上面是因为磁盘io导致iowait升高,不过,iowait高并不一定代表IO有性能瓶颈。当系统中只有IO类型的进程在运行时,iowait也会很高,但实际上,磁盘的读写远没有达到性能瓶颈的程度
因此,碰到iowait升高时,需要先用dstat、pidstat等工具,确认是不是磁盘IO的问题,然后再找是哪些进程导致了IO
等待IO的进程一般是不可中断状态,所以用ps命令找到D状态(即不可中断状态)的进程,多为可疑进程,这时,再检查源码中对应位置的问题,就很轻松了。
⽽僵⼫进程的问题相对容易排查,使⽤ pstree 找出⽗进程后,去查看⽗进程的代码,检查 wait() / waitpid() 的调⽤,或是
SIGCHLD 信号处理函数的注册就⾏了。