灰气球

灰气球

Linux 全面解析Load

2023-02-26

本文转自公众号“阿里智能运维”,原文链接:深度好文-全面解析Linux Load。

1 Linux load 准确含义

日常运维中我们经常会遇到linux系统load过高的问题,但是围绕linux load是如何计算的问题,业界还没有系统的论述文章。本文将全面系统阐述linux load计算原理和排查影响因素的方法。
日常获取linux load的方法无外乎通过top、w、uptime等linux系统命令进行获取。事实上这些linux系统命令打印的load值都是通过/proc/loadavg中获取的,大家可以在自己的linux中运行如下命令:

在这里我们可以看到系统命令top、w、uptime都在procps-ng rpm包中,老一点版本linux中包名叫procps。有兴趣的同学可以下载procps-ng源码包,看看load值是否取自/proc/loadavg。
/proc/目录中mount的是一种叫proc的linux伪文件系统,主要被用作内核数据结构的接口。我们可以通过如下方法查看其中的伪文件和数值的含义。

这段话大意是说,loadavg 文件中前三个字段是平均负载值,分别代表1、5 和 15 分钟的作业(job)数量的平均值,作业(job)包括运行队列(state R)或者等待磁盘I/O(state D)两种类型。这里面有3层信息:

  1. /proc/loadavg中前三个数字分别表示load1、load5、load15的值。
  2. load值代表的是对应时间内的jobs的平均数量,比如load1就表示过去1分钟内的jobs数量的平均值。job主要是一个shell概念,和进程组概念近似,这里应该属于用词不当(后面会分析,准确的用词应该是内核中的tasks或用户空间中的threads概念)。
  3. 而且只包含state状态为R和D的两种jobs,其他state状态不包含在内。

2 分解 Linux load 的脚本工具 load2process

很显然具体的load1、load5和load15的值是内核计算之后,通过proc文件系统提供给用户空间的。要深入到内核中查看load的相关代码搞懂load计算过程,也非易事。而真正有个别能看懂内核代码的人分析一下load计算过程的相关代码,广大读者看完之后也还是无从下手。
在这里我们先抛开复杂的内核代码,直接给大家上干货load2process工具脚本,初步了解分解linux load的方法。具体获取方法如下:

load2process中的关键脚本工具是load2process和load2pid,主要内容如下:

简单解释一下这几个ps命令的参数:

  • -e参数,显示当前系统中所有进程;
  • -L参数,对每一个进程信息都展开显示包含的所有线程,每个线程展开一行;
  • h参数,隐藏ps命令的第一行header标题信息;
  • o state,ucmd,这里o和state,ucmd是组合在一起生效的,只输出state和ucmd这两列信息,state表示线程状态,ucmd表示线程名称。
  • o state,pid,cmd,这里o和state,pid,cmd是组合在一起生效的,只输出state,pid和cmd三列信息,pid表示进程id,cmd表示完整的线程名称。

找一台比较繁忙的机器,运行下以上脚本,同时获取当时的load情况。

load2process 输出结果中第一列各数字相加为31,基本上和load1值26.92差不多,具体存在的差异后面会分析。此时如果想将load1值拆解到具体的线程级别,即可通过这个输出结果看到R状态的tasker_1是影响load1值的主要因素,且数量上贡献了18个。
load2process除以上这个用法外,还有如下几种用法。

如果在执行 ./load2process 后,结果中的第三列比较集中于java、python和php等进程名。则可以继续使用load2pid工具进行进一步load分解。效果如下:

3 更灵敏的load5s和load值预测

写到这里,很多同学可能还是会有些怀疑,分解linux load就这么简单吗?下面会通过引入load5s的概念,将内核中复杂的load算法尽量转换到用户空间,让读者以一个看得见摸得着的方式来体会load的计算过程。
平时分析load高时,很多资料上都会说看load1、load5和load15的趋势。如果load15很高,但是load1已经比较低了,那说明系统已经处于逐步恢复中。反之,如果load1很高、load15比较低,那说明系统正在越来越严重。这都说的没错,也很容易理解,load1比load15更加灵敏。那有没有比load1更加灵敏的load值呢?
加载load5s的内核模块,即可获取更加灵敏的5秒钟load average(目前已经适配过centos7的3.10.0内核和centos6的2.6.32内核)。具体如下:

