当2022级的同学考上研究生,这个暑假在云班课开启了Linux内核学习之旅,很多同学是零基础、本科非计算机专业,通过两个月的学习,他们逐渐踏入Linux内核的大门,开启性能探索之旅。
这篇报告主要根据CPU性能指标——运行队列长度、调度延迟和平均负载,对系统的性能影响进行简单分析。
CPU调度程序运行队列中存放的是那些已经准备好运行、正等待可用CPU的轻量级进程,如果准备运行 的轻量级进程数超过系统所能处理的上限,运行队列就会很长,运行队列长表明系统负载可能已经饱和。
代码源于参考资料1中map.c用于获取运行队列长度的部分代码:-
// 获取运行队列长度
// SEC("kprobe/update_rq_clock")
int update_rq_clock(struct pt_regs *ctx) {
u32 key = 0;
u32 rqKey = 0;
struct rq *p_rq = 0;
p_rq = (struct rq *)rq_map.lookup(&rqKey);
if (!p_rq) { // 针对map表项未创建的时候,map表项之后会自动创建并初始化
return 0;
}
bpf_probe_read_kernel(p_rq, sizeof(struct rq), (void *)PT_REGS_PARM1(ctx));
u64 val = p_rq->nr_running;
runqlen.update(&key, &val);
return 0;
}
挂载点:update_rq_clock()函数
update_rq_clock()被scheduler_tick()函数调用。
周期性调度器:
周期性调度器在scheduler_tick中实现. 如果系统正在活动中, 内核会按照频率HZ自动调用该函数. 如果没有进程在等待调度, 那么在计算机电力供应不足的情况下, 内核将关闭该调度器以减少能耗. 这对于我们的 嵌入式设备或者手机终端设备的电源管理是很重要的。
周期性调度器主流程:
scheduler_tick函数定义在kernel/sched/core.c,linux内核版本:5.15:
void scheduler_tick(void)
{
int cpu = smp_processor_id();
struct rq *rq = cpu_rq(cpu);
struct task_struct *curr = rq->curr;
struct rq_flags rf;
unsigned long thermal_pressure;
u64 resched_latency;
arch_scale_freq_tick();
sched_clock_tick();
rq_lock(rq, &rf);
update_rq_clock(rq);
.....
在这个函数中主要做两方面工作:
函数 | 描述 | 定义 |
---|---|---|
update_rq_clock | 处理就绪队列时钟的更新, 本质上 就是增加struct rq当前实例的时钟时间戳 | sched/core.c |
update_cpu_load_active | 负责更新就绪队列的cpu_load数 组, 其本质上相当于将数组中先前 存储的负荷值向后移动一个位置, 将当前就绪队列的符合记入数组的 第一个位置. 另外该函数还引入一 些取平均值的技巧, 以确保符合数 组的内容不会呈现太多的不联系跳读. | kernel/sched/fair.c |
calc_global_load_tick | 跟新cpu的活动计数, 主要是更新 全局cpu就绪队列的 calc_load_update | kernel/sched/loadavg.c |
2 . 激活负责当前进程调度类的周期性调度方法。
由于调度器的模块化结构, 主体工程其实很简单, 在更新统计信息的同时, 内核将真正的调度工作委 托给了特定的调度类方法。
内核先找到了就绪队列上当前运行的进程curr, 然后调用curr所属调度类sched_class的周期性调度 方法task_tick,即:
curr->sched_class->task_tick(rq, curr, 0);
task_tick的实现方法取决于底层的调度器类, 例如完全公平调度器会在该方法中检测是否进程已经 运行了太长的时间, 以避免过长的延迟, 注意此处的做法与之前就的基于时间片的调度方法有本质区 别, 旧的方法我们称之为到期的时间片, 而完全公平调度器CFS中则不存在所谓的时间片概念.
bpf_probe_read_kernel():读取内核结构体的成员
rq结构体:
linux内核用结构体rq(struct rq)将处于就绪(ready)状态的进程组织在一起。rq结构体包含cfs和rt成员,分别表示两个就绪队列:cfs就绪队列用于组织就绪的普通进程(这个队列上 的进程用完全公平调度器进行调度);rt就绪队列用于用于组织就绪的实时进程(该队列上的进程用实时调 度器调度)。在多核系统中,每个CPU对应一个rq结构体**。
struct rq {
/* runqueue lock: */
raw_spinlock_t lock;
/*
nr_running and cpu_load should be in the same cacheline because
remote CPUs use both these fields when doing load calculation.
*/
unsigned int nr_running;
....
nr_running:表示总共就绪的进程数(包括cfs,rq及正在运行的)
正常运行结果,查看第三列的运行队列长度:
压力测试工具 stress-ng :
这里进行压力测试后,再次查看运行队列长度:
可以看到运行队列长度的明显变化,从3左右变化到了10左右。
总结:
当系统运行队列长度等于虚拟处理器的个数时,用户不会明显感觉到性能下降,当运行队列长度达到虚 拟处理器的4倍或更多时,系统的响应就非常迟缓了。
CPU调度程序运行队列性能调优的一般原则:
如果在很长一段时间里,运行队列的长度一直都超过虚拟处理器个数的1倍,就需要关注了,只是暂时不需要立即采取行动。如果在很长一段时间里,运行队列的长度达到虚拟处理器个数的3~4倍或更高,则需要立即采取行动。
解决CPU调用程序运行队列过长有以下两个方法:
调度延迟
所谓调度延迟,是指一个任务具备运行的条件(进入 CPU 的 runqueue),到真正执行(获得 CPU 的 执行权)的这段时间。
runqlat是一个bcc和bpftrace工具,用于测量cpu调度程序延迟,通常称为运行队列延迟。
runqlat.py部分代码:
.......
int trace_wake_up_new_task(struct pt_regs *ctx, struct task_struct *p)
{
return trace_enqueue(p->tgid, p->pid);
}
int trace_ttwu_do_wakeup(struct pt_regs *ctx, struct rq *rq, struct task_struct
*p,
int wake_flags)
{
return trace_enqueue(p->tgid, p->pid);
}
// record enqueue timestamp
static int trace_enqueue(u32 tgid, u32 pid)
{
if (FILTER || pid == 0)
return 0;
u64 ts = bpf_ktime_get_ns();
start.update(&pid, &ts);
return 0;
}
/*trace_enqueue()函数只做了一件事情,就是记录当前这个pid进程进入 runqueue 的时间戳, 现在只
考虑最普通的情况,只记录pid的情况,因此每有一个 task 被加入到 runqueue 的时候,就记录这个
task 的 pid 和当前的纳秒时间戳。*/
int trace_run(struct pt_regs *ctx, struct task_struct *prev)
{
u32 pid, tgid;
// ivcsw: treat like an enqueue event and store timestamp
if (prev->__state == TASK_RUNNING) {
tgid = prev->tgid;
pid = prev->pid;
if (!(FILTER || pid == 0)) {
u64 ts = bpf_ktime_get_ns();
start.update(&pid, &ts);
}
}
tgid = bpf_get_current_pid_tgid() >> 32;
pid = bpf_get_current_pid_tgid();
if (FILTER || pid == 0)
return 0;
u64 *tsp, delta;
// fetch timestamp and calculate delta
tsp = start.lookup(&pid);
if (tsp == 0) {
return 0; // missed enqueue
}
delta = bpf_ktime_get_ns() - *tsp;
FACTOR
// store as histogram
STORE
start.delete(&pid);
return 0;
}
.....
# load BPF program
b = BPF(text=bpf_text)
if not is_support_raw_tp:
b.attach_kprobe(event="ttwu_do_wakeup", fn_name="trace_ttwu_do_wakeup")
b.attach_kprobe(event="wake_up_new_task", fn_name="trace_wake_up_new_task")
b.attach_kprobe(event="finish_task_switch", fn_name="trace_run")
print("Tracing run queue latency... Hit Ctrl-C to end.")
.....
挂载点:
唤醒睡眠进程: process_timeout->wake_up_process->try_to_wake_up->ttwu_queue-> ttwu_do_activate()->ttwu_do_wakeup
新进程创建后(do_fork),也会被唤醒(wake_up_new_task)
wake_up系列函数,完成两个主要功能:
进程被重新调度时无论是否为刚fork出的进程都会走到finish_task_switch这个函数,主要工作为:检查回收前一个进程资源,为当前进程恢复执行做一些准备工作。
使用runqlat工具:
正常情况下使用 runqlat工具,查看调度延迟分布情况:
压力测试:
压力测试后,再次查看调度延迟:
这里观察压力测试前后的调度延迟,从最大延迟511微秒变化到了32767微秒,可以明显的看到调度延迟 的变化。
以上的 runqlat脚本只能看出延迟时间的统计结果,如果要探究延迟为什么会增大,得用 perf 这样更精 细的工具。在保持 4 个 worker 线程的情况下,采样 5 秒内和 "sched" 相关的信息:perf sched record -- sleep 5,然后用 perf sched latency 解析,可以看到每个进程的运行时间、最大延迟等 信息。像这里,就是 stress-ng 进程有 4 个线程,总共运行了 20 秒左右,最大延迟为 5.151 毫秒。
说明:
当CPU 还被其他任务占据,还没有空出来,可能还有其他在 runqueue 中排队的任务。就会产生调度延 迟,排队的任务越多,调度延迟就可能越长,所以这也是间接衡量 CPU 负载的一个指标(CPU 负载通 过计算各个时刻 runqueue 上的任务数量获得)。
平均负载:
正常情况下的top命令:
看1分钟、5分钟、15分钟的load average分别为0.66、1.68、1.49,并且cpu基本上是空闲状态。压力测试后的top命令:
再次查看1分钟、5分钟、15分钟的load average分别为4.98、3.17、1.98,并且cpu占用率达到了 99.3%。
load average 是对 CPU 负载的评估,其值越高,说明其任务队列越长,处于等待执行的任务越多。
说明:
多核和多处理器下的平均负载,单个四核处理器和具有四个处理器(每个处理器一个核)的服务器是否 相同?相对来说,是的。多核和多处理器的主要区别在于,前者是指单个 CPU 具有多个内核,而后者是 指多个 CPU。总结一下:一个四核等于两个双核,也就是四个单核。平均负载与服务器中可用内核的数 量有关,而不是它们在 CPU 上的分布情况。这意味着最大利用率范围是单核 0-1、双核 0-2、四核 0- 4、八核 0-8,依此类推。在单核处理器上,负载为 1.00 意味着容量在单核处理器上恰到好处;而在双 核处理器上,负载为 1.50 意味着负载已满,另一个也要耗尽满。同样,四核处理器上的 5.00 负载是值 得担心的,而在八核处理器上,5.00 意味着正在消耗,并且仍有最佳可用空间。我的虚拟机是四核的, 这里看出,一分钟内的平均负载已经达到4.98,已经是非常高的了。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8