2023-06-11
原文作者:奇小葩 原文地址:https://blog.csdn.net/u012489236/category_10946851.html

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份额,从用户的层面来看,这是“不公平”的。

202306111307090041.png

而如果将同一用户(或session)的任务归为一个group,每个group能够占据的CPU份额相同,那么每个用户可以获得的CPU时间都是100%。

202306111307096602.png

这就是group scheduling(组调度),由一组进程构成的group作为一个整体去竞争CPU的时间。

2 数据结构

在进程调度中,每一个进程通过task_struct描述来管理,通过sched_entity这个调度实体参与调度,现在引入组调度器,在CFS定义了一个数据结构task_group来抽象和描述组调度,定义在kernel/sched/sched.h头文件中

202306111307105813.png

  • 调度单元有两种,即普通调度单元和实时进程调度单元
  • 在没有组调度之前,每个CPU上只有一个调度队列,可以理解成所有的进程在一个调度组里面,而现在则是每个调度组在每个CPU上独有调度调度队列。在调度过程中,原来是系统选择一个进程运行,而当前是选择一个调度单元运行。
  • 带宽(bandwidth)控制,是用于控制用户组(task_group)的CPU带宽,通过设置每个用户组的限额值,可以调整CPU的调度分配。在给定周期内,当用户组消耗CPU的时间超过了限额值,该用户组内的任务将会受到限制。

202306111307115174.png

  • 对于内核维护一个全局链表task_groups,创建的task_group会通过list链表加入到这个链表中
  • 内核定义了root_task_group全局结构,充当task_group的根节点,以它为根构建树状结构
  • struct task_group的子节点,会加入到父节点的siblings链表中
  • 每个struct task_group会分配运行队列数组和调度实体数组(以CFS为例,RT调度类似),其中数组的个数为系统CPU的个数,也就是为每个CPU都分配了运行队列和调度实体;

对应到实际的运行中,如下:

202306111307121715.png

  • struct cfs_rq包含了红黑树结构,sched_entity调度实体参与调度时,都会挂入到红黑树中,task_structtask_group都属于被调度对象;
  • 调度器在调度的时候,比如调用pick_next_task_fair时,会从遍历队列,选择sched_entity,如果发现sched_entity对应的是task_group,则会继续往下选择;
  • 由于sched_entity结构中存在parent指针,指向它的父结构,因此,系统的运行也能从下而上的进行遍历操作,通常使用函数walk_tg_tree_from进行遍历;

3 group的初始化

首先,我们来看看组调度根group的初始化过程,在sched_init中

202306111307127646.png

组调度属于cgroup架构的CPU子系统,在系统配置时候需要使能组调度需要配置CONFIG_CGROUPS和CONFIG_FAIR_GROUP_SCHED,我们直接从之前 深入CGroup框架–基础篇 的机制可以看出

202306111307136287.png

我们直接从创建组的cpu_cgroup_css_alloc中sched_create_group函数开始,来看看如何创建和组织一个组调度器

202306111307142148.png

参数parent指向上一级的组调度节点,系统中有一个组调度的根,命名为root_task_group,首先分配一个task_group数据结构实体tg,然后调用alloc_fair_sched_group函数创建CFS需要的组调度器的数据结构,alloc_rt_sched_group创建realtime调度器需要的组调度器的数据结构,那么我们来看看CFS的组调度

202306111307148689.png

  • 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;

这里*“se”* 和*“cfs_rq”* 都是以二级指针的形式出现的,实际代表结构体数组,数组的长度为CPU的数目,这是因为一个task group往往包含多个runnable的任务,而这些任务可能在多个CPU上运行。

2023061113071572710.png

此外,这些任务还可能具有不同的属性,其中一些是普通任务,另一些是实时任务,所以一个"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);

调度的核心就是挑选合适的进程运行,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;
    }

该函数也很简单,判断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;
    }

任务组内部对时间做二次分配的算法思路也是一样的,即根据目标 se 与任务组的总体权重比例进行分配。我们知道一个任务组的所有调度实体都在一个cfsrq中,代码通过 se->parent 一路往上走,依次计算出每个任务组中的时间配额,循环结束后即得到 se 最终的时间配额。我们通过一个示意图来理解总体流程:

2023061113071648711.png

图中目标的调度实体为 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
    };

以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 */
    ...
    }

5.1 带宽控制意味着什么

来看看做出这种限制会带来什么后果。假设一个任务运行完成需要200ms,如果不限制的话,那就是一脚油门飚到底:

2023061113071731312.png

而如果对它进行bandwidth控制(设period和quota分别是100ms和40ms),那就只能走走停停,从开始到结束过去了440ms的时间:

2023061113071836213.png

可见,在5个period内,任务的运行被限制了4次,限制总时长为240ms,这些数据都会记录在统计信息里:

