vruntime与优先级

调度域、调度组和调度域拓扑等级都是对CPU的组织,可以认为是资源面的组织方式,调度系统需要对内核进程(线程)进行调度,对于内核进程的组织也可以分为单个的内核进程和内核进程组的区别,无论是单个的内核进程还是内核进程组,只要是参与调度的运行实体,都需要在其task_struct中包含struct sched_entity,调度系统直接根据sched_entity来决策调度,并且与调度相关的进程的数据都存储在sched_entity中。

使用调度域的过程就是实际的调度过程,在调度过程中当前线程不再需要CPU,当需要选择下一个进程来执行时,会调用pick_next_task函数,该函数的核心就是从最高优先级的调度类遍历,找到该优先级下的pick_next_task返回的线程。

CFS以红黑树组织所有待调度的任务,虚拟时钟(sched_entity:vruntime)是红黑树排序的依据,CFS通过每个进程的虚拟运行时间(vruntime)来衡量哪个进程最值得被调度。CFS中的就绪队列是一棵以vruntime为键值的红黑树,虚拟时间越小的进程越靠近整个红黑树的最左端。因此,调度器每次选择位于红黑树最左端的那个进程,该进程的vruntime最小。vruntime是通过进程的实际运行时间和进程的权重(weight)计算出来的,权重与进程的优先级成正比,优先级越高,权重越大,表示该进程越需要被运行。从优先级到权重的转换是通过prio_to_weight数组进行的,代码如下:

static const int prio_to_weight[40] = {/* -20 */     88761,     71755,     56483,     46273,     36291,/* -15 */     29154,     23254,     18705,     14949,     11916,/* -10 */      9548,      7620,      6100,      4904,      3906,/*  -5 */      3121,      2501,      1991,      1586,      1277,/*   0 */      1024,       820,       655,       526,       423,/*   5 */       335,       272,       215,       172,       137,/*  10 */       110,        87,        70,        56,        45,/*  15 */        36,        29,        23,        18,        15,
};

权重数组以nice值为计算单位,每差一个nice值就会差10%的权重。也就是说,在CFS下,每调整1个单位的nice值,在理论上CPU的运行时间就会差别10%。vruntime的单位是纳秒,之所以叫作虚拟运行时间是因为权重对真实运行时间做改变,每一个内核进程都有一个vruntime,也就是说每个内核进程都独立维护自己的运行时间,fair.c:update_curr函数在周期性的时钟中断中调用,会更新当前任务的vruntime。之所以只更新当前任务,是因为只有当前任务在运行,才有必要累加vruntime。更新的方式如下:

curr.nice!=NICE_0_LOAD  vruntime += delta* NICE_0_LOAD/se.weight;
curr.nice=NICE_0_LOAD  vruntime += delta;

优先级越高的进程,在真实运行时间相当的情况下vruntime的增加越慢,会得到越小的vruntime值,也就越倾向于被调度执行。这种vruntime的设计并不是完美的,例如新创建的进程,如果vruntime的值为0,则会一段时间优先级很高,阻塞等待的进程被忽然唤醒,由于vruntime的值一直没有前进,所以也会得到一个相对很小的vruntime值,从而该段时间获得更多的时间片。针对这种问题在内核中引入了min_vruntime,随着整个cfs_rq的推进而推进。

vruntime计算

一个调度周期是sysctl_sched_latency,表示在这个周期内,每个可以被调度运行的进程都要被调度执行一次。但是如果当前CPU的进程数太多的话,很多个进程瓜分固定的时间片显然不合适,因为每个进程可能有最小保证运行的时间,如果进程数太多,最小保证运行时间加起来就会超过sysctl_sched_latency指定的时间。所以实际上,这个调度延迟,在内核中叫做进程调度周期是随着进程数目的变化而变化的,变化的方法如下函数定义:

static u64 __sched_period(unsigned long nr_running)
{if (unlikely(nr_running > sched_nr_latency))return nr_running * sysctl_sched_min_granularity;elsereturn sysctl_sched_latency;
}

当当前CPU上的可运行的进程数低于sched_nr_latency的值时,周期固定为sysctl_sched_latency。因为此时内核是可以保证在这个周期范围内对所有可运行的进程运行一遍的。但是当进程数量大于sched_nr_latency时,这个估算的调度延迟就是当前CPU的可运行的进程数乘以每个进程的最小运行时间。

这个计算比较容易理解,因为假设当前CPU的每个可运行进程都运行最小的保证运行时间,就可以做到所有的进程都执行一遍。但是实际上,有的进程运行一遍又会变成可运行,有的进程不止运行最小运行时间,这样实际上这个值并不能保证所有的进程都会在这个周期内得到执行一遍。这个值的确切意义是运行完当前可运行进程所需要的最小时间。调用这个函数的入口位于 u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se) 函数中,这个函数的作用是计算一个调度实体se所需要的时间,单位是ns。这个函数的定义如下:

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;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)) {lw = qcfs_rq->load;update_load_add(&lw, se->load.weight);load = &lw;}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;elsemin_gran = sysctl_sched_min_granularity;slice = max_t(u64, slice, min_gran);}return slice;
}