安装完load5s.ko内核模块之后,即可在/proc/目录中获取load5s值:
多次cat这个伪文件可以发现,该值5秒内不会变化,每隔5秒会发生变化。这就是比load1更加灵敏的load5s值。也就是说,如果你安装了load5s模块,当你的load1高时,你可以看看load5s的值,如果它已经不高了,很可能说明你的系统已经开始逐步恢复中。
load5s除了比load1更加灵敏这个用途之外,它也是能帮我们揭示load1、load5和load15的计算逻辑的最关键因素。我们可以尝试在安装了load5s模块的机器上,运行如下load_predict.sh的shell脚本。
(下图为节选,详情可参考GIT)

运行shell脚本后有如下输出:

从数据结果中我们可以看到,current_load列和predict_load列分别在load1、load5和load15三种情况下惊人的一致。其中current_load列是从当前系统中采集出来的load值,而predict_load是我们通过shell脚本中运算预测的load1、load5和load15的值,大家不妨多运行几次load_predict.sh脚本进行验证(采集值和预测值最多只有0.01的误差)。
此时,我们再回过头来仔细看这个load_predict.sh脚本,原来这就是linux load的计算过程。

4 Linux内核中load相关代码分析

了解了linux load的计算逻辑,我们再来对照一下内核代码,以内核3.10.0版本为例。将shell代码和c代码对比着看看,相信大家很快就会理解内核中load相关的c语言代码。
首先来看下/proc/loadavg伪文件对应的内核代码。

LOAD_INT(avnrun[0]), LOAD_FRAC(avnrun[0])预编译之后是((avnrun[0]) >> 11), ((((avnrun[0]) & ((1<<11)-1)) * 100) >> 11)。由于内核没有小数计算,这段代码本质上是实现了avnrun[0]除以2048且取2位小数的浮点运算。
get_avenrun函数定义在kernel/sched/core.c页面。(下图为节选,详情可参考GIT)

我们可以看到get_avenrun函数通过avenrun全局数组变量,返回上面的avnrun数组变量。avenrun全局数组变量在calc_global_load函数中每隔5001毫秒,由calc_load函数将active值添加到原有的load值中,进而产生新的load值。
其中load1、load5和load15的区别只是第二个exp参数传入给calc_load函数不同的值,依次为1884、2014和2037。这里active值从calc_load_tasks全局结构体变量获取。calc_load_tasks全局结构体变量在calc_load_account_active函数中设置,而整个值的最初来源是calc_load_fold_active函数。
在函数calc_load_fold_active中,我们可以看到最终获取的是rq(run queue)队列中的this_rq->nr_running和this_rq->nr_uninterruptible两种状态的task数。
熟悉cpu调度算法的同学知道,这里实际上是把每个cpu队列里的nr_running和nr_uninterruptible值都汇总到一起。而nr_running和nr_uninterruptible正好对应于用户空间中的R和D两种状态的线程的数量。
回头再看前面man page说明中的jobs概念显然是不对的,正确的应该是tasks概念(用户空间对应threads概念)。
load2process输出结果中第一列之和、load5s和load1值之间的关系也一目了然。load2process第一列之和是运行这个脚本的瞬时nr_running和nr_uninterruptible状态线程数之和。load5s值是每5001毫秒对nr_running和nr_uninterruptible状态线程数之和的一个采样。load1是对之前历史所有load5s采样值按某种算法的平均值。

5 内核代码的反汇编

搞懂了内核load相关代码之后,我们还是要回过头来再看看
load5s的实现机制。理解了load5s的实现,才能让我们更加相信脚本load_predict.sh中的load计算逻辑。
而在此之前有必要先搞清楚内核反汇编的知识。linux启动时加载的内核binary程序存储在/boot/目录中,名称以vmlinuz开头,再加版本号命名。

可以看到vmlinux是bzImage自解压格式。这种格式是无法直接进行反汇编的,首先需要进行解压缩。bizip压缩包内容的开始头部可以通过“1f 8b 08”这个签名来查找。

返回decompression OK表示解压缩成功。下一步对其进行反汇编。objdump有-d和-D两个选项,这里选择-d即可。-M att表示按AT&T汇编语法进行反汇编。

打开汇编文件vmlinuzu_d.dis,我们发现这里面缺少一些符号信息,原来vmlinuz在在内核编译时,去掉了debug信息。
同时我们也了解到linux也提供了kernel-debug包,在其中包含了符号信息。从centos官方网址下载和安装相关包,http://debuginfo.centos.org/7/x86_64/。

复制出带符号的vmlinux文件。不方便安装的情况下,也可以使用rpm包直接解压文件的方式。

下面我们也对vmlinux进行反汇编。

