接上文深入讲解CFS组调度!(上)
六、task group时间片
6.1. 时间片分配
若使能CFS组调度会从上到下逐层通过权重比例来分配上层分得的时间片,分配函数是sched_slice()。但是从上到下不便于遍历,因此改为从下到上进行遍历,毕竟 ABC 和 CBA 是相等的。
sched_slice的主要路径如下:
在tick中断中,若发现se此次运行时间已经超过了其分得的时间片,就触发抢占,以便让其让出CPU。
如下图4,假设tg嵌套2层,且在当前CPU上各层gse从tg那里分得的权重都是1024,且假设直接通过任务个数来计算周期,5个tse,period 就是 3 * 5 = 15ms那么:
tse1 获得 1024/(1024+1024) * 15 = 7.5ms;
tse2 获得 [1024/(1024+1024+1024)] * {[1024/(1024+1024)] * 15 }= 2.5ms
tse4 获得 [1024/(1024+1024)] * {[1024/(1024+1024+1024)] * [1024/(1024+1024)] * 15} = 1.25ms
图4:
注:tg1和tg2的权重通过 cpu.shares 文件进行配置,然后各个cpu上的gse从 cpu.shares 配置的权重中按其上的grq的权重比例分配权重。gse的权重不再和nice值挂钩。
6.2. 运行时间传导
pick_next_task_fair() 会优先pick虚拟时间最小的se。gse的虚拟时间是怎么更新的呢。虚拟时间是在 update_curr()中进行更新,然后通过 for_each_sched_entity 向上逐层遍历更新gse的虚拟时间。若tse运行5ms,则其父级各gse都运行5ms,然后各层级根据自己的权重更新虚拟时间。
主要调用路径:
在选择下一个任务出来运行时逐层级选择虚拟时间最小的se,若选到gse就从其grq上继续选,直到选到tse。
资料直通车:Linux内核源码技术学习路线+视频教程内核源码
学习直通车:Linux内核源码内存调优文件系统进程管理设备驱动/网络协议栈
七、task group的PELT负载
7.1. 计算负载使用的timeline
计算负载使用的timeline和计算虚拟时间使用的timeline不同。计算虚拟时间时使用的timeline是 rq->clock_task, 这个是运行多长时间就是多长时间。而计算负载使用的timeline是rq->clock_pelt,它是根据CPU的算力和当前频点scale后的,在CPU进idle是会同步到rq->clock_task上。因此PELT计算出来的负载可以直接使用,而不用像WALT计算出来的负载那样还需要scale。更新rq->clock_pelt这个timeline的函数是 update_rq_clock_pelt()
最终计算的 delta= delta * (capacity_cpu / capacity_max(1024)) * (cur_cpu_freq / max_cpu_freq) 也就是将当前cpu在当前频点上运行得到的delta时间值,缩放到最大性能CPU的最大频点上对应的delta时间值。然后累加到 clock_pelt 上。比如在小核上1GHz下跑了5ms,可能只等效于在超大核上运行1ms,因此在不同Cluster的CPU核上跑相同的时间,负载增加量是不一样的。
7.2. 负载定义与计算
load_avg 定义为:load_avg = runnable% * scale_load_down(load)。
runnable_avg 定义为:runnable_avg = runnable% * SCHED_CAPACITY_SCALE。
util_avg 定义为:util_avg = running% * SCHED_CAPACITY_SCALE。
这些负载值保存在struct sched_avg结构中,此结构内嵌到se和cfs_rq结构中。此外,struct sched_avg中还引入了load_sum、runnable_sum、util_sum成员来辅助计算。不同实体(tse/gse/grq/cfs_rq)的负载只是其runnable% 多么想运行,和 running% 运行了多少的表现形式不同。这两个因数只对tse取值是[0,1]的,对其它实体则超出了这个范围。
7.2. 1. tse负载
下面看一下tse负载计算公式,为了加深印象,举一个跑死循环的例子。计算函数见 update_load_avg --> __update_load_avg_se().
load_avg: 等于 weight * load_sum / divider, 其中 weight = sched_prio_to_weight[prio-100]。由于 load_sum 是任务 running+runnable 状态的几何级数,divider 近似为几何级数最大值,因此一个死循环任务的 load_avg 接近于其权重。
runnable_avg: 等于 runnable_sum / divider。由于 runnable_sum 是任务 running+runnable 状态的几何级数然后scale up后的值,divider 近似为几何级数最大值,因此一个死循环任务的 runnable_avg 接近于 SCHED_CAPACITY_SCALE。
util_avg: 等于 util_sum / divider。由于 util_sum 是任务 running 状态的几何级数然后scale up后的值,divider 近似为几何级数最大值,因此一个死循环任务的 util_avg 接近于 SCHED_CAPACITY_SCALE。
load_sum: 是对任务是单纯的 running+runnable 状态的几何级数累加值。对于一个死循环,此值趋近于 LOAD_AVG_MAX 。
runnable_sum: 是对任务 running+runnable 状态的几何级数累加值然后scale up后的值。对于一个死循环,此值趋近于 LOAD_AVG_MAX * SCHED_CAPACITY_SCALE 。
util_sum: 是对任务 running 状态的几何级数累加值然后scale up后的值。对于一个独占某个核的死循环,此值趋近于 LOAD_AVG_MAX * SCHED_CAPACITY_SCALE,若不能独占,会比此值小。
7.2.2. cfs_rq的负载
下面看一下cfs_rq负载计算公式,为了加深印象,举一个跑死循环的例子。计算函数见 update_load_avg --> update_cfs_rq_load_avg --> __update_load_avg_cfs_rq()。
load_avg: 直接等于 load_sum / divider。cfs_rq 跑满(跑一个死循环或多个死循环),趋近于cfs_rq的权重,cfs_rq的权重也就是其上挂的所有调度实体的权重之和,即Sum(sched_prio_to_weight[prio-100]) 。
runnable_avg: 等于 runnable_sum / divider。cfs_rq 跑满(跑一个死循环或多个死循环),趋近于cfs_rq上任务个数乘以 SCHED_CAPACITY_SCALE。
util_avg: 等于 util_sum / divider。cfs_rq 跑满(跑一个死循环或多个死循环),趋近于 SCHED_CAPACITY_SCALE。
load_sum: cfs_rq 的 weight,也就是本层级下所有se的权重之和乘以非idle状态下的几何级数。注意是本层级,下面讲解层次负载h_load时有用到。
runnable_sum: cfs_rq上所有层级的runnable+running 状态任务个数和乘以非idle状态下的几何级数,然后再乘以 SCHED_CAPACITY_SCALE 后的值。见 __update_load_avg_cfs_rq().
util_sum: cfs_rq 上所有任务 running 状态下的几何级数之和再乘以 SCHED_CAPACITY_SCALE 后的值。
load_avg、runnable_avg、util_avg分别从权重(优先级)、任务个数、CPU时间片占用三个维度来描述CPU的负载。
7.2.3. gse 负载
对比着tse来讲解gse:
(1) gse会和tse走一样的负载更新流程(逐层向上更新,就会更新到gse)。
(2) gse的runnable负载与tse是不同的。tse的 runnable_sum是任务 running+runnable 状态的几何级数累加值然后scale up后的值。而gse是其当前层级下所有层级的tse的个数之和乘以时间几何级数然后scale up后的值,见 __update_load_avg_se() 函数 runnable 参数的差异。
(3) gse 和tse的 load_avg 虽然都等于 se->weight * load_sum/divider, 见 ___update_load_avg() 的参数差异。但是weight 来源不同,因此也算的上是一个差异点,tse->weight来源于其优先级,而gse来源于其从tg中分得的配额。
(4) gse会比tse多出了一个负载传导更新过程,放到下面讲解(若不使能CFS组调度,只有一层,没有tg的层次结构,因此不需要传导,只需要更新到cfs_rq上即可)。
7.2. 4. grq 负载
grq的负载和cfs_rq的负载在更新上没有什么不同。grq会比cfs_rq多了一个负载传导更新过程,放到下面讲解。
7.2.5. tg的负载
tg只有一个load负载,就是tg->load_avg,取值为\Sum tg->cfs_rq[]->avg.load_avg,也即tg所有CPU上的grq的 load_avg 之和。tg负载更新是在update_tg_load_avg()中实现的,主要用于给gse[]分配权重。
调用路径:
7.3. 负载传导
负载传导是使能CFS组调度后才有的概念。当tg层次结构上插入或删除一个tse的时候,整个层次结构的负载都变化了,因此需要逐层向上层进行传导。
7.3.1. 负载传导触发条件
是否需要进行负载传导是通过struct cfs_rq 的 propagate 成员进行标记。grq上增加/删除的tse时会触发负载传导过程。tse的负载load_sum值会记录在 struct cfs_rq 的 prop_runnable_sum 成员上,然后逐层向上传导。其它负载(runnable_、util_)则会通过tse-->grq-->gse-->grq...逐层向上层传导。
在 add_tg_cfs_propagate() 中标记需要进行负载传导:
此函数调用路径:
由上可见,当从非CSF调度类变为CFS调度类、移到当前tg中来、新建的任务开始挂到cfs_rq上、迁移到当前CPU都会触发负载传导过程,此时会向整个层次结构中传导添加这个任务带来的负载。当任务从当前CPU迁移走、变为非CFS调度类、从tg迁移走,此时会向整个层次结构中传导移除这个任务降低的负载。
注意,任务休眠时并没有将其负载移除,只是休眠期间其负载不增加了,随时间衰减。
7.3.2. 负载传导过程
负载传导过程体现在逐层更新负载的过程中。如下,负载更新函数update_load_avg() 在主要路径下,每层都会进行调用:
load负载传导函数和标记需要进行传导的函数是同一个,为 add_tg_cfs_propagate(), 其调用路径如下:
7.3.2.1. update_tg_cfs_util() 更新gse和grq的util_* 负载,并负责将负载传递给上层。
可见gse的util负载在传导时直接取的是其grq上的util负载。然后通过更新上层 grq 的 util_avg 向上层传导。
7.3.2.2. update_tg_cfs_runnable() 更新gse和grq的runnable_*负载,并负责将负载传递给上层。
可见gse的runnable负载在传导时也是直接取的是其grq上的runnable负载。然后通过更新上层 grq 的 runnable_avg 向上层传导。
7.3.2.3. update_tg_cfs_load() 更新gse和grq的load_*负载,并负责将负载传递给上层。
load负载比较特殊,负载传导时并不是直接取自grq的load负载,而是在向grq添加/删除任务时就记录了tse 的load_sum值,然后在 add_tg_cfs_propagate() 中逐层向上传导,传导位置调用路径:
对load负载的标记和传导都是这个函数:
load负载更新函数:
删除任务就是将grq上的se的平均load_sum赋值给gse。添加任务是将gse的load_sum直接加上delta值。
load_avg和普通tse计算方式一样,为load_sum*se_weight(gse)/divider。
对比可见,runnable负载和util负载的传导方向是由grq-->gse,分别通过runnable_avg/util_avg进行传导,gse直接取grq的值。而load负载的传导方向是由gse-->grq进行传导,且是通过load_sum进行传导的。
load负载传导赋值方式上为什么和runnable负载和util负载有差异,可能和其统计算法有关。对于runnable_avg,gse计算的是当前层级下所有层级上tse的个数和乘以runnable状态时间级数的比值,底层增加一个tse对上层相当于tse个数增加了一个;对于util_avg,gse计算的是其下所有tse的running状态几何级数和与时间级数的比值,底层增加一个tse对上层就相当于增加了tse的running状态的几何级数;而 load_avg 和se的权重有关,gse和tse的权重来源不同,前者来自从tg->shares中分得的配额,而后者来源于优先级,不能直接相加减。而load_sum对于se来说是一个单纯的runnable状态的时间级数,不涉及权重,因此tse和gse都可以使用它。
对于load_avg的传导举个例子,如下图5,假如ts2一直休眠,ts1和ts3是两个死循环,那么gse1的grq1的load_avg将趋近于4096,而根cfs_rq的负载将趋近于2048,若此时要将ts3迁移走,若像计算runnable和util负载那样直接想减,得到的delta值是-4096,那么根cfs_rq的load_avg将会是个负值(2048-4096<0),这显然是不合理的。若通过load_sum进行传导,它只是个时间级数,相减后根cfs_rq上只相当于损失了50%的负载。
图5:
注: 这只是在tg的层次结构中添加/删除任务时的负载的传导更新路径,随着时间的流逝,即使没有添加/移除任务,gse/grq的负载也会更新,因为普通的负载更新函数 __update_load_avg_se()/update_cfs_rq_load_avg() 并没有区分是tse还是gse,是cfs_rq还是grq。
7.4. 层次负载
在负载均衡的时候,需要迁移CPU上的负载以便达到均衡,为了达成这个目的,需要在CPU之间进行任务迁移。然而各个task se的load avg并不能真实反映它对root cfs rq(即对该CPU)的负载贡献,因为task se/cfs rq总是在某个具体level上计算其load avg。比如grq的load_avg并不会等于其上挂的所有tse的load_avg的和,因为runnable的时间级数肯定是Sum(tse) > grq的(有runnable等待运行的状态存在)。
为了计算task对CPU的负载(h_load),在各个cfs rq上引入了hierarchy load的概念,对于顶层cfs rq而言,其hierarchy load等于该cfs rq的load avg,随着层级的递进,cfs rq的hierarchy load定义如下:
下一层的cfs rq的h_load = 上一层cfs rq的h_load x gse负载在上一层cfs负载中的占比
在计算最底层tse的h_load的时候,我们就使用如下公式:
tse的h_load = grq的h_load x tse的load avg / grq的load avg
获取和更新task的h_load的函数如下:
更新grq的h_load的函数如下:
调用路径:
可以看到,主要是唤醒wake_affine_weight机制和负载均衡逻辑中使用。比如迁移类型为load的负载均衡中,要迁移多少load_avg可以使负载达到均衡,使用的就是task_h_load(),见 detach_tasks()。
八、总结
本文介绍了CFS组调度功能引入的原因,配置方法,和一些实现细节。此功能可以在高负载下"软限制"(相比与CFS带宽控制)各分组任务对CPU资源的使用占比,以达到各组之间公平使用CPU资源的目的。在老版原生Android代码中对后台分组限制的较狠(甚是将 background/cpu.shares 设置到52),将CPU资源重点向前台分组进行倾斜,但这个配置可能会在某些场景下出现前台任务被后台任务卡住的情况,对于普适性配置,最新的一些Android版本中将各个分组的 cpu.shares 都设置为1024以追求CPU资源在各组之间的公平。