可以看到如果ALT_PERIOD特性存在,nr_running的计算方法将会不同。这是因为由于调度组的存在,调度是分层的,nr_running是代表当前这层的处于可运行状态的调度实体的数量,如果下层的se有调度组,那么整个调度组都会被算作一个可运行状态的计数。而h_nr_running则是包括所有子层次的调度组内的进程数,是真正的处于可运行状态的进程的个数。

通过进程数量计算得到slice的值就是当前CPU下将所有可运行进程都运行一遍所需要的最小时间。后面的函数计算就是计算se这个当前正在被计算的调度实体本次调度所能获得的时间片,时间片单位为ns,一个进程在计划被运行的时间叫做slice。__calc_delta是计算时间片的关键函数,本质原理是:delta_exec * weight / lw.weight。对应上上述函数的参数,就是slicese->load.weight,/load。for_each_sched_entity代表从当前的se往上逐级计算,se->load.weight代表当前se的负载,load则是当前se所在的cfs_rq的总负载。整个计算公式相当于slice当前se的负载/当前se所在cfs_rq的总负载,得到的结果就是当前se在slice这个预估的调度完成需要的最小时间内所能占的值。例如slice为100ms,se的负载占所在cfs_rq负载的比例是20%,那么该se本次所能获得的时间片为20ms。由于调度组分层的存在,所以得到的slice值在for_each_sched_entity循环往上计算的过程,进一步要把这个slice值*当前se所在cfs_rq的负载占上层cfs_rq的负载的比例,进一步最终得到最后的slice值就是该进程本次调度分配的时间片。

在容器的环境下,最后计算得到的slice值很可能会极小,如果进行计算的se是一个调度组,也就是该调度组获得的时间片就会极小。最后的BASE_SLICE调度特性给se一个最小获得时间片的保证,最小可以获得一个sysctl_sched_min_granularity的时间片。

这个计算得到的时间片,如果在HRTICK调度特性被启用的情况下,会作为高精度定时器的定时间隔设置,硬件会精准的在时间片到期的时候唤醒该进程。如果没有HRTICK这个特性,slice时间片的保证将依赖系统的HZ或者其它的调度事件。在比较繁忙的系统,一个进程要一次性的运行完整的slice也是不容易的,因为会有大量的进程有抢占的需求,还有中断的抢占触发的内核调度。

不同进程有不同的优先级,在过去的运行历史相同的情况下,不同优先级的进程所能获得的下次vruntime时间片也可能是不同的。对slice进行优先级调整的函数如下:

static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{if (unlikely(se->load.weight != NICE_0_LOAD))delta = __calc_delta(delta, NICE_0_LOAD, &se->load);return delta;
}

当se是默认的优先级的时候,这个函数直接返回输入的delta,表示不对slice进行修改。当se的优先级不为0的时候,就对slice进行调整。调整的做法是delta*NICE_0_LOAD/se->load,相当于认为输入的delta(slice)是当前特殊优先级下的时间片,乘以默认优先级权限和当前优先级下se的权重的比就可以得到折算到默认优先级下的时间片分配。从这里也可以看到,内核计算得到的调度时间片都是以默认优先级为统一尺度进行计算的。这里的NICE_0_LOAD默认是1024,代表默认优先级下的权限值。

可以这样理解,优先级更高的进程需要调小vruntime的值,以可以更优先的得到执行。进程的vruntime就是这个进程的slice,也就是这个函数的delta,调小的方法就是将过去的负载看做是高优先级下的负载,通过比例换算到默认优先级后,相当于认为过去的负载变小,这样得到的slice就会变小,vruntime就会小,就会更优先的得到调度执行。

vruntime的边界问题

如果休眠进程的 vruntime 保持不变, 而其他运行进程的 vruntime 一直在推进, 那么等到休眠进程终于唤醒的时候, 它的 vruntime 比别人小很多, 会使它获得长时间抢占 CPU 的优势, 其他进程就要饥饿,这也是不公平的,但是休眠的进程确实vruntime很低,理应获得更多的执行时间。这时,CFS会取一个折中的值。CFS使用本se上的最小vruntime时间(min_vruntime)来对该刚唤醒进程的vruntime进行重新计算,min_vruntime代表当前cfs_rq中过去进程运行时的最短运行时间,意思是本运行队列上要运行的时间最短也要运行这么久。而刚被唤醒的进程很可能vruntime长时间没有更新,并且之前也不在cfs_rq中,而是在等待队列里,所以其vruntime很可能远小于这个值。CFS的思路是在这个值的基础上减去一定的值,重新计算得到一个vruntime,这个vruntime和这个刚被唤醒的进程的vruntime取最大值作为刚被唤醒的进程的vruntime。

