背景
Read the fucking source code!
--By 鲁迅A picture is worth a thousand words.
--By 高尔基
说明:
- Kernel版本:4.14
- ARM64处理器,Contex-A53,双核
- 使用工具:Source Insight 3.5, Visio
1. 概述
组调度(task_group
)是使用Linux cgroup(control group)
的cpu子系统来实现的,可以将进程进行分组,按组来分配CPU资源等。
比如,看一个实际的例子:
A和B两个用户使用同一台机器,A用户16个进程,B用户2个进程,如果按照进程的个数来分配CPU资源,显然A用户会占据大量的CPU时间,这对于B用户是不公平的。组调度就可以解决这个问题,分别将A、B用户进程划分成组,并将两组的权重设置成占比50%即可。
带宽(bandwidth
)控制,是用于控制用户组(task_group
)的CPU带宽,通过设置每个用户组的限额值,可以调整CPU的调度分配。在给定周期内,当用户组消耗CPU的时间超过了限额值,该用户组内的任务将会受到限制。
由于组调度和带宽控制紧密联系,因此本文将探讨这两个主题,本文的讨论都基于CFS调度器,开始吧。
2. task_group
- 组调度,在内核中是通过
struct task_group
来组织的,task_group
本身支持cfs组调度
和rt组调度
,本文主要分析cfs组调度
。 - CFS调度器管理的是
sched_entity
调度实体,task_struct(代表进程)
和task_group(代表进程组)
中分别包含sched_entity
,进而来参与调度;
关于组调度的相关数据结构,组织如下:
- 内核维护了一个全局链表
task_groups
,创建的task_group
会添加到这个链表中; - 内核定义了
root_task_group
全局结构,充当task_group
的根节点,以它为根构建树状结构; struct task_group
的子节点,会加入到父节点的siblings
链表中;- 每个
struct task_group
会分配运行队列数组和调度实体数组(以CFS为例,RT调度类似),其中数组的个数为系统CPU的个数,也就是为每个CPU都分配了运行队列和调度实体;
对应到实际的运行中,如下:
struct cfs_rq
包含了红黑树结构,sched_entity
调度实体参与调度时,都会挂入到红黑树中,task_struct
和task_group
都属于被调度对象;task_group
会为每个CPU再维护一个cfs_rq
,这个cfs_rq
用于组织挂在这个任务组上的任务以及子任务组,参考图中的Group A
;- 调度器在调度的时候,比如调用
pick_next_task_fair
时,会从遍历队列,选择sched_entity
,如果发现sched_entity
对应的是task_group
,则会继续往下选择; - 由于
sched_entity
结构中存在parent
指针,指向它的父结构,因此,系统的运行也能从下而上的进行遍历操作,通常使用函数walk_tg_tree_from
进行遍历;
2.2 task_group权重
- 进程或进程组都有权重的概念,调度器会根据权重来分配CPU的时间。
- 进程组的权重设置,可以通过
/sys
文件系统进行设置,比如操作/sys/fs/cgoup/cpu/A/shares
;
调用流程如下图:
sched_group_set_shares
来完成最终的设置;task_group
为每个CPU都分配了一个sched_entity
,针对当前sched_entity
设置更新完后,往上对sched_entity->parent
设置更新,直到根节点;shares
的值计算与load
相关,因此也需要调用update_load_avg
进行更新计算;
看一下实际的效果图吧:
- 写节点操作可以通过
echo XXX > /sys/fs/cgroup/cpu/A/B/cpu.shares
; - 橙色的线代表传入参数指向的对象;
- 紫色的线代表每次更新涉及到的对象,包括三个部分;
- 处理完
sched_entity
后,继续按同样的流程处理sched_entity->parent
;
3. cfs_bandwidth
先看一下/sys/fs/cgroup/cpu
下的内容吧:
- 有两个关键的字段:
cfs_period_us
和cfs_quota_us
,这两个与cfs_bandwidth息息相关; period
表示周期,quota
表示限额,也就是在period
期间内,用户组的CPU限额为quota
值,当超过这个值的时候,用户组将会被限制运行(throttle
),等到下一个周期开始被解除限制(unthrottle
);
来一张图直观理解一下:
- 在每个周期内限制在
quota
的配额下,超过了就throttle
,下一个周期重新开始;
3.1 数据结构
内核中使用struct cfs_bandwidth
来描述带宽,该结构包含在struct task_group
中。
此外,struct cfs_rq
中也有与带宽控制相关的字段。
还是来看一下代码吧:
struct cfs_bandwidth {
#ifdef CONFIG_CFS_BANDWIDTH
raw_spinlock_t lock;
ktime_t period;
u64 quota, runtime;
s64 hierarchical_quota;
u64 runtime_expires;
int idle, period_active;
struct hrtimer period_timer, slack_timer;
struct list_head throttled_cfs_rq;
/* statistics */
int nr_periods, nr_throttled;
u64 throttled_time;
#endif
};
- period:周期值;
- quota:限额值;
- runtime:记录限额剩余时间,会使用quota值来周期性赋值;
- hierarchical_quota:层级管理任务组的限额比率;
- runtime_expires:每个周期的到期时间;
- idle:空闲状态,不需要运行时分配;
- period_active:周期性计时已经启动;
- period_timer:高精度周期性定时器,用于重新填充运行时间消耗;
- slack_timer:延迟定时器,在任务出列时,将剩余的运行时间返回到全局池里;
- throttled_cfs_rq:限流运行队列列表;
- nr_periods/nr_throttled/throttled_time:统计值;
struct cfs_rq
结构中相关字段如下:
struct cfs_rq {
...
#ifdef CONFIG_CFS_BANDWIDTH
int runtime_enabled;
u64 runtime_expires;
s64 runtime_remaining;
u64 throttled_clock, throttled_clock_task;
u64 throttled_clock_task_time;
int throttled, throttle_count;
struct list_head throttled_list;
#endif /* CONFIG_CFS_BANDWIDTH */
...
}
- runtime_enabled:周期计时器使能;
- runtime_expires:周期计时器到期时间;
- runtime_remaining:剩余的运行时间;
3.2 流程分析
3.2.1 初始化流程
先看一下初始化的操作,初始化函数init_cfs_bandwidth
本身比较简单,完成的工作就是将struct cfs_bandwidth
结构体进程初始化。
- 注册两个高精度定时器:
period_timer
和slack_timer
; period_timer
定时器,用于在时间到期时重新填充关联的任务组的限额,并在适当的时候unthrottle
cfs运行队列;slack_timer
定时器,slack_period
周期默认为5ms,在该定时器函数中也会调用distribute_cfs_runtime
从全局运行时间中分配runtime;start_cfs_bandwidth
和start_cfs_slack_bandwidth
分别用于启动定时器运行,其中可以看出在dequeue_entity
的时候会去利用slack_timer
,将运行队列的剩余时间返回给tg->cfs_b
这个runtime pool
;unthrottle_cfs_rq
函数,会将throttled_list
中的对应cfs_rq
删除,并且从下往上遍历任务组,针对每个任务组调用tg_unthrottle_up
处理,最后也会根据cfs_rq
对应的sched_entity
从下往上遍历处理,如果sched_entity
不在运行队列上,那就重新enqueue_entity
以便参与调度运行,这个也就完成了解除限制的操作;
do_sched_cfs_period_timer
函数与do_sched_cfs_slack_timer()
函数都调用了distrbute_cfs_runtime()
,该函数用于分发tg->cfs_b
的全局运行时间runtime
,用于在该task_group
中平衡各个CPU上的cfs_rq
的运行时间runtime
,来一张示意图:
- 系统中两个CPU,因此
task_group
针对每个cpu都维护了一个cfs_rq
,这些cfs_rq
来共享该task_group
的限额运行时间; - CPU0上的运行时间,浅黄色模块表示超额了,那么在下一个周期的定时器点上会进行弥补处理;
3.2.2 用户设置流程
用户可以通过操作/sys
中的节点来进行设置:
- 操作
/sys/fs/cgroup/cpu/
下的cfs_quota_us/cfs_period_us
节点,最终会调用到tg_set_cfs_bandwidth
函数; tg_set_cfs_bandwidth
会从root_task_group
根节点开始,遍历组调度树,并逐个设置限额比率 ;- 更新
cfs_bandwidth
的runtime
信息; - 如果使能了
cfs_bandwidth
功能,则启动带宽定时器; - 遍历
task_group
中的每个cfs_rq
队列,设置runtime_remaining
值,如果cfs_rq
队列限流了,则需要进行解除限流操作;
3.2.3 throttle
限流操作
cfs_rq
运行队列被限制,是在throttle_cfs_rq
函数中实现的,其中调用关系如下图:
- 调度实体
sched_entity
入列时,进行检测是否运行时间已经达到限额,达到则进行限制处理; pick_next_task_fair/put_prev_task_fair
在选择任务调度时,也需要进行检测判断;
3.2.4 总结
总体来说,带宽控制的原理就是通过task_group
中的cfs_bandwidth
来管理一个全局的时间池,分配给属于这个任务组的运行队列,当超过限额的时候则限制队列的调度。同时,cfs_bandwidth
维护两个定时器,一个用于周期性的填充限额并进行时间分发处理,一个用于将未用完的时间再返回到时间池中,大抵如此。
组调度和带宽控制就先分析到此,下篇文章将分析CFS调度器
了,敬请期待。
CFS的调度器颗粒是进程,但是在某些应用场景中,用户希望的调度颗粒是用户组,例如下面的用户场景,主要用到服务器场景中
A用户要跑一个任务,因为要调用一个库,只能单线程跑
B用户跑并行的任务,创建了100个线程跑
那么对于CFS会发生什么呢?用户A可使用的CPU时间越来越少。这显然是不公平的
对于服务器中,我们希望这两个用户可以平均的分配CPU时间,所以这在调度颗粒为进程的CFS是很难做到的,拥有进程数量多的用户将被分配比较多的CPU资源,因此,我们引入组调度(Group Scheduling )的概念。我们以用户组作为调度的单位,这样用户A和用户B各获得50% CPU时间。用户A中的每个进程分别获得5.5%(50%/9)CPU时间。而用户B的进程获取50% CPU时间。这也符合我们的预期。本篇文章讲解CFS组调度实现原理。
1. 为什么要有组调度器
Linux是支持多用户多session的,假设现在系统有2个CPU,用户A只有1个任务需要运行,而用户B有3个。按照下图所示的分配方式,A只能获得50%的CPU时间,而B可以获得150%,如果B继续创建更多的任务,甚至还可以占据更多的CPU份额,从用户的层面来看,这是“不公平”的。
而如果将同一用户(或session)的任务归为一个group,每个group能够占据的CPU份额相同,那么每个用户可以获得的CPU时间都是100%。
这就是group scheduling(组调度),由一组进程构成的group作为一个整体去竞争CPU的时间。
2 数据结构
在进程调度中,每一个进程通过task_struct描述来管理,通过sched_entity这个调度实体参与调度,现在引入组调度器,在CFS定义了一个数据结构task_group来抽象和描述组调度,定义在kernel/sched/sched.h头文件中
调度单元有两种,即普通调度单元和实时进程调度单元
在没有组调度之前,每个CPU上只有一个调度队列,可以理解成所有的进程在一个调度组里面,而现在则是每个调度组在每个CPU上独有调度调度队列。在调度过程中,原来是系统选择一个进程运行,而当前是选择一个调度单元运行。
带宽(bandwidth)控制,是用于控制用户组(task_group)的CPU带宽,通过设置每个用户组的限额值,可以调整CPU的调度分配。在给定周期内,当用户组消耗CPU的时间超过了限额值,该用户组内的任务将会受到限制。
对于内核维护一个全局链表task_groups,创建的task_group会通过list链表加入到这个链表中
内核定义了root_task_group全局结构,充当task_group的根节点,以它为根构建树状结构
struct task_group的子节点,会加入到父节点的siblings链表中
每个struct task_group会分配运行队列数组和调度实体数组(以CFS为例,RT调度类似),其中数组的个数为系统CPU的个数,也就是为每个CPU都分配了运行队列和调度实体;
对应到实际的运行中,如下:
struct cfs_rq包含了红黑树结构,sched_entity调度实体参与调度时,都会挂入到红黑树中,task_struct和task_group都属于被调度对象;
调度器在调度的时候,比如调用pick_next_task_fair时,会从遍历队列,选择sched_entity,如果发现sched_entity对应的是task_group,则会继续往下选择;
由于sched_entity结构中存在parent指针,指向它的父结构,因此,系统的运行也能从下而上的进行遍历操作,通常使用函数walk_tg_tree_from进行遍历;
3 group的初始化
首先,我们来看看组调度根group的初始化过程,在sched_init中
组调度属于cgroup架构的CPU子系统,在系统配置时候需要使能组调度需要配置CONFIG_CGROUPS和CONFIG_FAIR_GROUP_SCHED,我们直接从之前深入CGroup框架–基础篇的机制可以看出
我们直接从创建组的cpu_cgroup_css_alloc中sched_create_group函数开始,来看看如何创建和组织一个组调度器
参数parent指向上一级的组调度节点,系统中有一个组调度的根,命名为root_task_group,首先分配一个task_group数据结构实体tg,然后调用alloc_fair_sched_group函数创建CFS需要的组调度器的数据结构,alloc_rt_sched_group创建realtime调度器需要的组调度器的数据结构,那么我们来看看CFS的组调度
cfs_rq起始是一个指针数组,分配nr_cpu_ids个cfs_rq数据结构并将其存放到该指针数组中
task_group数据结构中shares成员表示该组的权重,暂时初始化为nice值为0的权重,也就是1024
init_cfs_bandwidth函数初始化CFS中与带宽控制相关的信息
for循环遍历系统中所有的CPU,为每个CPU分配一个cfs_rq调度队列和sched_entity调度实体,init_cfs_rq函数初始化cfs_rq调度队列中的tasks_timeline和min_vruntime等信息,init_tg_cfs_entry用于构建组调度结构的关键函数
4. 如何进行组调度
4.1 组调度逻辑
从调度的层面,task group作为一个调度单位,和其他的task/task group一起参与CPU份额的分配,因此task group也被视为一种"sched_entity",由"task_group->se[cpu]"指向。
以CFS为例,当这个task group因为vruntime最小被选中时,还需要从这个group中再挑选其中vruntime最小的task,所以这个group也形成了一个runqueue,被"task_group->cfs_rq[cpu]"指向,也被"sched_entity->my_q"域指向(普通task的"sched_entity->my_q"为NULL,这也可作为区分两者的一个标志)。
tg->se[c] = &se;
tg->cfs_rq[c] = &se->my_rq;
1
2
这里*“se”* 和*“cfs_rq”* 都是以二级指针的形式出现的,实际代表结构体数组,数组的长度为CPU的数目,这是因为一个task group往往包含多个runnable的任务,而这些任务可能在多个CPU上运行。
此外,这些任务还可能具有不同的属性,其中一些是普通任务,另一些是实时任务,所以一个"task_group"中的"se"可能位于cfs_rq上,也可能位于rt_rq上。在RT调度中,总是选择优先级最高的se来执行,那task group作为se,其优先级该如何界定呢?
原则是选择group中优先级最高的任务的priority作为group的priority,比如group中3个runnable的实时任务,优先级分别是10, 20和30,那么group的优先级就是10。当任务在不同的CPU之间迁移时,可能会引起group优先级的变化。
下面我们来看看代码是如何实现的,只看核心代码
do {
struct sched_entity *curr = cfs_rq->curr;
if (curr) {
if (curr->on_rq)
update_curr(cfs_rq);
else
curr = NULL;
if (unlikely(check_cfs_rq_runtime(cfs_rq))) {
cfs_rq = &rq->cfs;
if (!cfs_rq->nr_running)
goto idle;
goto simple;
}
}
se = pick_next_entity(cfs_rq, curr);
cfs_rq = group_cfs_rq(se);
} while (cfs_rq);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
调度的核心就是挑选合适的进程运行,cfs调度类就是从cfs_rq红黑树最左边挑选vruntime最小的entity;而当开启组调度器后,则很有可能挑选到代表调度组的entity,这时就需要从调度组中的cfs_rq[cpu_nr]上继续挑选entity。从上面的代码中,可以看出使用group_cfs_rq函数来判断这个条件的
static inline struct cfs_rq *group_cfs_rq(struct sched_entity *grp)
{
return grp->my_q;
}
1
2
3
4
该函数也很简单,判断se->my_q成员是否为NULL,为NULL的话说明是进程,非NULL代表调度组。而se->my_q中存放的就是当前调度组在当前CPU上的cfs_rq,然后再在这个cfs_rq上继续找entity,如此往复。所以,这里仅留下了挑选下一个se的逻辑,对任务组的处理通过一个while循环搞定:如果没有开启组调度,则函数 group_cfs_rq 返回NULL, 只会遍历一次;如果开启了组调度,则会返回se指向的cfsrq继续遍历,直到找到最终代表一个具体任务的se为止。
其实从总体思路上讲,引入任务组并不会对CFS的调度模型产生根本性的改变,只是在时间分配与任务挑选时增加了递归层级:如果目标se代表一个任务组,则需要下层到该任务组的cfsrq中去,把对应的操作再做一遍;如此循环直到最终拿到一个代表任务的se为止。
4.2 组调度时间分配
如果se代表的是任务组,那么该se在当前cfsrq中根据权重分配到的时间还需要在自己的任务组内进行二次分配。为se计算时间份额的函数是 sched_slice, 该函数的逻辑在调度周期中已经分析过,这里我们再看一下该函数对任务组的处理方式:
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
unsigned int nr_running = cfs_rq->nr_running;
struct sched_entity *init_se = se;
unsigned int min_gran;
u64 slice;
if (sched_feat(ALT_PERIOD))
nr_running = rq_of(cfs_rq)->cfs.h_nr_running;
/*1. 调度周期*/
slice = __sched_period(nr_running + !se->on_rq);
/* 处理任务组的循环 */
for_each_sched_entity(se) {
struct load_weight *load;
struct load_weight lw;
struct cfs_rq *qcfs_rq;
qcfs_rq = cfs_rq_of(se);
load = &qcfs_rq->load;
if (unlikely(!se->on_rq)) {
/* 3. 整个运行队列 cfs_rq 的总权重 */
lw = qcfs_rq->load;
update_load_add(&lw, se->load.weight);
load = &lw;
}
/* 4。 从时间总额slice中为se分配其应得的份额,根据se在整个队列中的权重比例进行分配 */
slice = __calc_delta(slice, se->load.weight, load);
}
if (sched_feat(BASE_SLICE)) {
if (se_is_idle(init_se) && !sched_idle_cfs_rq(cfs_rq))
min_gran = sysctl_sched_idle_min_granularity;
else
min_gran = sysctl_sched_min_granularity;
slice = max_t(u64, slice, min_gran);
}
return slice;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
任务组内部对时间做二次分配的算法思路也是一样的,即根据目标 se 与任务组的总体权重比例进行分配。我们知道一个任务组的所有调度实体都在一个cfsrq中,代码通过 se->parent 一路往上走,依次计算出每个任务组中的时间配额,循环结束后即得到 se 最终的时间配额。我们通过一个示意图来理解总体流程:
图中目标的调度实体为 SE2, 其最终的时间配额结果是 slice0 = slice * r2 * r1 * r0, 其中 slice 是总时间,可以看出不管任务组嵌套多少层,目标调度实体不管处在哪一层,最终都可以正确计算出其应该分配到的时间配额。
5 如何进行带宽控制
首先我们要明白,为什么需要进行带宽控制,最初的 CFS 只能控制进程的 CPU 使用下限, 更准确的表述应该是它只能控制进程全速运行时 CPU 的使用下限。场景如下:
CFS 是根据权重也就是 cfs.share 参数、进程的运行时间(vruntime)来尽量保证 CPU 时间的公平分配。 假设系统中只运行 A 和 B 进程,其调度权重分别为 100、200, 那么 CFS 可保证 A 至少能占有 1/3 的 CPU, B 占有 2/3 的 CPU 时间。 但是, 如果 B 一直处于休眠状态,那么 CFS 可以一直调度执行 A 让它占有超过 1/3 的 CPU 使用时间。
CFS 的这个特点可以造成 CPU 资源的过度使用。 CPU 资源的过度使用可能会造成诸如:系统负载过高,系统响应延迟等问题。 所以内核需要一种机制来限制进程能使用的 CPU 时间的上限。 CPU bandwidth control 还有一个重要的应用场景就是用户按需购买资源, 譬如用户购买 0.5 个 CPU,我们就可以把它的 CPU 使用上限设定为 50%。
CFS调度器本身做不到这一点,只能依靠带宽控制功能,描述带宽控制的"cfs_bandwidth"和"rt_bandwidth"都是内嵌在这个结构体的定义中的,就是以task group为单位进行限制的。
struct cfs_bandwidth {
#ifdef CONFIG_CFS_BANDWIDTH
raw_spinlock_t lock;
ktime_t period; //CPU带宽限制的监控周期,典型值100ms
u64 quota; //一个周期内的时间限额值
u64 runtime; // 记录限额剩余时间,会使用quota值来周期性赋值
u64 burst;
u64 runtime_snap;
s64 hierarchical_quota; //层级管理任务组的限额比率
u8 idle; //空闲状态,不需要运行时分配;
u8 period_active; //周期性计时已经启动
u8 slack_started;
struct hrtimer period_timer; //高精度周期性定时器,用于重新填充运行时间消耗;
struct hrtimer slack_timer; //延迟定时器,在任务出列时,将剩余的运行时间返回到全局池里
struct list_head throttled_cfs_rq; //限流运行队列列表
/* 统计值 */
int nr_periods;
int nr_throttled;
int nr_burst;
u64 throttled_time;
u64 burst_time;
#endif
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
以CFS为例,在一个*“period”* 内,task group可运行的CPU时间是*“quota”* ,如果quota用完,那任务将被带上镣铐,不得再执行,直到下一个period的轮回来到,才能再次注入quota,重获新生。这2个参数在"/sys/fs/cgroup/cpuacct/"中由*“cfs_period_us”* 和*“cfs_quota_us”* 标记,默认的值分别是100ms和-1(-1代表没有限制,敞开了用)。
struct cfs_rq {
...
#ifdef CONFIG_CFS_BANDWIDTH
int runtime_enabled; /* 是否开启带宽限制 */
s64 runtime_remaining; /*当前cfs_rq从task_group中分配到的时间配额的剩余量 */
/* 记录cfs_rq被throttle时的时间点,用于统计被throttle的时间 */
u64 throttled_clock;
u64 throttled_clock_task;
u64 throttled_clock_task_time;
int throttled; /* 标记当前 cfs_rq 是否被 throttle*/
/* 记录当前cfs_rq被throttle的次数,如果上层task_group被throttle时,该数字也会增加 */
int throttle_count;
/* 被throttle时挂入cfs_bandwidth->throttled_cfs_rq链表 */
struct list_head throttled_list;
#endif /* CONFIG_CFS_BANDWIDTH */
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
5.1 带宽控制意味着什么
来看看做出这种限制会带来什么后果。假设一个任务运行完成需要200ms,如果不限制的话,那就是一脚油门飚到底:
而如果对它进行bandwidth控制(设period和quota分别是100ms和40ms),那就只能走走停停,从开始到结束过去了440ms的时间:
可见,在5个period内,任务的运行被限制了4次,限制总时长为240ms,这些数据都会记录在统计信息里:
所以系统为任务组设置带宽额度,任务组中的各个cfs_rq首先需要向任务组(task_group)申请时间,如果申请成功,则cfs_rq中的任务可以被CFS调度运行,如果申请失败(例如当前周期内任务组的时间配额已经用完),则调度器会将cfs_rq挂起(throttle);系统通过定时器周期性地为任务组重新分配时间额度,并将挂起的cfs_rq解挂(unthrottle)。
5.2 带宽控制流程
先看一下初始化的操作,初始化函数init_cfs_bandwidth本身比较简单,完成的工作就是将struct cfs_bandwidth结构体进程初始化。
注册两个高精度定时器:period_timer和slack_timer
period_timer定时器,用于在时间到期时重新填充关联的任务组的限额,并在适当的时候unthrottlecfs运行队列;
slack_timer定时器,slack_period周期默认为5ms,在该定时器函数中也会调用distribute_cfs_runtime从全局运行时间中分配runtime;
start_cfs_bandwidth和start_cfs_slack_bandwidth分别用于启动定时器运行,其中可以看出在dequeue_entity的时候会去利用slack_timer,将运行队列的剩余时间返回给tg->cfs_b这个runtime pool;
unthrottle_cfs_rq函数,会将throttled_list中的对应cfs_rq删除,并且从下往上遍历任务组,针对每个任务组调用tg_unthrottle_up处理,最后也会根据cfs_rq对应的sched_entity从下往上遍历处理,如果sched_entity不在运行队列上,那就重新enqueue_entity以便参与调度运行,这个也就完成了解除限制的操作;
do_sched_cfs_period_timer函数与do_sched_cfs_slack_timer()函数都调用了distrbute_cfs_runtime(),该函数用于分发tg->cfs_b的全局运行时间runtime,用于在该task_group中平衡各个CPU上的cfs_rq的运行时间runtime,来一张示意图:
系统中两个CPU,因此task_group针对每个cpu都维护了一个cfs_rq,这些cfs_rq来共享该task_group的限额运行时间;
CPU0上的运行时间,浅黄色模块表示超额了,那么在下一个周期的定时器点上会进行弥补处理;
我们以CPU调度限制的执行来看看其流程,与实时调度器类似,cfsrq申请到的时间保存在字段 runtime_remaining 中,每当更新任务的时间时,系统也会更新该字段。前面讨论vruntime时我们提到系统通过函数 update_curr 来更新与任务相关的时间信息,实际上该函数也会更新与带宽控制相关的时间:
static void update_curr_fair(struct rq *rq)
{
update_curr(cfs_rq_of(&rq->curr->se));
}
1
2
3
4
直接调用update_curr更新当前进程所在的就绪队列
static void update_curr(struct cfs_rq *cfs_rq)
{
struct sched_entity *curr = cfs_rq->curr; //找到当前正在执行的调度实体
u64 now = rq_clock_task(rq_of(cfs_rq)); //获取当前的时间
u64 delta_exec;
if (unlikely(!curr))
return;
/* 1. 计算上次更新到本次更新之间当前进程运行的时间delta_exec*/
delta_exec = now - curr->exec_start;
if (unlikely((s64)delta_exec <= 0))
return;
/* 2. 把当前时间赋给下次更新的起始时间 */
curr->exec_start = now;
if (schedstat_enabled()) {
struct sched_statistics *stats;
stats = __schedstats_from_se(curr);
/* 3. 更新最长的单次执行时间 */
__schedstat_set(stats->exec_max,
max(delta_exec, stats->exec_max));
}
/*4. 更新进程的总运行时间*/
curr->sum_exec_runtime += delta_exec;
schedstat_add(cfs_rq->exec_clock, delta_exec);
/* 5. 更新进程的虚拟运行时间 */
curr->vruntime += calc_delta_fair(delta_exec, curr);
/* 6. 更新就绪队列的最小虚拟运行时间 */
update_min_vruntime(cfs_rq);
if (entity_is_task(curr)) {
struct task_struct *curtask = task_of(curr);
/* 7. 更新进程的统计时间 */
trace_sched_stat_runtime(curtask, delta_exec, curr->vruntime);
cgroup_account_cputime(curtask, delta_exec);
account_group_exec_runtime(curtask, delta_exec);
}
/* 8. 检查是否要对当前运行的进程所在运行队列进行throttle */
account_cfs_rq_runtime(cfs_rq, delta_exec);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
最终会根据当前运行时间调用__account_cfs_rq_runtime进行检查
static void __account_cfs_rq_runtime(struct cfs_rq *cfs_rq, u64 delta_exec)
{
/* 1. 更新就绪队列的剩余runtime */
cfs_rq->runtime_remaining -= delta_exec;
/* 如果还有剩余时间,则函数返回 */
if (likely(cfs_rq->runtime_remaining > 0))
return;
/* 向所在的任务组申请时间,但如果当前cfs_rq已经被挂起了的话,则函数返回*/
if (cfs_rq->throttled)
return;
/* 如果runtime_remaining小于或等于0 了,那么调用assign_cfs_rq_runtime尝试申请时间, 如果
assign_cfs_rq_runtime返回0代表申请不到时间了,这个时候就只能调用resched_curr先设置当前
运行进程的TIF_NEED_RESCHED */
if (!assign_cfs_rq_runtime(cfs_rq) && likely(cfs_rq->curr))
resched_curr(rq_of(cfs_rq));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
更新带宽时间的逻辑其实很简单,就是从 cfs->runtime_remaining 减去本次执行的物理时间。如果此时调度器发现时间余额已经耗尽,则会立即尝试从任务组中申请,如果申请失败,说明在本周期内整个任务组的时间都已经耗尽了,而如果当前正在执行的任务在本cfsrq中的话,则需要将其调度出去。从任务组中申请时间的函数是 assign_cfs_rq_runtime:
/* returns 0 on failure to allocate runtime */
static int assign_cfs_rq_runtime(struct cfs_rq *cfs_rq)
{
/* 返回task_group的cfs_bandwidth字段,该字段封装着整个任务组的带宽数据 */
struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg);
int ret;
raw_spin_lock(&cfs_b->lock);
/* 实际的申请逻辑,第三个参数是期望申请的时间额度,默认是5ms,
可以在/proc/sys/kernel/sched_cfs_bandwidth_slice_us配置该值*/
ret = __assign_cfs_rq_runtime(cfs_b, cfs_rq, sched_cfs_bandwidth_slice());
raw_spin_unlock(&cfs_b->lock);
return ret;
}
/* returns 0 on failure to allocate runtime */
static int __assign_cfs_rq_runtime(struct cfs_bandwidth *cfs_b,
struct cfs_rq *cfs_rq, u64 target_runtime)
{
u64 min_amount, amount = 0;
lockdep_assert_held(&cfs_b->lock);
/* 1. 更新带宽时间时,提到 cfs_rq->runtime_remaining
* 可能为负,这说明上次运行时已经透支了部分时间,这里补回来。
*/
min_amount = target_runtime - cfs_rq->runtime_remaining;
/*2 没有限制,则要多少分配多少 */
if (cfs_b->quota == RUNTIME_INF)
amount = min_amount;
else {
/*3. 保证定时器是打开的,保证周期性地为任务组重置带宽时间 */
start_cfs_bandwidth(cfs_b);
/* 4. 如果本周期内还有时间,则可以分配 */
if (cfs_b->runtime > 0) {
/* 4. 确保不要透支 */
amount = min(cfs_b->runtime, min_amount);
cfs_b->runtime -= amount;
cfs_b->idle = 0;
}
}
/* 将新申请到的时间补充给cfs_rq */
cfs_rq->runtime_remaining += amount;
/* 是否成功地从task_group中申请到了时间 */
return cfs_rq->runtime_remaining > 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
假设上述assign_cfs_rq_runtime()函数返回0,意味着申请时间失败。cfs_rq需要被throttle。函数返回后,会设置TIF_NEED_RESCHED flag,意味着调度即将开始。
当等到下一个调度点来临的时候,调度器核心层通过pick_next_task()函数挑选出下一个应该运行的进程。在这个过程中CFS调度器会调用put_prev_entity来处理即将被置换的进程,可以看到CFS调度器会对即将被置换出去的进程进行就绪队列runtime的检查:
static bool check_cfs_rq_runtime(struct cfs_rq *cfs_rq)
{
/* 检查cfs带宽限制是否enable */
if (!cfs_bandwidth_used())
return false;
/* 检查cfs_rq可用运行时间是否小于0,小于0代表要throttle */
if (likely(!cfs_rq->runtime_enabled || cfs_rq->runtime_remaining > 0))
return false;
/* 3. 如果该cfs_rq已经被throttle,这里不需要重复操作 */
if (cfs_rq_throttled(cfs_rq))
return true;
/* 4. 这是执行throttle的核心函数 */
return throttle_cfs_rq(cfs_rq);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
此时就进入到throttle的核心处理操作了
static bool throttle_cfs_rq(struct cfs_rq *cfs_rq)
{
struct rq *rq = rq_of(cfs_rq);
struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg);
struct sched_entity *se;
long task_delta, idle_task_delta, dequeue = 1;
raw_spin_lock(&cfs_b->lock);
/* 1. 再最后尝试一把,如果定时器在此刻之前已经更新了任务组的时间了的话,那我们就不用挂起了*/
if (__assign_cfs_rq_runtime(cfs_b, cfs_rq, 1)) {
dequeue = 0;
} else {
/* 2. 确实需要挂起,将cfs_rq加入到cfs_bandwidth的挂起列表中,以便后期定时器为其重置时间*/
list_add_tail_rcu(&cfs_rq->throttled_list,
&cfs_b->throttled_cfs_rq);
}
raw_spin_unlock(&cfs_b->lock);
if (!dequeue)
return false; /* Throttle no longer required. */
/*3. 通过task_group->se拿到对应CPU队列中指向cfs_rq的那个se */
se = cfs_rq->tg->se[cpu_of(rq_of(cfs_rq))];
/* freeze hierarchy runnable averages while throttled */
rcu_read_lock();
/* 4. 更新以cfs_rq->tg为顶点的所有任务组中cfs_rq的throttle_count字段,将其加一 */
walk_tg_tree_from(cfs_rq->tg, tg_throttle_down, tg_nop, (void *)rq);
rcu_read_unlock();
/* 5. cfs_rq 及其所有子孙 cfs_rq 队列中的可运行任务的总数 */
task_delta = cfs_rq->h_nr_running;
idle_task_delta = cfs_rq->idle_h_nr_running;
/* 6. 删除上层队列中对应的 se */
for_each_sched_entity(se) {
struct cfs_rq *qcfs_rq = cfs_rq_of(se);
/* throttled entity or throttle-on-deactivate */
if (!se->on_rq)
goto done;
/* 7. 从上层 cfs_rq 中删除对应的se */
dequeue_entity(qcfs_rq, se, DEQUEUE_SLEEP);
if (cfs_rq_is_idle(group_cfs_rq(se)))
idle_task_delta = cfs_rq->h_nr_running;
/* 8. 更新上层 cfs_rq 中的对应字段 */
qcfs_rq->h_nr_running -= task_delta;
qcfs_rq->idle_h_nr_running -= idle_task_delta;
if (qcfs_rq->load.weight) {
/* Avoid re-evaluating load for this entity: */
se = parent_entity(se);
break;
}
}
/* 经过上面的循环之后,虽然此时se及其父节点都不需要dequeue了,但仍然需要更新对应
的字段,因此这里再循环处理一次*/
for_each_sched_entity(se) {
struct cfs_rq *qcfs_rq = cfs_rq_of(se);
/* throttled entity or throttle-on-deactivate */
if (!se->on_rq)
goto done;
update_load_avg(qcfs_rq, se, 0);
se_update_runnable(se);
if (cfs_rq_is_idle(group_cfs_rq(se)))
idle_task_delta = cfs_rq->h_nr_running;
qcfs_rq->h_nr_running -= task_delta;
qcfs_rq->idle_h_nr_running -= idle_task_delta;
}
/* At this point se is NULL and we are at root level*/
sub_nr_running(rq, task_delta);
done:
/* 设置 throttle 的标记位,并记录时间 */
cfs_rq->throttled = 1;
cfs_rq->throttled_clock = rq_clock(rq);
return true;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
最后 cfs_rq->throttled 被设置为1后,整个cfs_rq就被挂起了,被挂起的cfs_rq已经不在CPU的运行队列中,因此其中的任务就不会被调度到了。当需要挂起一个cfs_rq时,系统调用函数 throttle_cfs_rq 来实现,挂起的意思就是不要让CFS再调度到该cfs_rq中的任何任务,也就是说,我们需要将整个cfs_rq从该CPU的rq中移除,实际上就是移除上层cfs_rq中指向当前cfs_rq的那个调度实体。整个流程我们可以参考下图:
这里系统将要挂起CPU0队列里面的 cfs_rq_2, 此时我们需要将 cfs_rq_1 中指向它的调度实体 SE_R1 从 cfs_rq_1 中删除,而将 SE_R1 删除之后, cfs_rq_1 就是一个空队列了,这时又需要将指向它的调度实体 SE_R0 从 cfs_rq_0 中删除。该过程需要一直沿着cfsrq的parent属性往上走,直到遇上不需要删除的cfs_rq为止,在上图中,该cfs_rq就是 cfs_rq_0. 整个挂起操作完成后,CPU0的队列示意图为:
当然有挂起操作就会有解挂操作,带宽控制器中有两个定时器,分别是 period_timer 与 slack_timer, 前者用来周期性地更新带宽时间并对cfs_rq解挂,后者用来酌情回收已经分配给下属cfs_rq的时间。解挂是由初始化注册的两个callback会最终调用到unthrottle_cfs_rq,挂起的操作是逆操作,主要是将cfs_rq入队,更新对应的se节点信息,最后调用resched_curr触发调度。
6 用户空间如何使用
用户可以通过操作/sys中的节点来进行设置:
有两个关键的字段:cfs_period_us和cfs_quota_us,这两个息息相关;
period表示周期,quota表示限额,也就是在period期间内,用户组的CPU限额为quota值,当超过这个值的时候,用户组将会被限制运行(throttle),等到下一个周期开始被解除限制(unthrottle);
操作/sys/fs/cgroup/cpu/下的cfs_quota_us/cfs_period_us节点,最终会调用到tg_set_cfs_bandwidth函数;
tg_set_cfs_bandwidth会从root_task_group根节点开始,遍历组调度树,并逐个设置限额比率 ;
更新cfs_bandwidth的runtime信息;
如果使能了cfs_bandwidth功能,则启动带宽定时器;
遍历task_group中的每个cfs_rq队列,设置runtime_remaining值,如果cfs_rq队列限流了,则需要进行解除限流操作;
先看一下/sys/fs/cgroup/cpu下的内容,cpuacct子系统(CPU accounting)会自动生成报告来显示cgroup中任务所使用的CPU资源,其中包括子群组任务。报告有两大类:
usage: 统计cgroup中进程使用 CPU 的时间,单位为纳秒。
stat: 统计cgroup中进程使用 CPU 的时间,单位为USER_HZ。
ls /sys/fs/cgroup/cpu/
cgroup.clone_children cpuacct.stat cpuacct.usage_percpu cpuacct.usage_sys cpu.cfs_quota_us notify_on_release tasks
cgroup.procs cpuacct.usage cpuacct.usage_percpu_sys cpuacct.usage_user cpu.shares release_agent user.slice
cgroup.sane_behavior cpuacct.usage_all cpuacct.usage_percpu_user cpu.cfs_period_us cpu.stat system.slice
1
2
3
4
cpuacct.usage : 报告一个cgroup中所有任务(包括其子孙层级中的所有任务)使用CPU的总时间(纳秒),该文件时可以写入0值的,用来进行重置统计信息。
cpuacct.usage_percpu: 报告一个cgroup中所有任务(包括其子孙层级中的所有任务)在每个CPU使用CPU的时间(纳秒)。
cpuacct.usage_user: 报告一个cgroup中所有任务(包括其子孙层级中的所有任务)使用用户态CPU的总时间(纳秒)。
cpuacct.usage_percpu_user 报告一个cgroup中所有任务(包括其子孙层级中的所有任务)在每个CPU上使用用户态CPU的时间(纳秒)。
cpuacct.usage_sys: 报告一个cgroup中所有任务(包括其子孙层级中的所有任务)使用内核态CPU的总时间(纳秒)。
cpuacct.usage_percpu_sys:报告一个cgroup中所有任务(包括其子孙层级中的所有任务)在每个CPU上使用内核态CPU的时间(纳秒)。
cpuacct.usage_all:详细输出文件cpuacct.usage_percpu_user和cpuacct.usage_percpu_sys的内容。
cpuacct.stat:报告 cgroup 的所有任务(包括其子孙层级中的所有任务)使用的用户和系统 CPU 时间,方式如下:
user——用户模式中任务使用的 CPU 时间
system——系统模式中任务使用的 CPU 时间
其单位为USER_HZ
详细见Linux Cgroup 入门—CPU
7 总结
缺省地,进程都在顶级层次结构中,被单独地调度。一旦一个进程被移动到了一个调度实体下面,它就会在顶层运行队列里被删除。当进程可以运行的时候,它就被放到它的父调度实体对应的运行队列之中。
当调度起选择下一个要运行的任务的时候,它首先检查所有顶层调度实体,取出最应该获得CPU的任务。如果这个实体不是一个进程(而是一个高层调度实体),那么调度器就会检查调度实体中的运行队列,并重复这一过程,直到到达层次结构的最底层、找到一个进程,并运行这个进程。在进程运行时,同样会收集一些运行时统计信息,但这些信息同时会向上层调度实体传播,从而在每一级别上都正确度量其CPU占用。