对比vmlinux_d.dis和vmlinuzu_d.dis这两个文件中的汇编代码,我们可以发现vmlinux_d.dis除了比vmlinuzu_d.dis多了更丰富的符号信息之外,其他内容高度一致。有了这样的结论,我们就可以放心的使用vmlinux_d.dis汇编代码来分析我们的load5s了。

6 Load5s 的 Kprobe 实现原理

Load5s主要使用了kprobe内核探针技术。kprobe是linux内核的一个重要特性,它可以能够在不修改现有代码的基础上,灵活的hook内核代码的执行。其中这样两个关键属性symbol_name和offset可以确定在内核的任意一个函数中的某一个偏移地址处进行hook。
首先,根据前面对代码的分析可以,我们关心的变量是calc_global_load函数中calc_load_tasks全局结构体变量的counter属性值。这个全局变量calc_load_tasks正是当时内核中nr_running和nr_uninterruptible两种状态的tasks数之和。
确定函数和函数内部的偏移量之后,我们可以通过kallsyms_lookup_name函数获取全局变量的calc_load_tasks内存地址,进而获取calc_load_tasks全局变量的值。
确定恰当的函数内偏移地址,需要你对汇编语言比较精通。3.10.0和2.6.32内核已经确定,其他版本内核可以按如下方法尝试获得。

对照c代码,源代码中有对calc_load_tasks全局变量进行修改的操作,因此,我们取lock add %rdx,0xdd15d9(%rip)这行汇编指令之后的内存地址取偏移量比较稳妥。

当分别取ffffffff810c2a4e和ffffffff810c2a4f处的偏移地址时,register_kprobe(&kp)会执行注册出错。主要是这个注册函数中的check_kprobe_address_safe(p, &probed_mod) 会对偏移地址进行合法性检查。这个地址检查函数具体实现原理此处不做过多阐述。
我们顺序再次尝试ffffffff810c2a56地址时结果正常,此时函数内偏移量是0x23d,使用ffffffff810c2a56减去函数的开始地址ffffffff810c2820获得。
最后,我们的load5s代码如下:(下图为节选,详情可参考GIT)

将以上代码进行编译,即可获得load5s.ko内核模块。如上述小结三中的make步骤。

7 Linux load 中的数学问题

接下来再对load计算过程中的数学问题做一些分析。在前面kernel源码分析部分,我们可以在预编译之后的core.i文件中看到calc_global_load调用calc_load函数时,针对load1、load5和load15,给calc_load的第二个参数分别传入了三个不同的参数值1884,2014和2037。那么这3个值是怎么得来的呢?查看内核源码之后,我们可以发现有这样的计算公式。

分别将x代入60秒(1分钟)、300秒(5分钟)和900秒(15分钟)可以得到如下结果

这里面exp函数是求e的n次方的函数,e是自然对数2.71828….。明确了这个公式,我们在来看load的递归调用过程。假设每5s的load5s的采样值依次是active1 active2 active3 active4 …….,x是计算跨度,比如load1是60/5,load5是300/5。那么我们可以将任意时刻的load值的计算过程转换为:

其中的各个权重之和是:

小学生奥数题,无穷数列,和正好等于1。
采用这样的计算方法,递归出来的load值是否更能代表系统的平均运行状况呢?由远及近权重越来越大。
到此我们已经对Linux load的计量有了一个比较完整的认识。有了以上的分析,接下去我们将聚焦于Linux load的系统性的监控。

8 Linux load 中的监控

Load5s的值可以清晰的揭示linux load1 load5 load15的计算逻辑,但是load5s毕竟依赖内核模块的支持,对日常运维来说还是比较厚重。这里有一个比较轻量级的替代方案。
前文提到load2process输出结果中第一列各数字相加的事情,load2process中还封装了一个-s参数(summary)。

输出结果23表示当前load2process运行的同时,linux系统上所有R和D状态线程数之和。与load5s不同之处是,load5s是内核中每隔5001毫秒进行一次快照,而load2process -s是更加实时的数据。
当系统load高时,我们可以登录机器使用load2process和load2pid进行原因定位。有时候系统load高发生在半夜,或者发生在白天但并不在电脑前。这个时候,就需要依靠监控系统协助我们进行采集。在我们的load2process包中也提供了一个用于分解load的监控采集脚本check_load_process,具体使用方法如下。