这个做法可以在vruntime的基础上为该唤醒进程增加vruntime的值。由于睡眠醒的时候,vruntime较小,按照通常的计算方法,该进程会获得很大的时间片份额,这个时候CFS采用对该进程的vruntime进行增加补偿的方法,使该进程仍然进行正常的时间片计算,只是vruntime在计算前被增大,这样获得的时间片就会偏小,但是仍然可以大于非睡眠的运行进程。 vruntime的睡眠唤醒补偿机制是固定存在的,非idle进程的补偿值是sysctl_sched_latency,就是系统配置的最小调度周期(/proc/sys/kernel/sched_latency_ns),这个补偿是针对本se所要加入的cfs_rq的min_vruntime来说的。注意这里是所要加入的cfs_rq,并不是se当前的cfs_rq。因为在进程补偿的时候,该se是还没有加入到目标cfs_rq的,而vruntime的正确计算则是加入目标cfs_rq的必要条件。

非idle进程的补偿默认计算是cfs_rq->min_vruntime - sched_latency_ns。这个计算的意义是补偿vruntime的新值是比当前cfs_rq的最小运行时间还小一个调度周期,这个值是非常保守的,因为改队列上所有进程的最小运行时间本身就是很短的vruntime,还要落后一个周期,所以正常情况下,这个值会远小于该cfs_rq上一个处于运行状态下的vruntime的值。而由于刚被唤醒的进程的vruntime有可能更小,通过这个补偿值与刚唤醒进程的vruntime取最大值,相当于设置了进入该cfs_rq上的运行进程的最小vruntime值。这个最小vruntime值的限制,让该进程不会获得太大的时间片,从而让该CPU上的运行进程不至于饥饿。

而很多时候这个补偿值对刚被唤醒的进程意义不大,因为补偿结果通常还是太小,se的vruntime并没有被补偿,或者补偿的太少。这种情况一般发生在进程比较短时间的睡眠,然后被唤醒,这个时候vruntime落后并不多,补偿就起不到明显的作用,相当于睡眠后被快速唤醒的进程会固定的获得更大的时间片。这个表现显然也是不合理的。所以CFS增加了一个可配置的调度特性GENTLE_FAIR_SLEEPERS,如果打开这个特性,补偿的值就是sysctl_sched_latency的一半,相当于增大补偿结果,让短时间睡眠的进程在被唤醒的时候,更容易被补偿,获得接近正常的时间片。 实现上述唤醒补偿的函数是place_entity,定义如下:

static void place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{u64 vruntime = cfs_rq->min_vruntime;if (initial && sched_feat(START_DEBIT))vruntime += sched_vslice(cfs_rq, se);if (!initial) {unsigned long thresh;if (se_is_idle(se))thresh = sysctl_sched_min_granularity;elsethresh = sysctl_sched_latency;if (sched_feat(GENTLE_FAIR_SLEEPERS))thresh >>= 1;vruntime -= thresh;}se->vruntime = max_vruntime(se->vruntime, vruntime);
}

上述函数还有一个参数,initial,代表该进程是否是刚创建,第一次加入运行队列。可以看到,如果START_DEBIT不启用,vruntime的值就是当前新创建的se的vruntime值与要加入运行队列的min_vruntime的值的最大值。这个设计让新创建的进程的vruntime不会太小,有了基础的最小值。但是由于刚创建的进程的vruntime为待加入cfs_rq的curr的vruntime的值(注意不一定是父进程),而curr当前正在运行,说明curr高概率已经是vruntime最小的进程了。min_vruntime会在curr加入cfs_rq的时候就会被更新,所以这个逻辑大部分情况下是没有作用的。但是存在一种情况,要加入的cfs_rq不存在curr,也就是没有当前正在运行的进程,这时新创建的进程的vruntime为0,此时这个逻辑就有意义。这个逻辑一般发生在一个CPU上为空闲,没有任何进程在运行,此时新创建的进程被设定在该cfs_rq上执行,那么这时该进程的vruntime就会为0。place_entity会自动将其更新为min_vruntime。

上述函数还出现了一个调度特性,START_DEBIT调度特性表示一个刚创建的进程的vruntime补偿,正常情况一个新创建的进程(用户空间线程)会得到一个要加入的cfs_rq最小的vruntime,新进程会在调度中获得不公平的优势,如果高频的创建新的线程,老进程将会出现调度饥饿。所以START_DEBIT特性就是对新创建的进程进行补偿。补偿的方法就是对vruntime增加一个sched_vslice计算得到的值,使得vruntime的值变大,缩小新进程的调度优势。这个函数的定义如下:

