本文将从内核中负载的计算过程进行深入分析,并简单分析load高时的排查思路。
1.负载的查看过程
我们一般会使用top命令查看Linux系统的负载情况,典型的top命令输出的负载如下:
#top load average: 0.18, 0.21, 0.18输出中的Load Avg就是我们常说的负载,即系统的平均负载。由于单独某一个瞬间的负载值并没有太大意义,所以Linux计算了过去一段时间内的平均值,这三个数分别代表的是过去1分钟,过去5分钟,过去15分钟的平均负载。
那么top命令展示的数据是从何而来的呢?我们可以使用strace来跟踪top命令的系统调用,看到这个过程:
#strace top ...... openat(AT_FDCWD, "/proc/loadavg", O_RDONLY) = 9 ......内核中定义了loadavg这个伪文件的open函数。在用户态访问/proc/loadavg会触发内核定义的函数,在这里会读取内核中的平均负载变量,经过计算便能够展示出来:
根据上图的流程展开看,伪文件/proc/loadavg在kernel中的定义在fs/proc/loadavg.c中。该文件会创建/proc/loadavg,并为其指定操作方法为loadavg_proc_show.
//file:fs/proc/loadavg.c static int loadavg_proc_show(struct seq_file *m, void *v) { unsigned long avnrun[3]; get_avenrun(avnrun, FIXED_1/200, 0); seq_printf(m, "%lu.%02lu %lu.%02lu %lu.%02lu %u/%d %d\n", LOAD_INT(avnrun[0]), LOAD_FRAC(avnrun[0]), LOAD_INT(avnrun[1]), LOAD_FRAC(avnrun[1]), LOAD_INT(avnrun[2]), LOAD_FRAC(avnrun[2]), nr_running(), nr_threads, idr_get_cursor(&task_active_pid_ns(current)->idr) - 1); return 0; }在loadavg_proc_show函数中做了两件事:
- 调用
get_avenrun读取当前负载值。 - 按照一定格式打印输出。
那么下一个问题随之而来,avenrun全局数组变量中存储的数据是在何时被如何计算出来的?
2.内核负载计算过程
avenrun全局数组变量的计算过程分为如下两步:
1.PerCPU定期汇总瞬时负载:定时将每个CPU当前任务刷新到calc_load_tasks,将每个CPU的负载数据汇总起来,得到系统当前的瞬时负载。
2.定时计算系统平均负载:定时器会根据当前系统整体瞬时负载,使用指数加权移动平均法计算过去1,5,15分钟的平均负载。
2.1PerCPU定期汇总负载
Linux内核中有一个时间子系统,在这里面初始化了一个高分辨率的定时器,在该定时器中会定时将每个CPU上的负载数据(running进程个数+uninterruptible进程个数)汇总到系统全局的瞬时负载变量calc_load_tasks中。整体流程如下图:
简单来说,在这个高分辨率定时器初始化的时候,会通过设定一个到期函数使CPU定期的去执行一些周期性任务,比如调度,负载均衡的操作,而刷新当前系统负载就是在这个时候进行的。
这里不大幅度展开内核源码的过程,这个到期函数最后会落到scheduler_tick方法上,在这个方法里把当前的值刷新到calc_load_tasks上,每个CPU都会定时刷新,故此时记录的就是当前系统上的瞬时负载值。
我们直接定位到最后的内核函数看是如何根据运行队列计算负载值的:
//file:kernel/sched/loadavg.c long calc_load_fold_active(struct rq *this_rq, long adjust) { long nr_active, delta = 0; nr_active = this_rq->nr_running - adjust; nr_active += (int)this_rq->nr_uninterruptible; if (nr_active != this_rq->calc_load_active) { delta = nr_active - this_rq->calc_load_active; this_rq->calc_load_active = nr_active; } return delta;//返回一个差值 }明显是计算了当前运行队列里的nr_running和nr_uninterruptible两种状态的任务数量,对应的就是用户空间中的R和D状态的任务数,此外,由于calc_load_tasks是一个长期存在的数据,所以在刷新rq里的任务数时,只需要刷新变化的量,不用全部重新计算。所以上述函数返回的是一个delta。
2.2 定时计算系统平均负载
在传统意义上,我们计算平均数时采取的方法是,把过去一段时间的数据都加起来然后取平均数。但是如果用这个方法计算平均负载,会存在以下的问题:
1️⃣需要存储过去每一个采样周期的数据
假设没10毫秒采集一次,就需要使用一个比较大的数组将每一次采样的数据全部存起来,那么统计过去15分钟的平均负载就需要存9万个数据。而且每出现一个新的观察值,就要从这个数组中减去一个最早的观察值,再加上一个新的观察值,那么就需要对这个内存数组进行频繁的修改和更新。
2️⃣计算过程较为复杂
计算的时候需要把整个数组全部加起来,再除以样本总数。
3️⃣不能准确的表现当前的变化趋势
在传统的平均数计算过程中,所有数字的权重都是一样的,但是对于平均负载这种实时的应用来说,其实越靠近当前时刻的数值权重应该更大一点,所以在实际的Linux中使用的是指数加权移动平均法(EMWA)。使用这个算法计算平均数只需要上一个时间的平均数即可,不需要保存所有瞬时负载值。另外,越靠近现在的时间点,权重越高。
现在详细看一下其详细执行过程,时间子系统在时钟中断中定期进行一些处理,即do_timer函数:
//file:kernel/time/timekeeping.c void do_timer(unsigned long ticks) { jiffies_64 += ticks; calc_global_load(); }其中的calc_global_load是平均负载计算的核心,它会获取系统当前瞬时负载值,然后计算过去1,5,15分钟的平均负载,并存在averun里。
//file:kernel/sched/loadavg.c void calc_global_load(void) { ...... //1.获取当前瞬时负载值 active = atomic_long_read(&calc_load_tasks); //2.平均负载的计算 avenrun[0] = calc_load(avenrun[0], EXP_1, active); avenrun[1] = calc_load(avenrun[1], EXP_5, active); avenrun[2] = calc_load(avenrun[2], EXP_15, active); ....... }在calc_load函数中采用前面说的指数加权移动平均法来计算过去的平均负载。
//file:include/sched/loadavg.h #define FSHIFT 11 /* nr of bits of precision */ #define FIXED_1 (1<<FSHIFT) /* 1.0 as fixed-point */ #define LOAD_FREQ (5*HZ+1) /* 5 sec intervals */ #define EXP_1 1884 /* 1/exp(5sec/1min) as fixed-point */ #define EXP_5 2014 /* 1/exp(5sec/5min) */ #define EXP_15 2037 /* 1/exp(5sec/15min) */ static inline unsigned long calc_load(unsigned long load, unsigned long exp, unsigned long active) { unsigned long newload; newload = load * exp + active * (FIXED_1 - exp); if (active >= load) newload += FIXED_1-1; return newload / FIXED_1; }公式如下:
exp是衰减系数,决定历史数据和当前数据的权重比例。exp越大:历史数据权重越高,负载变化越平滑,响应越慢。exp越小:当前数据权重越高,负载变化越敏感,响应越快。
根据上次的负载,和瞬时负载值,以及衰减系数计算。
综上,我们通过top可以观测到的load值就是这么计算出来的。Linux定时将每个CPU上的运行队列中的running和uninterruptible状态的进程数量汇总到一个全局的系统瞬时负载值中,然后再定时使用指数加权移动平均法来统计过去1分钟,过去5分钟,过去15分钟的平均负载。
3.平均负载和CPU消耗
大家都容易将平均负载和CPU联系到一起,简单地认为负载高,CPU消耗就会高;负载低,CPU消耗就会低。
根据上述所分析的load计算过程,平均负载表现的是系统对所有资源的需求情况,而不是只是表现对CPU的需求。我们所计算的D状态的进程,是因等待硬件资源(如磁盘 I/O、网络 IO)而暂停,虽不占用 CPU,但处于 “被阻塞且无法被信号中断” 的状态,属于系统资源的实际占用者。
假设当前有一个TASK_UNINTERRUPTIBLE状态的进程因为等待磁盘的I/O而排队,此时其并不消耗CPU,而是在等待磁盘等硬件资源,那么他是要计算在平均负载里的。
所以,我们看到的load是当前服务器上对系统资源的整体需求情况,如果负载变高,可能是CPU资源不够,也可能是磁盘I/O资源不够,所以要配合其他的的观测手段去解决。
4.高load排查手段
首先我们要知道,上述的计算出负载的值是针对整个系统的,所以要知道什么情况属于高负载?
使用nproc获取当前机器的核数,即逻辑核个数。如果:
负载/核数>1
其实已经表示系统过载。
其次,load这个值,也和请求数没有任何关系,真正和load相关的是工作线程数量,main线程是工作线程、Timer是工作线程、GC线程也是工作线程,load是以线程/进程作为统计指标,无论请求数是多少,最终都需要线程去处理,而工作线程的处理性能直接决定了最终的load值。
举个例子来说,假设一个服务中有一个线程池,线程池中线程数量固定为64:
- 正常来说一个任务执行时间为10ms,线程拿到任务10ms处理完,很快回归线程池等待下一个任务到来,自然很少有处于运行状态或者等待IO的线程,从一个统计周期来看load表现为很低;
- 某段时间由于系统问题,一个任务10s都处理不完,相当于线程一直在处理任务,在load的统计周期里面就体现出的值=64(不考虑这64条线程外的场景)。
所以,搞清楚load值和请求数、线程数的关系也是非常重要。
下面给出两种常见的高负载排查手段:
4.1 load高,CPU高
首先,cpu高不是问题,由cpu高引起的load高才是问题,load是判断系统能力指标的依据。我们可以先定位到高CPU利用率的进程,接着可以定位到线程。按照应用的类型进行堆栈打印。
# 步骤1:通过top命令查看系统整体负载与CPU使用率 top # 步骤2:按<P>键排序,查找占用CPU最高的进程PID top -c # 显示完整命令行 # 步骤3:精确定位问题线程 top -Hp <PID> # 查看指定进程的线程CPU使用率生成线程堆栈快照
# 方式1:使用jstack(Java应用) jstack <PID>|grep<hex_TID>-A30 # 方式2:使用gdb(C/C++应用) gdb -p<PID>(gdb) thread apply all bt full根据堆栈内容去做详细排查。
4.2 load高,CPU低
1️⃣I/O wait
如果是load高,但是CPU利用率比较低,可能是 I/O wait的情况:
使用top命令实时监控:
这里的wa指的就是CPU 等待 I/O 完成的时间百分比。
于是可以针对磁盘和网络的I/O去进行具体排查,找到异常进程。
2️⃣fork爆炸,上下文切换次数太多
遂登录机器做排查,但是没有CPU高的进程,wa指标也很低,CPU基本是空闲的,所以现在就要考虑其他的情况。
1.首先使用vmstat:
可以看到上下文切换次数是极度异常的,说明CPU正在120多万个小任务之间切换,开销极大。这里可以得到的初步结论是:CPU没有被打满,而是大量的任务在等待运行但却没有实际的运行,导致调度器被压垮,初步怀疑是用户态的某些程序里有异常的行为。
2.查看哪些命令创建了最多的运行态进程
ps -eo stat,comm | awk '$1 ~ /^R/ {print $2}' | sort | uniq -c | sort -nr | head -20结果如下:
这里有150个处于运行态的ps进程,以及56个处于运行态的cleanlog脚本。目前可以怀疑是这个脚本的问题,可能在创建大量进程。
使用以下命令查看是否有哪个程序在反复刷屏:
watch -n 1 "ps -eo comm | sort | uniq -c | sort -nr | head"结果如下:
可以确认之前的判断,这里都可以看出是cleanlog.sh和crond的问题。这个系统中有一个定时的cron任务在执行cleanlog脚本,造成进程数爆炸。
下一步就需要去查看这个脚本里到底干了什么,导致进程数爆炸。