Linux中进程的D状态会引起CPU-Load虚高

写在前面

前几天从同事手里接盘了一个 HHKB 的键盘,虽说是顶级的配置,但是如果不提一句的话估计大家都不会意识到码出这篇博文的工具如此高大上,同时意味着我要持续吃土小半年了。

就像之前博文提到的,我工作的重心从业务开发逐渐向基础平台建设转移,关注的点从 CPU 和内存变成了更为宏观的系统架构、稳定性、性能等。一方面需要站的足够高,如此才能理清楚系统的整体脉络,避免在解决一个棘手问题的时候再引入另一个棘手问题;另一方面要看的够细,既要知其然也要知其所以然,如此才能保证方案中选用的组件是满足需求的。

本文将对 Linux 中进程的 D 状态( uninterruptible sleep)进行简单的探索,并探究由其引起的 CPU-Load 虚高的现象及影响。

适用人群

入门——初级——中级√——高级;本文适应中级及以上。

Linux中进程的D状态会引起CPU-Load虚高

有一天我去检查机器的负载,发现其中一台服务器的负载非常高,CPU 的 load-average 达到了 19 以上,查看机器的配置却只有4个 cpu 核,且这种状态已经持续了一段时期。这和我以往获取到的常识是相悖的;按照 Linux 的设定,4 个 CPU 核的正常 load-average 不应该大于 4,如果大于 4 很大程度上说明存在 IO 瓶颈。蹊跷的是出现这种负载异常的服务器除了数据有点难看,并未表现出明显的超负载现象,这就引起我很大的兴趣进一步追查其原因。

Linux中的CPU-Load-Average

在 Linux 系统中,我们可以通过好几种方式查看 CPU 的平均负载情况,比如 w 命令,比如top 命令 ,都会打印出 load average: 1.57, 1.40, 1.32 模样的数字。那么我们应该怎么理解 CPU-Load 所对应的数值呢?

大家对 CPU-Load 的理解,主流的是把只有一个核(单核)的 CPU 比喻成一条单车道,不过对于不开车的人来说,可能不太好理解。其实我们可以用更通俗的包饺子来打比方。在老家,从擀面皮到包馅都是自己亲力亲为;对我家来说,一般都是我妈妈擀面皮,我爸爸包馅。假如把饺子皮看做进程,把我老爸看做 CPU 核,我们可以想象这样几种场景:1)老妈制作面皮的时候电视上正播放着《还珠格格》,她时不时看几眼电视屏幕,平均一分钟制作 5 个面皮,而老爸平均一分钟包 10 个饺子,他包完一个饺子后,发现面皮还没有做好,于是不得不在那里等;这种情况下,可以认为 CPU 核的负载是小于 1 的。原来 1 分钟能包出来 10 个饺子,现在因为饺子皮制作慢导致 1 分钟只能包好 5 个饺子,可以计算出 CPU 的负载是 0.5 (5/10)。2)假如这个时候老爸抱怨了一句,老妈把注意力收回来一些,平均一分钟制作 10 个面皮,与老爸包饺子的速度一致了,此时可以认为 CPU 核的负载正好等于 1.0(10/10)。3)又过了几分钟电视上开始播放广告了,老妈的注意力完全收回来,平均一分钟制作 15 个面皮,这个时候老爸包饺子的速度跟不上了,此时可以认为 CPU 的负载为 1.5(15/10)。4)过了一分钟电视上又开始播放《还珠格格》了,这个时候老妈的注意力又开始被吸引过去,于是饺子皮越来越少,老爸的负载也变得越来越小最后变成第一种情况(从 1.5 变成 1.0 最后变成 0.5)。5)老爸终于鼓起勇气拿起遥控器把频道换成了 CCTV-9 的《动物世界》,这个时候老妈(假设能强忍住怒气)不得不专注地擀面皮,大概坚持了十分钟,这段时间里面皮越来越多,相对来说老爸的负载从 0.5 到 1.0 到 3.0 最后到 5.0 ,只要老妈坚持的时间够久最后负载可能到 20.0 甚至更高。

上面“包饺子”的比方,可以解释一般情况下 CPU-Load-Average 的含义,因此当系统管理员发现 CPU 负载高的时候,总要特别关注,如果发现当前的机器资源已经无法支撑当前的业务量,就需要排查是否被攻击或者考虑加机器了(以包饺子为例,可能就是老爸把我从被窝里拖出来和他一起包饺子,这中情况下相当于两个 CPU 核)。

值得说明的一点是,当 Linux 拥有多个 CPU 核的时候,在展示 CPU-Load 的时候 Linux 并不会主动取平均值而只给出 Load 的总值。因此,对于有 4 个核的 CPU,可以认为 CPU-Load 小于 4 的时候都是没有满载的。

Linux-Load-average 的理解误区