static u64 sched_vslice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{return calc_delta_fair(sched_slice(cfs_rq, se), se);
}

这个值的计算结果就相当于在新创建进程的调度优势的基础上,向后推迟一个最小调度周期,使得新创建的进程从优势变为劣势。凡是调度特性都是在特定的场景下有正面效果,在特定的场景下有负面效果,所以才存在动态开关的必要。有的业务需要新创建的进程快速得到调度,以降低系统延迟,这种业务通常是通过动态创建线程来执行特定任务的,这时START_DEBIT就需要关闭。但是通常情况,一个高性能的用户空间代码都是在已经创建好的线程上调度执行,所以START_DEBIT几乎总是打开,以追求全系统的稳定性为默认值。

但是如果既希望不影响系统的稳定性,又希望让新创建的进程总是得到优先执行,就需要一个引入一个额外的控制参数:/proc/sys/kernel/sched_child_runs_first。这个值的设置会导致cfs_rq上的curr与新创建的进程交换vruntime,使得新创建的进程的START_DEBIT变成对curr的惩罚,而新创建的进程则直接获得curr的vruntime,并且立刻触发调度。

如果不设置START_DEBIT,新创建的进程默认就是curr的vruntime,但是不会立刻触发调度,所以curr仍然会继续运行,直到下次触发调度才会高概率的调度到新创建进程。如果设置了START_DEBIT,新创建的进程默认是curr延后一个最小调度周期,不会立刻调度,并且即使触发了调度也不会很高概率调度到新创建的进程。但是如果打开了/proc/sys/kernel/sched_child_runs_first,则会立刻触发调度,但是如果此时同时打开了START_DEBIT,则高概率不会调度到新创建的进程,而是相当于让curr主动的出让CPU的一个逻辑,这个逻辑大部分情况下意义不大,所以START_DEBIT和sched_child_runs_first一般不会同时设置。对于大部分需要吞吐的常驻线程来执行任务的业务场景,开START_DEBIT不开sched_child_runs_first是比较常见的配置,也是大部分系统的默认配置。但是对于追求响应时间的场景,不开START_DEBIT,开sched_child_runs_first始比较好的配置,但是这个配置下,如果线程创建太过频繁,则会有调度饥饿问题。所以需要十分清楚业务的类型才能针对性配置。

调度实体的负载计算

内核中的调度方法是以调度实体为单位的。sched_entity,简称se是一个调度实体,可以是进程也可以是一组进行。这个组通常为一个用户或者为一个cgroup,对应用户空间的一个容器。对应的负载跟踪算法叫做PELT(Per-entity load tracking)。负载更新逻辑对于调度算法的运行很重要,因为合理的评估每个进程的负载才能正确的做调度的决策,负载代表历史,调度决策代表未来。对未来的决策很大程度依赖过去。负载并不等于进程运行消耗的CPU时间,因为进程即使没有在CPU上运行,比如该进程只是位于调度红黑树内,也会增加红黑树的体量,从而增加调度成本。进程负载是一个综合性的评估。

PELT下,时间被分为1024us的一个个时间片,这个时间片与调度的时间片无关,只是用于负载计算的。在每一个1024us的周期中,一个entity对系统负载的贡献是根据该实体处于runnable状态的时间进行计算的。注意这里只是Linux内核对于负载的定义,这个定义相对合理,因为runnable状态的时候,或者在CPU上执行或者在等待被调度执行,处于等待被调度执行时也被定义为在贡献负载。在该周期内,如果进程runnable的时间是x,那么对系统负载的贡献就是:x/1024+过去的负载衰减。过去的负载衰减是将当前进程的负载计算包含了过去的负载情况,过去的负载有个小于1的小数衰减因子,定义为y(y ^32=0.5,1024ns内衰减一半)。那么当前负载就是本次的负载计算累加过去的负载乘以对应的衰减因子,对应的公式如下:

L = L0 + L1y + L2y2 + L3*y3 + …

这样的设计同时让当前负载在负载计算结果中占有最大的比重,同时考虑了过去的负载情况。还有一个从计算上的极大优势,内核永远只需要存储上一次的计算结果,当前次的计算可以通过当前的L0加上上一次的计算结果乘以衰减因子y就可以直接得到,对应的公式如下:

L= L0 + L_last*y

负载贡献的计算是从下往上传到的,由于调度组的存在,一个se中的多个进程se的负载累加就变成了这个调度组se的负载贡献。

Linux下负载的定义并不准确的,只是一个工程上选择的折中。例如处于阻塞状态时候的任务也是贡献负载,还有如果启用了CFS带宽控制器( CFS bandwidth controller)的调度组,在分配的CPU带宽内,这些节流进程已经用光了分配的CPU带宽,但是仍然处于可运行状态。这时即使CPU处于空闲状态这些任务也无法得到调度运行。所以这部分处于运行状态的进程和那些可以被调度运行的处于可运行状态的进程对于系统的负载贡献是不同的,显然这种由于带宽限制不能被调度运行的进程的负载贡献会低很多。

