组调度器 - 进程与线程(二十三)

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占用。
 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值