由于 CPU-Load 出问题很大程度是因为超载,因此这个值一般会作为一个发起告警的指标,当 CPU-Load-Average 大于某个值的时候触发告警。不过这个地方是有误区的,我从这里摘录了一段话,很精确的解释了这个误区:

Linux上的 load average 除了包括正在使用CPU的进程数量和正在等待 CPU 的进程数量之外,还包括 uninterruptible sleep 的进程数量。通常等待 IO 设备、等待网络的时候,进程会处于 uninterruptible sleep 状态。Linux 设计者的逻辑是,uninterruptible sleep 应该都是很短暂的,很快就会恢复运行,所以被等同于 runnable。然而uninterruptible sleep 即使再短暂也是 sleep,何况现实世界中 uninterruptible sleep 未必很短暂,大量的、或长时间的 uninterruptible sleep 通常意味着 IO 设备遇到了瓶颈。众所周知,sleep 状态的进程是不需要 CPU 的,即使所有的 CPU 都空闲,正在 sleep 的进程也是运行不了的,所以 sleep 进程的数量绝对不适合用作衡量 CPU 负载的指标,Linux 把 uninterruptible sleep 进程算进 load average 的做法直接颠覆了 load average 的本来意义。所以在 Linux 系统上,load average 这个指标基本失去了作用,因为你不知道它代表什么意思,当看到 load average 很高的时候,你不知道是 runnable 进程太多还是 uninterruptible sleep 进程太多,也就无法判断是 CPU 不够用还是 IO 设备有瓶颈。

上面的文字虽然讲的很有道理,不过并无足够的论据说服我们把 CPU-Load 的告警去掉(最好不要去掉,可以认为这是个与理论不符的最佳实践)。话说回来,既然 CPU-Load 中包含了 uninterruptible sleep 的进程数量,那么什么是 uninterruptible sleep 呢?

Linux 进程的 D 状态(Uninterruptible Sleep)

我觉得这篇文章里讲的比较透,很多地方也引用了这篇文章里下面的图:

   Executing process requests some service from kernel,
    \   kernel suspends process and moves it to a parking queue
     \ 
      \              Kernel completes the service,
       \             \    kernel moves suspended process to one of the runnable queues
        \             \ 
         \             \               Suspended process moves to top of runnable queue, 
          \             \               \   kernel resumes the process execution
           \             \               \
  Running --;--> Parked --;--> Runnable --;--> Running

简单来讲,1)内核 每隔一段时间都会统一接受所有进程的请求(这里可能与CPU时间片有关系,需要进一步确认),如果发现有进程发起了请求,首先内核会去获取进程所需要的资源,然后把这个进程暂时放到一个 parking 队列里;这里类似于需求收集阶段。2)在需求收集结束后,会把发起请求的进程放到一个runnable 队列里,等待执行。3)把 runnable 队列里的进程依次执行。

进程的 D 状态(Uninterruptible Sleep)发生在需求收集阶段。试想在这个阶段,当内核去获取进程所需要的资源的时候,比如从磁盘读取某一个文件,这个时候突然磁盘驱动不干活了(可能因为取的数据太多驱动没反应过来,也可能因为磁盘出了故障),这个时候内核就为难了,场面变得比较尴尬。1)首先驱动是工作在内核态的,内核对驱动有绝对的信任权;其次,2)进程发起请求了,自己作为老大于情于理都应该应承;3)但是进程所要求的数据实在拿不到怎么办呢?4)这个时候内核只能把进程的状态临时转换为 D 状态,标明这个锅是内核自己的,内核正在尝试获取进程需要的资源,而且获取资源的途径是内核可控的(内核必须对全局的资源拥有控制权限,否则也不要当内核了😢)。

上面的情况,如果进程所需要的数据很快就被内核拿到了还好(大部分情况下都是如此),进程会从 D 状态转到可运行的状态;如果拿不到(比如真的磁盘出现了故障,而驱动代码又没有考虑到这种情况,或者驱动抛出了信号但是内核不承认),那么相应的进程就会一直处于 D 状态。非常戏剧性的是,只有处于可运行状态(Runnable)的进程可以接受终止信号( kill 信号),处于 D 状态的进程是没有办法被 kill 掉的;这也让终止 D 状态的进程变得复杂——必须重启服务器才能把 D 状态的进程杀掉😓……不过想一想也好理解,因为流程卡在内核那里,这个时候内核与进程是绑定的状态,内核限制进程不接受被 kill 的信号也是理所当然的(我的理解是,这里的内核过于自信了,我的故障机是4.4.0内核版本,或许高版本的已经没有这个问题了)。

一些工具命令示例

其实本文很多东西都没有讲透,需要大家具有一定的知识储备才能理解所讲的内容及意义。受到篇幅影响,我这里给出一些命令,大家可以自行去钻研相关的细节。

参考