负载追踪对于调度系统的调度决策的最大价值是用于负载均衡,任务在各个CPU上均衡负载时需要合理的评估每个进程会对一个CPU造成的负载影响。这个估计就是依赖的这里计算的负载的值。所以负载的绝对值的意义并不太重要,重要的是负载作为一种计量手段,能够区分不同的任务的对CPU的负载需求,从而可以做到均衡。还有另外一个调度特性叫做small-task packing,这个特性将小进程都尽可能的调度到一个CPU上,从而让其它的CPU上运行的要么是负载比较大的进程避免被小进程打扰以提高吞吐,要么是空载,从而可以进入低功耗模式节省功率。这个小的定义就需要靠负载计算的值来给出。CPU频率调节器(CPU frequency governor)和功率调节器(CPU power governor)可以利用负载值来猜测在不久的将来,系统需要提供多少的CPU计算能力。

负载计算的另外一个比较重要的功能是评估一个进程的分配时间片。使用的方法是看当前se的负载占本cfs_rq的总负载的比例作为因子乘以总的最小调度周期。在这里负载提供了比例层面的意义,用于区别一个进程相比整体的占比,从而可以从整体的时间片推算出个体。
无论是哪种使用方法,负载作为一个绝对值是没有物理意义的,它是CFS创造的一种用于进程与进程组合之间评估平衡与比例的一种单位。

调度组(struct sched_group)

在内核中有两种调度组,一种是bwc cgroup,一种是session。bwc(带宽控制)是cpu类型的cgroup用来控制一段时间内本cgroup可以使用的CPU时间长度的机制,k8s使用这个机制来计算和创建每个容器的资源占用配置。一个bwc cgroup对应一个调度组。session通过setsid系统调用创建,通常在一个终端下运行的所有命令都属于同一个session。有一个特性叫做autogroup,如果开启了,只要调用setsid创建了一个session就会对应的创建一个autogroup,也就是一个调度组。

调度组让一些进程以一个组为单位参与调度资源的计算,让资源在组间公平而不是在进程间公平。这个概念很重要,因为一个组中可能对应大量进程的大量CPU需求,而另外一个组中可能只有少量进程的少量CPU需求,有了调度组,CFS就保证CPU的分配在两个调度组内是均衡的,即使第一个调度组内的进程数量更多,CPU需求更大。例如两个调度组对应两个用户,就能让另外一个负载较低的用户更加公平的获得CPU。例如在容器中,不同的容器中的所有进程位于不同的调度组,就可以保证高负载的容器不会过多的争夺低负载容器的CPU资源。还有在桌面系统中,桌面虽然需要的CPU很少,但是会随着用户的突然操作而产生突然的大量CPU需求,如果同时有一个并发的编译任务,占据了大量的CPU,这个时候如果是没有调度组的CFS就会导致桌面延迟大,因为CPU被大量供给到编译操作。通过将编译操作和桌面操作放到两个调度组就可以保证两者均衡的获得到CPU,从而保证了桌面的延迟,而autogroup就是自动的完成这个操作的机制。

autogroup特性通过CONFIG_SCHED_AUTOGROUP内核配置选项打开,通过/proc/sys/kernel/sched_autogroup_enabled查看是否启用,一个进程的autogroup可以通过cat /proc//autogroup查看,可以通过echo > /proc//autogroup来修改一个autogroup调度组的nice值。bwc cgroup会覆盖autogroup的调度组结构。autogroup自动的将一个session的所有进程聚合成一个调度组,也就是当调用setsid创建一个session的时候,autogroup自动工作创建对应的调度组。
一个调度组的结构定义如下:

struct sched_group {struct sched_group *next;    //同一个调度域下的所有调度组的环形数组atomic_t ref;                              //该调度组的引用计数unsigned int group_weight;     //组权重,就是组中包含了多少个CPU,例如最下层调度组的weight一般为1struct sched_group_capacity *sgc;   //该组的指令执行容量const struct sched_group_energy const *sge;   //当前组的能耗,也就是组里的多个CPU在不同频率下的功耗的和unsigned long cpumask[0];   //当前组可用的CPU
};

在sched_group的结构体中也有一个group_weight的域,这个权重的意思就是该组中包含了多少个CPU。每个CPU在特定的频率下都对应一个单位时间能执行的指令数量,这个数量叫作capacity。在不同的频率下不同CPU的capacity不一样,而频率又与功耗相关,在调度时要找到最忙的组,而一个组是否忙是相对的。

调度大部分基于vruntime,但是现在的调度通常也会同时考虑调度组中的功耗,这种调度叫作功耗感知调度。

