linux CFS调度和load balance

内容有点多,文章篇幅长!!!

本文讲述的是基于sched entity的cfs调度和load balance,不涉及group的cfs调度和load balance. 代码是基于linux-4.7.1.

首先,我们来看一下两个初始化sched_init()和sched_init_smp()。

start_kernel() -> sched_init()

kernel/sched/core.c

sched_init()为每个cpu的rq(running queue)初始化:

7404行,初始化当前rq上的task计数nr_running为0,即当前rq上没有可运行的task。

7442行,清零rq的cpu_load[].

7445行,设置sched domain为NULL.

7446行,设置root domain为NULL.

7452行,设置当前的cpu号.

7473行,设置系统的第一个手工进程init_task的load weight.

kernel/sched/core.c

set_load_weight()根据进程的优先级priority来计算一个weight/inv_weight表的索引,weight/inv_weight表是实现计算好的,他们将用于进程的vrunning_time的计算,其中inv_weight是通过232  / weight计算得来的,即inv_weight是weight的反转。

weight(sched_prio_to_weight[])和inv_weight(sched_prio_to_wmult[])定义:

kernel/sched/core.c

其中sched_prio_to_weight[20] * sched_prio_to_wmult[20] = 232 , 即默认优先级的weight * inv_weight刚好为232 .

include/linux/sched/prio.h

从上述定义可以看出来,进程的优先级0-100为RT进程(实时进程), 101-140为normal进程(普通进程)优先级。进程的nice转换为进程的优先级为prio = nice + default_prio.

7488行,设置init_task的调度类为cfs调度类。

7496行,初始化init_task的sched entity,并设置为running状态。调度类初始化为idle类。

kernel/sched/core.c

7508行,设置load rebalance的中断以及中断句柄run_rebalance_domains,用于load balance处理。

kernel/sched/fair.c

 

start_kernel() -> rest_init() -> kernel_init() -> kernel_init_freeable() -> sched_init_smp()

kernel/sched/core.c

7281行,sched_init_smp()调用init_sched_domains()去初始化调度域sched domains.

6980行,init_sched_domains()调用build_sched_domains()去构建sched domains.

kernel/sched/core.c

6859行,调用__visit_domain_allocation_hell()给特定的sched domain做些初始化工作,比如分配sched domain内存,分配sched group内存等。

kernel/sched/core.c

我们接着来看__visit_domain_allocation_hell()调用的__sdt_alloc().

kernel/sched/core.c

这个__sdt_alloc()主要是给sd,sg等二维指针数组分配对应的内存,我们来看一下这个for_each_sd_topology()。

kernel/sched/core.c

我们这里假设既没有SMT,又没有MC,所以只有6448这行一个。

在kernel_init_freeable()调用sched_init_smp()之前,先调用了sched_init_numa(), 对于NUMA架构,这个6448行的default_topology[]会被修改:

对于非NUMA架构来说,default_topology[]没有变化。

下面是我找的NUMA架构的toppology信息:

6869行,调用build_sched_domain() -> sd_init().

kernel/sched/core.c

sd_init()给每个cpu的sched domain进行了初始化。6365行,调用了tl->mask(cpu)来获取指定cpu的同组的其他cpu。这里的tl->mask()对于非NUMA架构就是default_topology[].cpu_cpu_mask()。

include/linux/topology.h

这个mask函数首先获取cpu所在的node号(cpu_to_node(), 非NUMA架构,相当于NUMA架构下, 只有一个node的特殊情形),然后获取这个node上存在的所有的cpu的bitmap,之后由cpumask_weight()来计算出这个bitmap上bit1的数目,即当前cpu所在node的cpu数目,这个数据用作load balance min和max的时间数。总之,在非NUMA架构下,每个cpu的sched domain下的span(即sd->span)为系统中所有的cpu.

build_sched_domain()在sd_init()完成后,调用cpumask_and()设置sd->span为当前sd对应的cpu所在的node的cpu bitmap, 即sd->span记录了对应node的所有的cpu。

tl->mask对于NUMA架构来说,就是sd_numa_mask(),作用和cpu_cpu_mask()类似。

kernel/sched/core.c

6882行,设置sd->span_weight为对用node的所有cpu的数目。

6884行, 调用build_overlap_sched_groups()去设置sched group. (NUMA架构)

kernel/sched/core.c