2023061113071894614.png

所以系统为任务组设置带宽额度,任务组中的各个cfs_rq首先需要向任务组(task_group)申请时间,如果申请成功,则cfs_rq中的任务可以被CFS调度运行,如果申请失败(例如当前周期内任务组的时间配额已经用完),则调度器会将cfs_rq挂起(throttle);系统通过定时器周期性地为任务组重新分配时间额度,并将挂起的cfs_rq解挂(unthrottle)。

5.2 带宽控制流程

先看一下初始化的操作,初始化函数init_cfs_bandwidth本身比较简单,完成的工作就是将struct cfs_bandwidth结构体进程初始化。

2023061113071957915.png

  • 注册两个高精度定时器:period_timerslack_timer
  • period_timer定时器,用于在时间到期时重新填充关联的任务组的限额,并在适当的时候unthrottlecfs运行队列;
  • slack_timer定时器,slack_period周期默认为5ms,在该定时器函数中也会调用distribute_cfs_runtime从全局运行时间中分配runtime;
  • start_cfs_bandwidthstart_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,来一张示意图:

2023061113072014716.png

  • 系统中两个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));
    }

直接调用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);
    }

最终会根据当前运行时间调用__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));
    }

更新带宽时间的逻辑其实很简单,就是从 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;
    }

假设上述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);
    }

此时就进入到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;
    }

最后 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的那个调度实体。整个流程我们可以参考下图:

2023061113072063317.png

这里系统将要挂起CPU0队列里面的 cfs_rq_2, 此时我们需要将 cfs_rq_1 中指向它的调度实体 SE_R1cfs_rq_1 中删除,而将 SE_R1 删除之后, cfs_rq_1 就是一个空队列了,这时又需要将指向它的调度实体 SE_R0cfs_rq_0 中删除。该过程需要一直沿着cfsrq的parent属性往上走,直到遇上不需要删除的cfs_rq为止,在上图中,该cfs_rq就是 cfs_rq_0. 整个挂起操作完成后,CPU0的队列示意图为:

2023061113072117118.png

当然有挂起操作就会有解挂操作,带宽控制器中有两个定时器,分别是 period_timerslack_timer, 前者用来周期性地更新带宽时间并对cfs_rq解挂,后者用来酌情回收已经分配给下属cfs_rq的时间。解挂是由初始化注册的两个callback会最终调用到unthrottle_cfs_rq,挂起的操作是逆操作,主要是将cfs_rq入队,更新对应的se节点信息,最后调用resched_curr触发调度。

6 用户空间如何使用

用户可以通过操作/sys中的节点来进行设置:

  • 有两个关键的字段:cfs_period_uscfs_quota_us,这两个息息相关;
  • period表示周期,quota表示限额,也就是在period期间内,用户组的CPU限额为quota值,当超过这个值的时候,用户组将会被限制运行(throttle),等到下一个周期开始被解除限制(unthrottle);

2023061113072162119.png

2023061113072234020.png

  • 操作/sys/fs/cgroup/cpu/下的cfs_quota_us/cfs_period_us节点,最终会调用到tg_set_cfs_bandwidth函数;
  • tg_set_cfs_bandwidth会从root_task_group根节点开始,遍历组调度树,并逐个设置限额比率 ;
  • 更新cfs_bandwidthruntime信息;
  • 如果使能了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
  • 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_usercpuacct.usage_percpu_sys的内容。

  • cpuacct.stat:报告 cgroup 的所有任务(包括其子孙层级中的所有任务)使用的用户和系统 CPU 时间,方式如下:

    • user——用户模式中任务使用的 CPU 时间
    • system——系统模式中任务使用的 CPU 时间
    • 其单位为USER_HZ

详细见Linux Cgroup 入门—CPU

7 总结

缺省地,进程都在顶级层次结构中,被单独地调度。一旦一个进程被移动到了一个调度实体下面,它就会在顶层运行队列里被删除。当进程可以运行的时候,它就被放到它的父调度实体对应的运行队列之中。

当调度起选择下一个要运行的任务的时候,它首先检查所有顶层调度实体,取出最应该获得CPU的任务。如果这个实体不是一个进程(而是一个高层调度实体),那么调度器就会检查调度实体中的运行队列,并重复这一过程,直到到达层次结构的最底层、找到一个进程,并运行这个进程。在进程运行时,同样会收集一些运行时统计信息,但这些信息同时会向上层调度实体传播,从而在每一级别上都正确度量其CPU占用。

8 参考文档

组调度和带宽控制 (qq.com)

https://blog.csdn.net/weixin_37948055/article/details/107989524

https://wangxu.me/translation/2008/01/28/[译文]-cfs-组调度/index.html

https://www.cnblogs.com/LoyenWang/p/12459000.html

https://segmentfault.com/a/1190000008323952

阅读全文