让调度系统感知当前CPU的功耗,从而让功耗控制与调度系统互通信息,通过策略设置做到更合理的功耗感知的调度,功耗控制系统也不会盲目控制功耗。

调度组与带宽控制

带宽控制(CONFIG_CFS_BANDWIDTH,简称BWC)是作用于调度组的,必须要在调度组特性(CONFIG_FAIR_GROUP_SCHED )打开的基础上才可以打开。其意义是整体限制一个调度组可以占有的CPU比例。

由于调度组特性被容器广泛采用,通常一个容器配置一个CPU cgroup,该cgroup对应一个调度组,调度组特性对应到用户空间就是CPU cgroup。

一个CPU cgroup对应内核里的一个任务组struct task_group,一个struct task_group在每个可以运行的CPU上都有一个调度实体se,可以使用cpuset来限制一个任务组的可运行CPU。由于cgroup可以嵌套多级,反应在内核中任务组就是一个树形的结构。这个任务组的树形结构也就对应了每个CPU下se的树形结构,但是必须要区别的是任务组是跨CPU的概念,而se是每CPU的概念,一个任务组可以有多个任务组的se出现在不同的CPU。

一个任务组中也针对每个CPU有一个cfs_rq,因为CFS整体上是以红黑树组织的,而每个层级的se都是一个单独的红黑树,这个红黑树的树形结构也与se的树形结构一一对应。

一个调度树形结构如下图:

一个struct task_group中包含一个struct cfs_bandwidth的结构体,代表带宽控制。带宽控制的结构体如下:

struct cfs_bandwidth {
#ifdef CONFIG_CFS_BANDWIDTHraw_spinlock_t       lock;ktime_t            period;u64          quota;u64           runtime;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
};

带宽控制最重要的是period和quota,period代表一个周期的宽度,quota代表这个周期内的本task_group的所能占有的带宽,-1代表不设置带宽控制。也就是说quota/period就是带宽控制的比例,确切的说quota/(period*CPU)个数才是带宽控制的比例。分配的cfs_quota_us是可以比cfs_period_us大的,因为cfs_period_us代表物理时间,如果有n个CPU核,物理时间就被放大n倍。例如有三个CPU,cfs_period_us设置为100000,如果cfs_quota_us设置为200000,那就代表在每个100ms内,三个CPU的两个可以被当前任务组跑满。

在用户空间的cgroup下,分别对应cpu.cfs_period_us和cpu.cfs_quota_us两个配置值。一个k8s下一个pod的一个容器的cpu cgroup的路径如下:/sys/fs/cgroup/cpu/kubepods/burstable/pode9d2b35a-0314-11ed-bb16-525400e798c5/fa2422f966d4920f3c96cc59ef0d0c76dc657f5a190772e9cc2c796216fe9001。两个配置值在这个路径下就存在。因为这是容器级的cgroup,之上还有一个pod级的cgroup,对应上述路径的上级路径,在pod级的cgroup也存在这两个配置。在这之下还有用户级的cgroup和进程级的cgroup,每一级都有这两个配置。这反应的树形组织的任务组对应的cgroup业务侧的层级。

struct cfs_bandwidth的runtime域代表本任务组当前可用的时间配额,会使用quota值来周期性更新。burst则是一个额外的设定,允许带宽超过限额一定的比例。这个特性并不破坏quota/period的比例限制,而是考虑在一个带宽限制周期内,有的CPU时间不会被使用,没有被完全使用的带宽时间就会直接浪费掉。burst特性将这些没有被使用的带宽时间积攒下来,在后续遇到业务突发需要使用更多的带宽时间的时候就使用攒下来的CPU时间(最大cpu.cfs_burst_us)进行额外满足。

在每个新的带宽控制周期都需要更新runtime,更新runtime的函数如下:

void __refill_cfs_bandwidth_runtime(struct cfs_bandwidth *cfs_b)
{s64 runtime;if (unlikely(cfs_b->quota == RUNTIME_INF))return;cfs_b->runtime += cfs_b->quota;runtime = cfs_b->runtime_snap - cfs_b->runtime;if (runtime > 0) {cfs_b->burst_time += runtime;cfs_b->nr_burst++;}cfs_b->runtime = min(cfs_b->runtime, cfs_b->quota + cfs_b->burst);cfs_b->runtime_snap = cfs_b->runtime;
}

只有在quota不为-1的时候才会更新runtime,赋予该带宽控制结构体具体的runtime值,代表接下来的周期可以用的CPU时间。burst的主要实现也在这个函数中。runtime_snap保存了上次的runtime的值,当运行到这个函数的时候,本次的runtime的值要么衰减到0,要么剩下一部分本调度组没有可运行任务,需要主动放弃剩余的runtime时间。