每个node上的cpu都可以创建sched group,存放在对应的sched domain下,相同node下的cpu的sched group list内容相同。

6887行, 调用build_sched_groups()去设置sched group. (非NUMA架构)

kernel/sched/core.c

由于是非NUMA架构,故每个cpu的sched domain的span(即sd->span)为系统中所有的cpu. 所有cpu创建的sched group list的顺序都是一样的,以first cpu为list head.

 

6900行,调用init_sched_groups_capacity()设置sg->group_weight.

kernel/sched/core.c

按照上面的分析,这个sg->group_weight应该为1,只有node的first cpu.

6908行,调用cpu_attach_domain将sched domain关联到rq->sd上。

好了,这两个初始化旧介绍到这,接下来,我们来看一个进程创建的系统调用sys_fork().

kernel/fork.c

sys_fork()调用_do_fork()来创建子进程, _do_fork()调用copy_process()来创建一个新的task_struct, 且这个新的task_struct拷贝父进程的task_struct的数据, 之后wake up这个新创建的task_struct,即唤醒子进程。

kernel/fork.c

我们先来分析下这个copy_process().

kernel/fork.c

copy_process()主要是copy当前进程current的数据,比如files, sighandler, signal等。和调度相关的就是1457行的sched_fork().

kernel/sched/core.c

sched_fork()调用__sched_fork()初始化新进程task_struct的调度实体sched entity.

kernel/sched/core.c

之后,根据进程的优先级,设置进程的调度类,本文讲述的调度类是CFS,故进程的sched_class为fair_sched_class。

设置了调度类后,调用调度类的task_fork()函数。

最后,2397行,暂时将该进程的运行cpu设置为父进程所在的cpu,并设置新进程on_cpu为false.

我们接着来看一看这个调度类的task_fork(), 即fair_sched_class.task_fork()。

这个fair_sched_class.task_fork()为定义在kernel/sched/fair.c的函数task_fork_fair().

kernel/sched/fair.c

task_fork_fair()调用update_rq_clock()更新rq的clock: rq->clock和rq->clock_task,并设置新进程p所在的cpu为当前cpu,之后调用update_curr()更新当前进程的vruntime. 我们接着来分析update_curr().

kernel/sched/fair.c

759行,计算当前时间和上一次update_curr()走过的时间。

760行,异常处理。上一行计算的时间差,我们期望的当然永远是正数(当前clock数-过去clock数)。由于clock数的存储是u64,那么不可避免的会产生数据溢出的情形。那么数据溢出时,计算结果会有问题吗?我们来分析下这个问题:

当now > curr->exec_start,且now没有溢出时,那么delta_exec > 0, 没有问题;

当now继续增长后,now溢出了,这时候,now就很小了。now – curr->exec_start就需要分情况了:

在进行下方分析时,我们要先知道,一个u64的数,和它的按位取反的数之和为264 -1.

  • 当now 溢出后,且now < 263时:

a. 如果curr->exec_start < 263 即

由于参与运算的两个数最高位bit63为0,即两个整数的减法运算。

  1. 如果now < curr->exec_start,他们之间的实际clock差值最小为264 - 263 = 263,这时候, (s64)(now – curr->exec_start)结果为负数,属于不正常值。即当now和curr->exec_start间隔为263,即 clock 差为263 (假如HZ为1000时,相当于292471208年),(s64)(now – curr->exec_start)为负数,属异常值, 会在761行return。

  1. 如果now > curr->exec_start, now – curr->exec_start远小于实际的clock数,出现这种情况时,说明调度程序出问题了。

总之,上述这2个case都不正常。出现不正常时,两次的时间间隔很长、很长(至少292471208年)。

b. 如果curr->exec_start >= 263 即:

  1. 如果now的低63 bits的值大于等于curr->exec_start的低63bits的值时,(s64)(now-curr->exec_start) < 0, 此时now和curr->exec_start的间隔的clock数为 263-curr->exec_start低63bits值 + now, 重新变化下公式为263 + (now – curr->exec_start低63bits值), 这个值最小为263 , 所以此时 (s64)(now – curr->exec_start) < 0时,它们之间的clock数很大了(292471208年), 也属调度不正常状态了。
  2. 如果now的低63bits的值小于curr->exec_start的低63bits的值时,(s64)(now – curr->exec_start) > 0,等价于now + (-curr->exec_start)。