先介绍下这个监控采集脚本的使用。有2个参数load_threshold和thread_threshold,可以省略,默认值分别是2和0.4。返回值是一个json格式的,方便于一些高级语言解析。下面分别介绍这2个参数的含义。
首先必须先说一下如何判断linux load高不高,单纯的看一个load1的绝对值是没有任何意义的。同样是load1值64,分别对于32核机器、64核机器和96核机器的压力是完全不同的。所以第一个参数load_threshold的阈值2,适用于判断当前监控采集脚本执行时机器的load是否高。根据我们的日常经验,当前压力大于机器的核数的200%时,可以用于判断机器负载较高。当然,对于一些灵敏度特别高的重要机器角色,你完全可以将此阈值调整为0.7。
第二个参数thread_threshold比较好理解。当我们判断系统load较高时,系统上除了有几个导致系统load高的进程(线程)之外,还有一些很本分的常规进程(线程)。我们通过thread_threshold参数将他们过滤掉,即只保留压力达到机器核数40%以上的进程(线程)。
上面这个check_load_process监控采集指令是根据load2process脚本改造的。如果机器角色上是java、python或php等进程密集,那么需要根据load2pid脚本再改造一个监控采集指令。

9 R&D 状态对 Linux load 的不同影响

很多公司在对load高进行监控的时候,会对load进行总体监控,并不区分其中的R和D两种类型的具体构成。事实上同样数量的R或D对系统的影响是远远不同的:

  • 一般来说如果是R状态为主导致的load高,系统就会特别卡。更准确的来说,R状态的多少,主要还是要和CPU核数相关,大于主机CPU核数2倍以上,系统就会出现严重问题,出现多个R状态线程争抢CPU资源的情况。
  • 如果是D状态为主导致的load高,曾经就有案例,当时的系统load高达11000多,但是整个操作系统还能正常服务。

既然这样,那我们就还需要进一步将load15、load5和load1,甚至load5s中的R和D状态分别进行监控记录,以便我们更加准确的判断系统健康程度。非常幸运的是kernel提供了这样的数据。

在/proc/loadavg伪文件中,内核还提供了一个nr_running(斜线前38)的值。这个值表示当前系统中正在进行中的R状态的线程数。linux的sar命令也对这个nr_running值进行了持久化的采集,即如下输出结果中的runq-sz列。

从上面这个案例中,也可以看到尽管load已经高达200多,但是runq-sz(即nr_running)值只有不到10。充分说明导致load高的原因主要是当时D状态线程数过高。
区分了R和D状态线程对Linux load的影响,我们再来看一个绝大多数人都曾经历的误区。当linux系统load标高时,很多人都会去top一下,查看当前系统谁占用的CPU Usage最高。这种排查方法忽略了一下2个事实,很多时候可能不能得到满意的答案。
只有当进程(线程)处于R状态时,才耗费CPU Usage,其他状态(包括D状态)并不耗费CPU Usage。当load高主要是由D状态线程数量过多导致的时候,此时从top中按CPU Usage的排名是不会发现任何线索的。
即使当load高是由R状态线程数量过多导致,如果运行top命令时导致load高的R状态线程已经结束,此时也不会从按CPU Usage的排名的top输出中发现线索的。默认情况下,top命令是3秒中刷新,只显示3秒内的CPU Usage信息。

10 分析 Linux 系统 load 高的问题

既然影响load的线程状态有R和D两种,那么再结合load在不同程序线程之间的聚合情况,我们将系统load高时,划分为如下四种场景:

  1. 由单个程序线程R状态高导致。 比如load1是200,load2process结果显示“160 R process1”,那么此时可以判断导致load高的主要原因是process1。 如果是我们业务自己开发的程序,那么很多时候是由于程序员没有注意并发数限制、执行了一些耗费CPU资源的任务。 也有可能是陷入了自旋锁的陷阱。
  2. 由多个程序线程R状态高导致。 比如load1是200,load2process结果显示“20 R process1,15 R process2,14 R process3,10 R process4…….”,这种情况在日常运维中不常见。
  3. 由单个程序线程D状态高导致。 比如load1是1000,load2process结果显示“950 D process1”。 此时可以查看这个程序对应进程的waiting channel信息,/proc//wchan,表示当前线程D在这个函数位置。有时候可能是陷入内核互斥锁mutex,用户空间叫futex。造成进程D住的原因多种多样,需要结合搜索引擎具体情况具体分析。进一步还可以查看内核当前wchan的内核调用栈,/proc/ /stack。
  4. 由多个程序线程D状态高导致。 此时可能操作系统和所在机器整体出现了问题,比如磁盘出现问题。 就有人解释状态D的含义是“Disk Sleep”,就比较生动的说明了这个问题。 实际造成的原因也是多种多样。


总而言之,在实际生产中,造成load高的原因千差万别,还需要对linux内核的各个模块有深入的了解。