正常的runtime填充更新逻辑是cfs_b->runtime += cfs_b->quota,代表每次都填充一个quota的配额。由于runtime中可能还有值,相当于上次没有用完的CPU时间下个周期可以接着用。最后还有一个修正cfs_b->runtime = min(cfs_b->runtime, cfs_b->quota + cfs_b->burst);代表之前累计到现在的runtime不能超过quota+burst。也就是每次分配的runtime时间片最高为quota+burst。这里的runtime是作用在跨CPU的任务组的。

函数里用到的runtime_snap是记录上次的runtime值,通过这次的分配的runtime与上次分配的差值就能得到burst的统计结果,结果累加到burst_time域。

struct cfs_bandwidth的hierarchical_quota域是一个验证作用,用来确保子级别的quota不高于父级别的quota。

限流的实现是基于周期的,在周期period时间内用光了限额的CPU就不能再运行,这个限制的动作叫做throttle。每当一个新的period来临的时候,需要重新填充限额的runtime( __refill_cfs_bandwidth_runtime),还需要把已经被throttle的进程组进行取消限制(unthrottle),因为的新的周期,进程组又可以运行了。驱动这个周期动作的是靠一个定时器period_timer。所以,period设定的越短,这个定时器就会触发越频繁。默认一般为100ms。
100ms的period_timer对系统的负载还不算高,但是如果只有这么一个定时器,就意味着每格100ms才会处理一次unthrottle,在这100ms被throttle的se都会无法执行。这虽然是BWC的设计目标,但是有很多情况这是不合理的。例如一个se获得了一定的配额,但是却用不完就自己陷入睡眠,这时配合会剩下时间。BWC的做法是让这部分时间归还到se所在cfs_rq的runtime池中,其它se如果用完了自己的配额会处于限流无法运行的状态。此时,cfs_rq中有了runtime,如果这个runtime超过了/proc/sys/kernel/sched_cfs_bandwidth_slice_us这里指定的值(通常是5ms),那么一个已经处于限流状态的se就可以去获得者5ms从而继续执行。这个最小颗粒度5ms的设计容易引入一个问题,例如cfs_period_us设置为100000,也就事100ms,而sched_cfs_bandwidth_slice_us是默认的5ms,那么同时只能有100/5=20个CPU上可以运行这个任务组的任务,如果该任务组有30个进程,系统有32个CPU,那么分布在20个CPU之外的CPU上的进程就只能被throttle,从而引入比较明显的调度延迟。

这种设计保证了一个period中,即使有的se的带宽用不完也可以匀给有需要的se(被throttle)使用。每次这个交付的单位是可以在用户空间配置的,默认是5ms。这个值如果太大,一个period内的空余时间不容易被利用,很可能出现CPU空闲,但是仍有se被throttle。如果设置的太小,就会频繁的把被throttle的se唤醒,但是却运行不了多久,额外增加调度开销。

这个转移自己不用的quota的逻辑一个比较合理的思路是在se从运行队列摘除,放弃quota的时候,去检查上级cfs_rq中的runtime是否够一个sched_cfs_bandwidth_slice_us,如果够就可以unthrottle一个se,使其可以获得一个sched_cfs_bandwidth_slice_us继续运行。但是实际上不能这么操作,因为一个struct task_group有一个struct cfs_bandwidth,而runtime是作用于整个task_group的,这个task_group是可以在每个可以运行的CPU上都有se的。因为一个调度组的A进程在CPU0,B进程在CPU1是很常见的。BWC的带宽限制是跨CPU的整体性概念,每period占quota是对所有CPU而言的,例如两个CPU各自运行了50ms,一个进程组的quota是30ms,是可以一部分在CPU0上运行10ms,另一部分在CPU1上运行20ms。那么这个runtime的规划和重新划拨就是跨CPU的,就不可以在一个CPU的调度流程里判断runtime够unthrottle却去unthrottle别的CPU的同调度组se。struct cfs_bandwidth的第一个域lock是一个自旋锁,因为任务组的全局runtime跨CPU,所以归还时间片的操作是要加锁的,这就意味着成本较高。burst与runtime的跨CPU分配是不同的,burst是可以跨任务组的CPU资源转移,runtime的跨CPU分配则是在一个任务组内部各个CPU上的转移。

内核实现了另外一个定时器slack_timer,这个定时器也是每个5ms运行一次,其作用就是检查任务组的runtime是否超过sched_cfs_bandwidth_slice_us,如果超过了就unthrottle一个或多个se,使其获得时间片继续运行。

这样做的成本是相当高的,因为每个5ms一个中断,其中断开销与HZ的中断开销接近了。由于每个任务组都有两个这种定时器,而一个k8s服务器上跑满了容器时,会配置大量的cgroup,也就是会有大量的5ms周期的定时器存在。所以BWC这个机制运行的成本就固定的很高,尤其在k8s环境下运行众多容器的时候,成本更高。