这里now好理解,就是溢出后经过的clock数。

而-curr->exec_start如何理解呢? 我们知道curr->exec_start + (-curr->exec_start) = 264 (溢出),所以-curr->exec_start = 264 - curr->exec_start。由于 curr->exec_start + (~curr->exec_start(按位取反)) = 264 – 1, 所以-curr->exec_start = 264 - curr->exec_start = (~curr->exec_start(按位取反)) + 1, 即-curr->exec_start表示curr->exec_start中剩余未置位的bits的值 + 1,即curr->exec_start需要经过的clock数才会达到溢出条件。这个就是正常状况下的clock溢出情况,程序能正确计算出过去的clock数。

  • 当now 溢出后,且now >= 263时:
  1. 如果curr->exec_start < 263 即

如果now的低63bits小于curr->exec_start的低63bits, 那么(s64)(now – curr->exec_start) > 0; 如果now的低63bits大于等于curr->exec_start的低63bits, 那么(s64)(now -curr->exec_start) < 0; 这两种情况,无论哪种,都是不正常的,因为两者之间的clock数至少是263.

  1. 如果curr->exec_start >= 263 即

如果now的低63bits小于curr->exec_start的低63bits, 那么(s64)(now – curr->exec_start) < 0; 如果now的低63bits大于等于curr->exec_start的低63bits, 那么(s64)(now -curr->exec_start) > 0; 这两种情况,无论哪种,都是不正常的,因为两者之间的clock数至少是263.

 

综上所述,调度程序的clock要没有溢出和调度问题, 两次更新clock的时间差不能太长。

好了,讲完了两个表示clock的u64数据相减溢出的问题后,我们接着来看update_curr()。

kernel/sched/fair.c

update_curr()计算出过去的clock后,更新当前进程的调度实体(sched entity)的exec_start为当前的clock, 并计算和记录最大的exec_max,以及当前进程总共的sum_exec_runtime, 同时更新cfs_rq的exec_clock数(769行)。

771行,计算当前进程的vruntime,我们着重来看一下这个计算过程。

kernel/sched/fair.c

calc_delta_fair(): 如果当前进程优先级为nice 0,即默认优先级,则vruntime直接等价于实际的delta clock, 否则调用__calc_delta()来计算。

kernel/sched/fair.c

这个__calc_delta()就是计算: delta * weight * lw->inv_weight / 232 = delta * weight *(232/lw->weight) /232 = delta * (weight/lw->weight), 这里weight为nice 0的权重, lw->weight为进程的nice值对应的权重。

由公式vruntime += delta * nice_0_weightlw→weight 可知,当进程的优先级越小, sched_prio_to_weight[]下标就越大,lw->weight越小, delta转换成的vruntime就越大,即进程优先级越小,相同的clock数转换成的vruntime就越大,而由于cfs调度每次都是选vruntime最小的task_struct来跑,故优先级越小的进程,被cfs调度器选中的次数就越小(vruntime增长得快),而优先级越大的进程,被cfs调度器选中的次数也就越大。

772行,计算完当前进程的vruntime后,调用update_min_vruntime()更新cfs_rq的最小min_vruntime

kernel/sched/fair.c

462行,初始化vruntime为当前进程的vruntime, 至少在上一次pick task的时候,当前进程的vruntime是最小的。

465行,取sched entity tree中记录的vruntime最小的sched entity, sched entity tree中的sched entity都是等待cpu运行的sched entity. 当前进程在上一次的pick task后运行了一段时间,在之前更新了vruntime,这个时候,vruntime最小的就由可能是sched entity tree中vruntime最小的那个进程了。

472行,取当前进程的vruntime和sched entity tree中vruntime最小的sched entity的vruntime两者中的最小的vruntime.

476行,当前cfs_rq->min_vruntime为上一次时的最小的vruntime,可能为当前进程那时候的vruntime,现在取之前cfs_rq->min_vruntime和当前实际最小的vruntime的最大值为新的cfs_rq->min_vruntime。

774-780行,更新当前进程的统计数据。

到这里, update_curr()执行完了,我们回到task_fork_fair()。

update_curr()之后,在8344行设置fork出来的新进程的vruntime为当前进程(父进程)的vruntime, 这样可使得子进程能的vruntime不至于太大或太小。

