注:本文缩写说明
一、CFS组调度简介
1.1. 存在的原因
总结来说是希望不同分组的任务在高负载下能分配可控比例的CPU资源。为什么会有这个需求呢,比如多用户计算机系统每个用户的所有任务划分到一个分组中,A用户90个相同任务,而B用户只有10个相同任务,在CPU完全跑满的情况下,那么A用户将占90%的CPU时间,而B用户只占到了10%的CPU时间,这对B用户显然是不公平的。再或者同一个用户,既想-j64快速编译,又不想被编译任务影响使用体验,也可将编译任务设置到对应分组中,限制其CPU资源。
1.2. 手机设备上的分组状态
/dev/cpuctl 目录使用struct task_group root_task_group 表示。其下的每一层级的子目录都抽象为一个task_group结构。有几个需要注意的点:
- 根组下的cpu.shares 文件默认值是1024,而且不支持设置。根组的负载也不会更新。
- 根组也是一个task group,根组下的任务也是被分过组的,不会再属于其它组。
- 内核线程默认在根组下,其直接从根cfs_rq上分配时间片,有非常大的优势,若是其一直跑,那在trace上看就几乎就是”一直跑”,比如常见的kswapd内核线程。
- 默认cpu.shares配置下,若全是nice=0的任务,且只考虑单核的情况下,根组下的每个任务在完全满载情况下能得到的时间片等于其它分组下所有任务能分配到的时间片的总和。
注意:task和task group都是通过权重来分配时间片的,但是task的权重来自其优先级,而task group的权重则来自与其cgroup目录下cpu.shares文件设置的值。使能组调度后,看任务分得的时间片,就不能单看其prio对应的权重了,还要看其task group分得的权重和本group中其它任务的运行情况。
二、任务的task group分组
CFS组调度功能主要是通过对任务进行分组体现出来的,一个分组由一个struct task_group来表示。
2.1. 如何设置分组
task group分组配置接口由cpu cgroup子系统通过cgroup目录层次结构导出到用户空间。
如何从task group中移除一个任务呢,没有办法直接移除的,在cgroup语义下,一个任务某一时刻必须属于一个task group,只有通过将其echo到其它分组中才能将其从当前分组中移除。
2.2. Android中如何设置分组
Process.java中向其它模块提供 setProcessGroup(int pid, int group) 将pid进程设置进group参数指定的分组中,供其它模块进行调用设置。比如OomAdjuster.java 中将任务切前/后台分别调用传参group=THREAD_GROUP_TOP_APP/THREAD_GROUP_BACKGROUND。
libprocessgroup 中提供了一个名为 task_profiles.json 的配置文件,它里面 AggregateProfiles 聚合属性字段配置了上层设置下来后的对应的行为。比如 THREAD_GROUP_TOP_APP 对应的聚合属性为 SCHED_SP_TOP_APP,其中的MaxPerformance属性对应的行为就是加入到cpu top-app分组。
”MaxPerformance”属性的配置可读性非常强,可以看出是加入到cpu子系统的top-app分组中。
2.3. Android中设置为TOP-APP分组,对cgroup设置了什么
因为有多个cgroup子系统,除了我们正在讲的CFS组调度依附的cpu cgroup子系统外,还有cpuset cgroup子系统(限制任务可运行的CPU和可使用的内存节点),blkio cgroup子系统(限制进程的块设备io),freezer cgroup子系统(提供进程冻结功能)等。上层配置分组下来可能不只切一个cgroup,具体切了哪些子系统体现在集合属性 AggregateProfiles 的数组成员上,比如上例中另外两个属性对应的行为分别是加入blkio子系统的根组和将任务的timer_slack_ns(一个平衡hrtimer定时唤醒及时性与功耗的参数)设置为50000ns。
2.4. 服务启动后就放在指定分组
在启动服务的时候使用 task_profiles进行配置,举例。
资料直通车:Linux内核源码技术学习路线+视频教程内核源码
三、内核实现概述
上面我们讨论了对task group分组的配置,本节开始将进入到内核中,了解其实现。
3.1. 相关功能依赖关系
内核相关CONFIG_*依赖如下:
图1:
CGROUP提供cgroup目录层次结构的功能;CGROUP_SCHED提供cpu cgroup目录层次结构(如 /dev/cpuctl/top-app),并为每个cpu cgroup目录提供task group的概念;FAIR_GROUP_SCHED基于cpu cgroup提供的task group提供CFS任务组调度功能。图1中灰色的虚线框图是Android手机内核中默认不使能的。
3.2. task group数据结构框图
如下图2所示,展示了一个task group在内核中维护的主要数据结构的框图,对照着图下面概念更容易理解:
- 由于不能确定一个分组内的任务跑在哪个CPU上,因此一个task group在每个CPU上都维护了一个group cfs_rq,由于task group也要参与调度(要先选中task group才能选中其group cfs_rq上的任务) ,因此在每个CPU上也都维护了一个group se。
- task se的my_q成员为NULL,而group se的my_q成员指向其对应的group cfs_rq,其分组在本CPU上的就绪任务就挂在这个group cfs_rq上。
- task group可以嵌套,由 parent/siblings/children 成员构成一个倒立树状层次结构,根task group的parent指向NULL。
- 所有CPU的根cfs_rq属于root task group。
图2:
注:画图比较麻烦,此图是借鉴蜗窝的。
四、相关结构体
4.1. struct task_group
一个 struct task_group 就表示一个cpu cgroup分组。在使能FAIR_GROUP_SCHED的情况下,一个 struct task_group 就表示CFS组调度中的一个任务组。
css: 该task group对应的cgroup状态信息,通过它依附到cgroup目录层次结构中。
se: group se,是个数组指针,数组大小为CPU的个数。因为一个task group中有多个任务,且可以跑在所有CPU上,因此需要每个CPU上都要有本task group的一个 se。
cfs_rq: group se的cfs_rq, 是本task group的在各个CPU上的cfs_rq,也是个数组指针,数组大小为CPU的个数。当本task group的任务就绪后就挂在这个cfs_rq上。它在各个CPU上对应的指针和task group在各个CPU上的se->my_q具有相同指向,见 init_tg_cfs_entry().
shares: task group的权重,默认为scale_up(1024)。和task se的权重类似,值越大task group能获取的CPU时间片就越多。但是和task 不同的是,task group 在每个CPU上都有一个group se,因此需要按照一定规则分配给各CPU上的group se, 下面会讲解分配规则。
load_avg: 此task group的负载,而且只是一个load_avg 变量(不像se和cfs_rq是一个结构),下面会对其进行讲解。注意它不是per-cpu的,此task group的任务在各个CPU上进行更新时都会更新它,因此需要注意对性能的影响。
parent/siblings/children: 构成task group的层次结构。
内核中有个全局变量 struct task_group root_task_group, 表示根组。其cfs_rq[]就是各个cpu的cfs_rq, 其se[]都为NULL。其权重不允许被设置,见 sched_group_set_shares()。负载也不会被更新,见 update_tg_load_avg()。
系统中的所有task group 结构都会被添加到task_groups链表上,在CFS带宽控制时使用。
初学者可能会混淆组调度和调度组这两个概念,以及 struct task_group 与struct sched_group 的区别。组调度对应struct task_group,用来描述一组任务,主要用于util uclamp(对一组任务的算力需求进行钳制)和CPU资源使用限制,也是本文中要讲解的内容。调度组对应 struct sched_group,是CPU拓扑结构sched_domain中的概念,用来描述一个CPU(MC层级)/Cluster(DIE层级)的属性,主要用于选核和负载均衡。
4.2. struct sched_entity
一个sched_entity 既可以表示一个task se, 又可以表示一个group se。下面主要介绍使能组调度后新增的一些成员。
load: 表示se的权重,对于gse, 新建时初始化为NICE_0_LOAD, 见init_tg_cfs_entry()。
depth: 表示task group的嵌套深度,根组下的se的深度为0,每嵌套深一层就加1。比如/dev/cpuctl目录下有个tg1目录,tg1目录下又有一个tg2目录,tg1对应的group se的深度为0,tg1下的task se的深度是1,tg2下的task se的深度是2。更新位置见 init_tg_cfs_entry()/attach_entity_cfs_rq()。
parent: 指向父se节点, 父子se节点都是对应同一cpu的。根组下任务的指向为NULL。
cfs_rq: 该se挂载到的cfs_rq。对于根组下的任务指向rq的cfs_rq,非根组的任务指向其parent->my_rq,见init_tg_cfs_entry()。
my_q: 该se的cfs_rq, 只有group se才有cfs_rq,task se的为NULL,entity_is_task()宏通过这个成员来判断是task se还是group se。
runnable_weight: 缓存 gse->my_q->h_nr_running 的值,在计算gse的runnable负载时使用。
avg: se的负载,对于tse会初始化为其权重(创建时假设其负载很高),而gse则会初始化为0,见init_entity_runnable_average()。task se的和group se的有一定区别,下面第五章会进行讲解。
4.3. struct cfs_rq
struct cfs_rq 既可以表示per-cpu的CFS就绪队列,又可以用来表示gse的my_q队列。下面列出对组调度比较关键的一些成员进行讲解。
load: 表示cfs_rq的权重,无论是根cfs_rq还是grq,这里的权重都等于其队列上挂的所有任务的权重之和。
nr_running: 当前层级下 task se 和 group se的个数和。
h_nr_running: 当前层级以及所有子层级下task se的个数和,不包括group se。
avg: cfs_rq的负载。下面将对比task se、group se、cfs_rq讲解负载。
removed: 当一个任务退出或者唤醒后迁移到到其他cpu上的时候,原来CPU的cfs rq上需要移除该任务带来的负载。这个移除动作会先把移除的负载记录在这个removed成员中,在下次调用update_cfs_rq_load_avg()更新cfs_rq负载时再移除。nr表示要移除的se的个数,*_avg则表示要移除的各类负载之和。
tg_load_avg_contrib: 是对 grq->avg.load_avg 的一个缓存,表示当前grq的load负载对tg的贡献值。用于在更新 tg->load_avg 的同时降低对 tg->load_avg 的访问次数。在计算gse从tg分得的权重配额时的近似算法中也有用到,见 calc_group_shares()/update_tg_load_avg()。
propagate: 标记是否有负载需要向上层传播。下面7.3节会进行讲解。
prop_runnable_sum: 在负载沿着task group层级结构向上层传播的时候,表示要上传的tse/gse的load_sum值。
h_load: 层次负载hierarchy load,表示本层cfs_rq的load_avg对CPU的load_avg的贡献值,主要在负载均衡路径中使用。下面会对其进行讲解。
last_h_load_update: 表示上一次更新h_load的时间点(单位jiffies)。
h_load_next: 指向子gse,为了获取任务的hierarchy load(task_h_load函数),需要从顶层cfs向下,依次更新各个level的cfs rq的h_load。因此,这里的h_load_next就是为了形成一个从顶层cfs rq到底层cfs rq的cfs rq--se--cfs rq--se的关系链。
rq: 使能组调度后才加的这个成员,若没有使能组调度的话,cfs_rq就是rq的一个成员,使用 container_of进行路由,使能组调度后,增加了一个rq成员进行cfs_rq到rq的路由。
on_list/leaf_cfs_rq_list: 尝试将叶cfs_rq串联起来,在CFS负载均衡和带宽控制相关逻辑中使用。
tg: 该cfs_rq隶属的task group。
五、task group权重
task group的权重使用struct task_group 的 shares 成员来表示,默认值是scale_load(1024)。可以通过cgroup目录下的cpu.shares文件进行读写,echo weight > cpu.shares 就是将task group权重配置为weight,保存到shares 成员变量中的值是scale_load(weight)。root_task_group不支持设置权重。
不同task group的权重大小表示系统CPU跑满后,哪个task group组可以跑多一些,哪个task group组要跑的少一些。
5.1. gse的权重
task group在每个CPU上都有一个group se,那么就需要将task group的权重 tg->shares 按照一定的规则分配到各个gse上。规则就是公式(1):
* tg->weight * grq->load.weight
* ge->load.weight = ----------------------------------------- (1)
* \Sum grq->load.weight
其中 tg->weight 就是tg->shares, grq->load.weight 表示tg在各个CPU上的grq的权重。也就是每个gse根据其cfs_rq的权重比例来分配tg的权重。cfs_rq的权重等于其上挂载的任务的权重之和。假设tg的权重是1024,系统中只有2个CPU,因此有两个gse, 若其grq上任务状态如下如下图3,则gse[0]分得的权重为 1024 * (1024+2048+3072)/(1024+2048+3072+1024+1024) = 768;gse[1]分得的权重为 1024 * (1024+1024)/(1024+2048+3072+1024+1024) = 256。
图3:
gse的权重更新函数为 update_cfs_group(),下面看其具体实现:
tg的权重向gse[X]的分配动作是在 calc_group_shares() 中完成的。
公式(1)中使用到 \Sum grq->load.weight,也就是说一个gse权重的更新需要访问各个CPU上的grq,锁竞争代价比较高,因此进行了一系列的近似计算。
首先进行替换:
* grq->load.weight --> grq->avg.load_avg (2)
然后得到:
* tg->weight * grq->avg.load_avg
* ge->load.weight = ---------------------------------------- (3)
* tg->load_avg
*
* Where: tg->load_avg ~= \Sum grq->avg.load_avg
由于cfs_rq->avg.load_avg = cfs_rq->avg.load_sum/divider。而 cfs_rq->avg.load_sum 等于 cfs_rq->load.weight 乘以非idle状态下的几何级数。这个近似是在tg的每个CPU上的grq的非idle状态的时间级数是相同的前提下才严格相等的。也就是说tg的任务在各个CPU上的运行状态越一致,越接近这个近似值。
task group 空闲的情况下,启动一个任务。grq->avg.load_avg 需要时间来建立,在建立时间这种特殊情况下公式1简化为:
* tg->weight * grq->load.weight
* ge->load.weight = --------------------------------------- = tg->weight (4)
* grp->load.weight
相当于一个单核系统下的状态了。为了让公式(3)在这种特殊情况下更贴近与公式(4),又做了一次近似,得到:
* tg->weight * grq->load.weight
* ge->load.weight = -------------------------------------------------------------------- (5)
* tg->load_avg - grq->avg.load_avg + grq->load.weight
但是因为grq上没有任务时,grq->load.weight 可以下降到 0,导致除以零,需要使用 grq->avg.load_avg作为它的下限,然后给出:
* tg->weight * grq->load.weight
* ge->load.weight = ------------------------------------------ (6)
* tg_load_avg'
*
* 其中:
* tg_load_avg' = tg->load_avg - grq->avg.load_avg + max(grq->load.weight, grq->avg.load_avg)
max(grq->load.weight, grq->avg.load_avg) 一般都是取grq->load.weight,因为只有grq上一直有任务running+runnable才会趋近于grq->load.weight。
calc_group_shares() 函数是通过公式(6)近似计算各个gse分得的权重:
由于tg中的每个任务都对gse的权重有贡献,因此grq上任务个数变更时都要更新gse的权重,近似过程中使用到了se的负载,在entity_tick()中也进行了一次更新。调用路径:
5.2. gse上每个tse分到的权重
任务组中的任务也是按其权重比例分配gse的权重。如上图2中gse[0]的grq上挂的3个任务,tse1分得的权重就是7681024/(1024+2048+3072)=128, tse2分得的权重就是7682048/(1024+2048+3072)=256, tse3分得的权重就是768*3072/(1024+2048+3072)=384。
在tg中的任务分配tg分得的时间片的时候,会使用到这个按比例分得的权重。分组嵌套的越深,能按比例分得的权重就越小,由此可见,在高负载时task group中的任务是不利于分配时间片的。
文章篇幅过长,下文继续讲解!