1.4. CFS调度算法相关推荐

  1. linux中O(1)调度算法与全然公平(CFS)调度算法

    一.O(1)调度算法 1.1:优先级数组 O(1)算法的:一个核心数据结构即为prio_array结构体. 该结构体中有一个用来表示进程动态优先级的数组queue,它包括了每一种优先级进程所形成的链表 ...

  2. CFS调度算法调度时机的理解

    上一篇文章分析了cfs调度算法中vruntime的计算,cfs以vruntime的键值组成红黑树,CFS调度算法优先调度红黑树中最左边的最小值.vruntime的大小决定CFS调度算法优先选择就绪队列 ...

  3. Linux CFS调度算法关键知识点

    本文对CFS调度算法关键知识点进行梳理 nice 值和运行时间的关系 nice 值的范围-20 ~ 19,进程默认的nice值为0.这些值类似与级别,可以理解成40个等级,nice 值越高,优先级越低 ...

  4. cfs调度算法JAVA实现_关于CFS/BFS调度算法

    关于CFS/BFS调度算法 (2010-10-27 17:36:56) 标签: 杂谈 http://www.cs.unm.edu/~eschulte/data/bfs-v-cfs_groves-kno ...

  5. 对linux的CFS调度算法的理解

    目录 1. CFS公式 2. 虚拟时间世界 3. 真实时间世界 4. "完全公平"的意思 5.CFS算法比喻 linux有好几种调度类,同时并存运行.其中完全公平调度类默认采用CF ...

  6. Linux CFS调度算法核心解析

    回家的路上,聊了下CFS调度器-我昨天不是写了一篇批判性的文章嘛: [为什么Linux CFS调度器没有带来惊艳的碾压效果] https://blog.csdn.net/dog250/article/ ...

  7. Linux 2.6 CFS 调度算法内幕

    自 2.6.23 起提供对 CPU 的出色访问 M. Jones 2010 年 1 月 25 日发布 https://www.ibm.com/developerworks/cn/linux/l-com ...

  8. 进程管理笔记三、完全公平调度算法CFS

    进程管理笔记三.CFS调度算法 引言:CFS是英文Completely Fair Scheduler的缩写,即完全公平调度器,负责进程调度.在Linux Kernel 2.6.23之后采用,它负责将C ...

  9. 优先级调度算法实现_一篇讲透嵌入式操作系统任务调度

    进互联网公司操作系统和网络库是基础技能,面试过不去的看,这里基于嵌入式操作系统分几章来总结一下任务调度.内存分配和网络协议栈的基础原理和代码实现. 处理器上电时会产生一个复位中断,接下来会执行复位中断 ...

最新文章

  1. 沈阳师范大学计算机题库,沈阳师范大学软件学院计算机学科专业基础综合历年考研真题汇编-20210607153358.docx-原创力文档...
  2. 力压 Java 与 C 的 Python 现状如何了?
  3. hdu 3729(二分图最大匹配+最大字典序)
  4. jzoj3910-Idiot的间谍网络【倍增,dfs】
  5. 远程计算机怎么安装系统,w7系统可以远程安装吗_win7远程重装系统详细步骤
  6. 17. Gradle编译其他应用代码流程(五) - 设置Task过程
  7. 《HTML5移动Web开发实战》—— 1.6 在移动网站中使用HTML5
  8. 设计模式---抽象工厂模式(C++实现)
  9. FCC 基础JavaScript 练习6
  10. Code UI Automation脱离VS黑盒自动化测试工具编写
  11. YUM服务那些事---详解YUM服务
  12. 智慧教育教学案例分析
  13. Android--BRVAH官方使用指南
  14. 上位机发送FINS UDP命令读写欧姆龙PLC数据
  15. 微信公众号服务器管理员权限,公众号管理员和运营者的区别是什么?公众号管理员需要承担责任吗?...
  16. Chrome浏览器保存整个网页为图片的方法
  17. Qt发布版权问题,是否需要公开源码?开源版与商业版的区别?
  18. 经典软件典范龙卷风网络收音机功能详解
  19. centos查看显卡型号时出现 NVIDIA Corporation
  20. 今日杂感-20220322

热门文章

  1. 【大厂面试】堆的内存结构及GC垃圾回收机制
  2. 在iOS中调用C语言的国密算法SM2以替换RSA
  3. 2019网络购车平台易车的发展
  4. 九招教你完全了解液晶拼接屏
  5. 《血族》全民模式火热开启 南北之战一触即发
  6. Oracle数据库综合试题
  7. 【高项】成本管理(ITTO)
  8. ILSpy中baml转化为xaml的改进(三)
  9. 2021年茶艺师(初级)考试及茶艺师(初级)模拟试题
  10. 精典的古代情诗,程序员追MM不可缺少.....