linux cfs对于新创建的进程,通过place_entity()对其vruntime要做适当的“惩罚”,以让其晚于父进程后被调度。

task_fork_fair()在最后(8356行),将新创建的进程的vruntime减去当前cpu的cfs_rq->min_vruntime,即保留相对值。

为什么要这么做呢?因为新创建的进程不一定会被本cpu调度,如果它被其他cpu调度,直接将vruntime带过去,那么这个新创建的进程在和target cpu的min_vruntime相比较,有可能很大,也有可能很小,对target cpu上的其他进程不公平。所以,这里取一个相对值,当这个新进程被调度到新的cpu上时,这个新进程的vruntime会被修改为target cpu cfs_rq->min_vruntime + se->vruntime, 这样对其他进程也就公平了。

至此,task_fork_fair()结束了。

下面,我们回到_do_fork()的copy_process()之后。

kernel/fork.c

_do_fork()通过copy_process()创建新的task_struct后,调用wake_up_new_task().

kernel/sched/fork.c

2530行, 调用init_entity_runnable_average()初始化进程的runnable average, 用于贡献cpu load的.

kernel/sched/fair.c

2538行,先调用select_task_rq(), 基于cpu load balance选择新进程运行所在的cpu,然后通过set_task_cpu()设置task_struct所在的cpu为select_task_rq()选出来的cpu.

 我们主要来看一下select_task_rq().

kernel/sched/core.c

select_task_rq()调用sched_class的select_task_rq, 即select_task_rq_fair().

kernel/sched/fair.c

5346行,循环取当前cpu的所有sched domain,我们假设sched_domain_topology[]只有一个,那么,每个cpu的sched domain就只有一个了(sched domain 是在build_sched_domains()中构建的,每个sched_domain_topology[]构建一个sched domain,当cpu有多个sched_domain_topology[]是, 会有多个sched domain, 并用链表串起来)。

5380行,通过find_idlest_group()在指定cpu的sched domain中查找最空闲的sched group.

find_idlest_group()就是遍历当前cpu所在node的所有sched group, 找到avg_load最小的group.

5386行,通过find_idlest_cpu()从group中找出load最小的cpu,来作为本次新创建的进程被安排在其上运行的cpu.

上述的find_idlest_group()和find_idlest_cpu()结合初始化过程构建的sched domain 和sched group来分析,好理解些,这里再列一下sched domain和sched group的关系:

到这里, select_task_rq_fair()已经结束,选出了新进程运行所在的cpu了。我们回到wake_up_new_task()继续分析。

在select_task_rq()选到cpu后,通过set_task_cpu(),设置了新进程的cpu,然后调用activate_task()。

我们来看一看这个activate_task()。

kernel/sched/core.c

activate_task()调用enqueue_task() -> p->sched_class->enqueue_task(), 将新进程入队running queue.

这个p->sched_class->enqueue_task()就是enqueue_task_fair()

kernel/sched/fair.c

4468行,这里我们不涉及组调度,故:

 

enqueue_task_fair()调用了enqueue_entity(),入队新进程的sched entity.

kernel/sched/fair.c

3302行,我们这里的se是新进程的sched entity,cfs_rq->curr代表的是当前进程(父进程)的sched entiry, 故curr = false.

3311行,调用update_curr()更新当前进程的vruntime(这部分前面介绍过,这里不再重复)。

3320行,之前task_fork的时候介绍过了se->vruntime存放的是相对于cfs_rq->min_vruntime的差值,经过之前的select_task_rq(),找到target cpu了,现在要放入target cpu的running queue,这里就是加上这个target cpu的cfs_rq->min_vruntime.

3322行,enqueue_entity_load_avg这里将新进程的load加到cpu上.

kernel/sched/fair.c

3020行,取cfs_rq->clock_task.

3023行,新进程的sa->last_update_time初始化为0,故migrated = true,新进程经历过从父进程的cpu到select_task_rq()选出来的cpu的过程,相当于cpu间迁移。

3030行,调用update_cfs_rq_load_avg()计算新进程给cpu带来的load average,这是load balance的核心(基础)。我们来分析下这个函数。

这个函数的开头详细注释了进程load_avg的算法:

我们以某个进程更新load为实例,来讲述这个算法。

进程当前的contrib_load和两个数据有关: 一个是当前的load,另一个是过去的load。

以进程创建的时间节点为起始点,将时间按1ms为周期period进行划分。

假设进程当前的load为U0, 过去1个周期的load为U1,过去2个周期的load为U2,过去3个周期的load为U3, ……

假设进程当前的contrib_load为L0, 过去1个周期的contrib_load为L1,过去2个周期的contrib_load为L2,过去3个周期的contrib_load为L3, ……

假设进程上一个周期的contrib_load对当前的contrib_load的衰减因子为Y.

则进程当前的contrib_load的计算公式为:

L0 = U0 + L1 * Y

     = U0 + (U1 + L2 * Y) * Y

     = U0 + (U1 + (U2 + L3 * Y) * Y) * Y

     = U0 + (U1 + (U2 + (U3 + L4 * Y) * Y) * Y) * Y

     = U0 + ……

     = U0 + U1 * Y + U2 * Y2 + U3 * Y3 + …… + Un * Yn

其中,Un = se->load.weight * T, 其中T为时间,单位为纳秒(ns).

整周期的Un = se->load.weight * 1024,

取Y32 = 0.5, 则Y = 0.97857206208770013450916112581344.

那么,实际的计算过程如下:

计算从T1到T0这段时间内进程的contrib_load,分成3部分: delta_0, delta_1, delta_2:

T1到T0的周期数(ms)为P = (T0 – T1) / 1024.

delta_0的纳秒数Tdelta_0,

delta_1的纳秒数Tdelta_1, Tdelta_1为(x+2)个周期.

delta_2的纳秒数Tdelta_2

delta_0部分contrib_load:    L0 = (sa->load_sum + se->load.weight * Tdelta_0) * Y(x+2).

delta_1部分contrib_load:    L1 = (se->load.weight * 1024) * Y1 + (se->load.weight * 1024) * Y2 + (se->load.weight * 1024) * Y3 + …… + (se->load.weight * 1024) * Y(x+1) = (se->load.weight * 1024) * (Y + Y2 + Y3 + …… + Y(x+1)).

delta_2部分contrib_load:    L2 = (sa->load_sum + se->load.weight * Tdelta_2) * Y0 = sa->load_sum + se->load.weight * Tdelta_2.

其中sa->load_sum为上一次的contrib_load.

__update_load_avg()就是按照上述的公式(delta_0 + delta_1 + delta_2)来实现的。为了消除浮点数运算,进行了左右移操作,即:

delta_0部分contrib_load:    L0 = (sa->load_sum + se->load.weight * Tdelta_0) * Y(x+2) * 232 / 232.

delta_1部分contrib_load:    L1 = (se->load.weight * 1024) * Y1 * 232 / 232 + (se->load.weight * 1024) * Y2 * 232 / 232 + (se->load.weight * 1024) * Y3 * 232 / 232 + …… + (se->load.weight * 1024) * Y(x+1) * 232 / 232 = (se->load.weight * 1024) * (Y * 232  + Y2 * 232  + Y3 * 232  + …… + Y(x+1) * 232) / 232 = (se->load.weight * 1024) * (Y  + Y2  + Y3  + …… + Y(x+1) )  * 232/ 232.

delta_2部分contrib_load:    L2 = (sa->load_sum + se->load.weight * Tdelta_2) * Y0 * 232 / 232 = sa->load_sum + se->load.weight * Tdelta_2.

 

其中,将Yn * 232和((Y + Y1 + Y2 + …… + Y(x)) * 232)提前计算好,分别存放在数组runnable_avg_yN_inv[]和runnable_avg_yN_sum[]中。

kernel/sched/fair.c

2787行, 计算load_avg。

 

从init_entity_runnable_average()可知,这个load_avg的含义就是se->load.weight的平均值.

load_sum的含义就是当前进程的contrib_load.

根据上述公式我们可知 contrib_load的计算:

L0 = U0 + U1 * Y + U2 * Y2 + U3 * Y3 + …… + Un * Yn, 且U0 = U1 = U2 = … =Un = se->load.weight * 1024.

所以L0 = se->load.weight * 1024 * (Y0 + Y1 + Y2 + Y3 + Y4 + … + Yn).

1024 * (Y0 + Y1 + Y2 + Y3 + Y4 + … + Yn) = 1024 * 1-Y(n+1)1-Y  = 1024 * 1-0.97857206n+11-0.97857206,

当n -> ∞时,1024 * 1-0.97857206n+11-0.97857206 <= 1024 * 10.02142794  <= 1024 * 46.6 = 47788

这个LOAD_AVG_MAX就是这个 1024 * (Y0 + Y1 + Y2 + Y3 + Y4 + … + Yn)的最大值。

所以,新进程创建的时候,初始化的load_sum为”时间0”开始到进程在创建的时间节点所能达到的最大的contrib_load。

平均的load_avg = load_sum / LOAD_AVG_MAX, 表明当前的contrib_load / 过去n个周期的衰减因子累加。

也因此,进程的优先级越大,运行状态(在running queue中)时间越长,其load_sum就越大,load_avg也就越大,对cpu的load贡献也越大。

__update_load_avg()之后,我们回到enqueue_entity_load_avg(), enqueue_entity_load_avg()在更新好cfs_rq的load后,加上新进程的load。

enqueue_entity_load_avg()处理完了后,返回到enqueue_entity().

enqueu_entity()在3323行调用account_entity_enqueue() -> update_load_add(),将新进程的se->load.weight加到cfs_rq中。

最后,在3338行,调用__enqueue_entity()将当前进程加到running queue中, 入队操作也结束,这个时候,新创建的进程在cpu的running queue中了,只要等待机会,被cpu执行,则这个新进程就跑起来了。

新进程入队后,wake_up_new_task() -> check_preempt_curr() -> rq->curr->sched_class->check_preempt_curr(),调用check_preempt_curr()检查新进程是否可以抢占当前进程。能抢占,则设置TIF_NEED_RESCHED标志,在返回用户空间前, 中断程序会检查这个标识,如果存在这个标识,则调用schedule()程序重新调度。

kernel/sched/fair.c

好了,fork()的过程我们就讲到这里,主要相关的部分都讲到了。

下面,我们要描述调度程序schedule()的执行时机以及执行过程:

schedule()的执行时机主要是三类: 系统调用时,系统调用返回时和中断处理程序返回时。

系统调用时:

 涉及进程主动放弃占用cpu, 当前进程为等待某个资源时陷入可中断睡眠状态或者进程退出(sys_exit())。举个例子, sys_nanosleep()

kernel/compat.c

系统调用返回时:

系统调用返回时,检查当前进程是否有TIF_NEED_RESCHED标识,有就调用schedule().

arch/x86/entry/entry_32.S

arch/x86/entry/common.c

中断处理程序返回时:

和系统调用返回时一样,检查当前进程是否有TIF_NEED_RESCHED标识,有就调用schedule().

arch/x86/entry/entry_64.S

这段代码显示,当从中断返回时,如果是返回用户模式,则检查TIF_NEED_RESCHED标识, 有则通过prepare_exit_to_usermode() 触发schedule()调用; 如果返回的是内核模式(特权级别), 则检查cpu的抢占计数,抢占计数不为0,则调用preempt_schedule_irq()触发schedule()调用,即所谓的内核抢占。

kernel/sched/core.c

下面,我们来讲讲这个schedule()调度程序:

kernel/sched/core.c

__schedule()首先更新当前cpu的rq的clock(3334行),然后调用pick_next_task()找出当前running queue中vruntime最小的task sched entity, 最后调用context_switch()切换当前cpu上下文到新的进程。我们来分析下这个pick_next_task()怎么做的。

kernel/sched/core.c

从pick_next_task()的实现来看,系统中只要是存在非fair_sched_class类的进程,则会按类优先级来pick_next_task(),优先级高的优先pick。调度类的优先级排序如下(高到低):

stop_sched_class  -> dl_sched_class -> rt_sched_class -> fair_sched_class -> idle_sched_class

本文只谈论fair_sched_class,故pick_next_task()调用了pick_next_task_fair().

kernel/sched/fair.c

pick_next_task_fair()首先调用put_prev_task() -> put_prev_task_fair() -> put_prev_entity() 将当前进程入队。

然后,调用pick_next_entity()找到vruntime最小的调度实体。

之后,调用set_next_entity()更新新的运行进程的load,并出队。

最后将进程返回,并切换到该进程的上下文,完成进程的切换。

至此,调度过程结果。

到目前为止,我们还有一个问题没说。那就是进程创建起来后,它的contrib_load数据是什么时候更新的。

先给出结论:

进程的contrib_load的数据更新是在scheduler_tick()中完成,scheduler_tick()只更新当前在cpu上运行的进程的contrib_load,所以进程的这个contrib_load数据只在占用cpu阶段才能更新。

scheduler_tick()是在时钟中断中调用的。

kernel/sched/core.c

scheduler_tick()首先调用curr->sched_class->task_tick()即task_tick_fair(), 这个task_tick_fair()首先更新当前进程的contrib_load,最后还检查是否有抢占发生(check_preempt_tick()),有则设置TIF_NEED_RESCHED标识.

更新contrib_load的算法函数__update_load_avg()前面介绍过,这里不再赘述。

scheduler_tick()最后调用trigger_load_balance()触发一次软中断SCHED_SOFTIRQ。

当硬件中断处理好要返回时,先check是否有软中断发生,有则调用对应的软中断处理函数。

SCHED_SOFTIRQ这个软中断的处理函数,就是去运行load rebalance, 使各cpu的load大体均衡。

SCHED_SOFTIRQ这个软中断的处理函数在sched_init() -> init_sched_fair_class()时注册过了。

kernel/sched/fair.c

下面,我们来看本文最后一个内容了,即这个run_rebalance_domains()是如何来均衡各cpu的load的。

kernel/sched/fair.c

rebalance_domains() 检查是否过了load balance的时间间隔,然后调用 load_balance()去do balance.

kernel/sched/fair.c

load_balance()是将其他cpu上的task弄到自己的running queue中,即它认为自己是相对空闲的。

kernel/sched/fair.c

load_balance()调用find_busiest_group()查找最busy的group,然后调用find_busiest_queue(),从这个group中找出load最高的cpu,并返回其running queue.

之后,调用detach_tasks() -> detach_task() -> deactivate_task() -> dequeue_task()将busiest cpu的rq中第一个task出队,即将任务移出cpu的running queue,并设置task的target cpu为当前cpu.

最后,调用attach_tasks() -> attach_task() -> activate_task() -> enqueue_task()将之前出队的tasks入队当前cpu的running queue, 并检查设置任务抢占标识。

 

最后的最后,我们来看一下/proc/loadavg.

6.76 6.83 7.40:

分别标识系统在过去1分钟,5分钟,15分钟内的平均Load,注意,这里说的Load和本文之前描述的load不是一个概念,这里的Load大致标识为系统中running和uninterrupt状态的进程的数量。

10/2294:

分别表示当前系统中running状态,且在running queue中,不占用cpu的进程数和系统中的线程数(task, fork时++)

13033:

表示系统中当前net namespace下最后一个被创建的task的pid.

fs/proc/loadavg.c

这里,avenrun[]每5秒更新一次。

fs/proc/loadavg.c

366行,从calc_load_tasks中读取所有cpu的running queue中的进程数(当前占用cpu的进程不在这个计数中)+uninterrupted的进程数。calc_load_tasks的更新: scheduler_tick() -> calc_global_load_tick()

fs/proc/loadavg.c

392行这里的calc_load_fold_active()就是计算task的数目。

fs/proc/loadavg.c

所有cpu调用calc_global_load_tick()只将自己rq上的task数目加到calc_load_tasks上。

我们回到计算load的地方: calc_global_load()

fs/proc/loadavg.c

369-371行的EXP_1, EXP_5, EXP15的定义:

include/linux/sched.h

EXP_1 = 1 / e1/12 * FIXED_1

EXP_5 = 1 / e1/60 * FIXED_1

EXP_15 = 1 / e1/180 * FIXED_1

这三个数用于计算平均Load的因子,我们来看计算算法实现函数calc_load().

load的计算公式为a1 = a0 * e + a * (1 - e):

这里

对于过去1分钟内的平均Load,e = 1 / exp1/12 = 0.92005239,(自然底数exp为2.718);

对于过去5分钟内的平均Load,e = 1 / exp1/60 = 0.98347316,( 自然底数exp为2.718);

对于过去15分钟内的平均Load,e = 1 / exp1/180 = 0.99446042,( 自然底数exp为2.718);

a表示当前的进程数。由计算公式知,当前进程数对平均Load的贡献,随着时间的变大而减小。

我们假设系统除最近的5秒外计算的历史上的load为p,最近5秒的load为a, 则:

p0  = 0 * e + a0 * (1-e)  =   a0 * (1-e)    // 5秒

p1 = p0 * e + a1 * (1-e)   =  a0 * (1 - e) * e +  a1 * (1-e)   //10秒

p2 = p1 * e + a2 * (1-e) =  a0 * (1 - e) * e2 +  a1 * (1-e) * e + a2 * (1-e)   //15秒

……

pn = pn-1 * e + an * (1-e) = a0 * (1 - e) * en +  a1 * (1-e) * en-1 + a2 * (1-e) * en-2 + … + an-1 * (1-e) * e + an * (1-e)

     = (1-e) * (a0 * en + a1 * en-1 + a2 * en-2 + … + an-1 * e + an)         ------  ①

上述公式①,结合y = ex的指数曲线(如下图):

 

可以得到:

  1. 对于某一个“时刻”的load(a),其对当下计算的平均load的贡献值(a * (1 - e) * e^n, n > 0)随着时间的逝去,贡献值越小。
  2. 1分钟平均Load: n < 12, 曲线变化最快,近前的load对其当下平均Load的计算相比5分钟和15分钟影响最大 a * (1 - e)。反应出该平均Load对”波动数据”更敏感

5分钟平均load:n < 60, 曲线变化居中,近前load对其当下平均load的计算影响相比1分钟的平均Load要小些,随着时间的延后,其影响相对1分钟平均Load来说影响变小的慢。反应出该平均Load对”波动数据”敏感剧中。

15分钟平均load:n <180, 曲线变化最慢,近前的load对当前平均Load的计算影响在3者中最小,随时间的逝去,影响因子变化最慢。反应出该平均Load对”波动数据”最不敏感,也即最平稳。

我们假设,系统中系统中只有n个cpu,共有a个task在running queue中,系统中没有uninterrupt状态的task. 那么,上述公式中,a0 = a1 = a2 = a3 =… = an = a, 则pn = a * (1-e) * (en + en-1 + en-2 + … + e + e0) = a * (1-e) * (1-e(n+1))(1-e) = a * (1-e(n+1))

则平均负载(pn)y1, y2, y3如下表格(n取最大值,y1对应为11, y2对应为59, y3对应为179):

y1 = a * (1-0.9200523912) = 0.6320822 * a

y2 = a * (1-0.9834731660) = 0.6320823 * a

y3 = a * (1-0.99446042180) = 0.6320825 * a

 

a=1

a=2

a=3

a=5

a=10

y1

0.632

1.264

1.896

3.16

6.32

y2

0.632

1.264

1.896

3.16

6.32

y3

0.632

1.264

1.896

3.16

6.32

由上表可知,在系统的task数目不变的情况下, 1分钟平均Load,5分钟平均load,15分钟平均Load都是一样的,每一个task对平均load的贡献为0.632. 如果系统中没有task在等待cpu,那么平均load为0.

如果一个系统,只有1个cpu,这个cpu上只有一个task在running queue,那么它的loadavg为0.632.

如果一个系统,他有n个cpu,这n个cpu上,每个cpu的running queue中只有一个task,那么它的loadavg为0.632 * n.

上面的两种情况,系统的cpu load认为是等价的,即相同程度的cpu load。

 

与/proc/loadavg比较容易混淆的是另外一个cpu数据: cpu usage. 对应的数据来源为/proc/stat.

对应的内核代码:

fs/proc/stats.c

show_stat()首先将所有cpu的时间加起来,放在第一行(cpu)输出,然后分别输出各个cpu的时间。show_stat()每一行输出的含义为:

下面的函数调用用来说明cpu时间的更新,tick_sched_timer()每个jiffy(cpu时钟中断)触发一次。

tick_sched_timer() -> tick_sched_handle() -> update_process_times() -> account_process_tick()

update_process_times()从寄存器CS(x86_32)获知当前cpu的运行特权级别是user还是kernel。

kernel/sched/cputime.c

user_tick只是当前进程的运行特权级别,1表示用户态,0表示内核态。

cputime_one_jiffy表示一个jiffy所对应的cputime数, 一般都是会设置1 cputime = 1 jiffy。

include/asm-generic/cputime_jiffies.h

account_process_tick()按照当前进程的运行特权级别,更新1 jiffy的时间到对应的统计类(user, system, idle)中,我们来看account_user_time()。

kernel/sched/cputime.c

148行表明当nice值大于0时,更新到CPUTIME_NICE,否则更新到CPUTIME_USER。

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值