Linux调度器原理
本文是个人对调度器的分析,部分插图来源于互联网。
调度器主要依靠两类函数实现:核心调度函数(主调度函数)与周期性调度函数。
主调度函数是指schedule函数,内核很多函数中会调用schedule函数去触发系统重新调度进程,比如cpu_sleep、read等io操作。他们都会尝试把当前的进程设置为挂起状态,然后重新调度(所以繁重的IO看起来并不会造成CPU资源的浪费,程序执行IO的时候内核把该进程挂起,然后等待IO完成后唤醒,然后CPU便可以做其他的工作。 但是同步IO和IO引起的上下文切换是会带来额外的系统开销)。
周期性调度:周期性调度器是基于scheduler_tick函数实现。系统都是以tick(节拍)来执行各种调度与统计,节拍可以通过CONFIG_HZ宏来控制。内核会以1/HZ ms为周期来执行周期性调度,这也是CFS实现的关键。 CFS调度类会根据这个节拍来对所有进程进行记账。 周期性调度是per CPU的,也就是每个CPU都会拥有自己的周期性调度器。 周期性调度器可以把当前进程设置为need resched状态,等待合适的时机当前的进程就会被重新调度(具体时机见后文)。
调度策略
调度策略是用户态的概念,与之对应的是内核态的各种调度类。抽象的调度器已经实现了一套调度框架,每一种具体的调度器只需要实现这些函数即可。Linux系统中有多种调度策略:
/*
* Scheduling policies
*/
#define SCHED_NORMAL 0
#define SCHED_FIFO 1
#define SCHED_RR 2
#define SCHED_BATCH 3
/* SCHED_ISO: reserved but not implemented yet */
#define SCHED_IDLE 5
#define SCHED_DEADLINE 6
不同调度类之间是绝对抢占的关系。实时调度类的进程在唤醒的时候可以立刻抢占CFS、BATCH、IDLE调度类的进程,但发送抢占的时候内核也只是把当前被抢占的进程设置为need resched,真正的任务切换需要等待切换时机。 Idle调度类任务不能在CPU之间迁移。
优先级
优先级就是nice值那一套。实时任务的优先级可以控制绝对抢占,高优先级的实时调度类的任务在唤醒的时候能立刻抢占低优先级的实时进程,并且一直霸占CPU直到自己主动放弃或者是达到带宽上限。 CFS调度类的优先级最后会变成权重,不同CFS任务在竞争CPU的时候按照权重来瓜分时间片。
任务的优先级有三个变量记录,这是为什么呢? static_prio保存着进程启动的时候分配的优先级,它可以通过nice和normal_priority系统调用来修改。 normal_prio表示基于static_prio与调度策略计算出来的优先级(这个有什么必要呢?静态优先级与普通优先级合为一个不行吗?也许是历史原因吧)。prio是动态优先级,是调度器考虑使用的。因为内核有时候需要临时的修改某个进程的优先级,所以需要这个变量。
CFS调度算法简述
用户态程序一般使用SHCED_NORMAL(有些地方也叫SCHED_OTHER),它使用的是CFS调度算法。CFS调度器在进行重新调度的时候总是选择等待时间最长的线程去执行(vruntime最小的任务),所以说它是公平的。
进程选择
CFS中进程选择非常简单,其函数调用是“pick_next_task_fair --> pick_next_entity”。它只要在cfs_rq中选择最左边的节点即可(也就是vruntime最小的任务,插入的时候就是按照vruntime来的),cfs_rq中的节点是一个可调度的实体,它可能是一个线程,也可能是一组线程。因为有时候我们可能需要以组为单位来控制调度的颗粒度。这也是实现cgroup的基础,因为该结构可以逐级嵌套,这个后面细说。
CFS调度中永远选择vruntime最小的进程去执行。比如有A和B两个进程,他们的nice值是0和5(假如sysctl_sched_min_granularity是2ms,sysctl_sched_latency是10ms,这两个参数后面有解释)。首先CPU优先选择执行A,当A执行了8ms后,系统周期性调度机制会发现B进程更值得执行,那么下一次调度选择的时候它会选择B去执行。这也体现了一种公平,保证了进程在一个周期内“总是”能被调度到一次(实际上很多时候不能,“总是”只是设计的初衷)。
vruntime计算
vruntime表示可调度实体的虚拟运行时间,它在可调度实体加入到run queue的时候会赋初值:place_entity -> sched_vslice -> sched_slice。在sched_slice中首先调用__sched_period确认se所在的CPU的run queue中总的vruntime,然后按照se的load.weight在整个run queue的load.weight中的比例计算出se的vruntime。代码如下:
那么load.wight又是什么?怎么计算出来的?为什么有了nice还要搞出一个load.weight呢?
在进程创建的时候会去调用set_load_weight初始化每个进程的weight。可以看出load->weight与nice值有非常直接的关系。但是我们并不能直接使用nice值来进行调度时候的进程选择,我觉得原因起码有下面两个:
- CFS中我们要保证nice值每提高1,其CPU获得的vruntime要少10%。 如果我们只保存nice的话,那我们的nice值必须设计成sched_prio_to_weight这个全局变量里面的这些数值。 而这些数值对于用户态程序来说太不友好。
- 因为有些时候我们还要考虑进程的调度策略,尤其是早期代码中,实时调度策略也是使用load->weight来计算vruntime。 我们不可能在每一次进程调度选择的时候都去重新计算然后去选择。 当然是一次计算出来后保存起来,以后一直使用比较省力。
vruntime更新:
在每次tick的时候去调用update_curr更新一下当前进程的vruntime。Vruntime更新与load.weight有关(也就是与进程的nice值有关),nice值不一样的vruntime走的速度不一样。越nice的走的越快,越不nice的走的越慢。task_tick_fair -> entity_tick –> update_curr -> “curr->vruntime += calc_delta_fair(delta_exec, curr);”。 因为权重大的任务vruntime增长速度更慢,所以获得的运行时间更多。两者相乘,这样在一个调度周期内权重不一样的两个任务vruntime增长的大小值是一样的,所以一个队列中的两个任务可以各运行一段时间。(tickless怎么处理呢?https://blog.csdn.net/ahaochina/article/details/8928654)。
check_preempt_wakeup
check_preempt_wakeup一般发生在任务被唤醒的时候,调度器代码会检测新唤醒的进程是否应该立刻被调度还是只是放在队列末尾等待被调度。find_matching_se先把两个se对齐到同一个层级分组下才好比较,所以进程是否能立刻抢占与它所处的调度组有很大关系。 这与在当前队列上争抢的两个调度组在该rq上的权重有很大关系。假如两个不同调度组下的两个进程在某个CPU上狭路相逢,A调度组的cpu.shares配置8192,B调度组配置的cpu.shares是1024。A调度组下的进程就一定能抢占B调度组下的进程吗? 未必,后面有详细的分析。
可调节的参数
sysctl_sched_latency,调度延迟表示在把该CPU上就绪队列中所有就绪的线程执行一遍所需的时间,它“保证”了线程在sysctl_sched_latency长时间内总会执行一次,保证了线程能得到一次调度的最大延迟(后面可以看到有些场景是保证不了最大的延迟的)。 如果系统繁忙,rq中等待运行的线程数超过了sched_nr_latency ,那么sysctl_sched_latency将不起作用。
sysctl_sched_min_granularity(sched_min_granularity):每次执行的最少时长。。可以看出如果有其他的线程来抢占它的话,那么会先计算一下该线程这一次执行了多长时间,如果比sysctl_sched_min_granularity小,那么其他线程抢占就会失败(比如周期性调度器每个tick发起的抢占询问),它可以防止频繁的进程切换。
sched_nr_latency,这个值不可配置,它是“(sysctl_sched_latency + sysctl_sched_min_granularity – 1) / sysctl_sched_min_granularity”.
给一个CPU上的就绪队列的总的时间片是__sched_period函数给出,主要的算法是如果就绪队列的长度大于上面理论计算的值,那么使用它们的乘积,否则就使用配置的。
sysctl_sched_migration_cost参数是用来判断是否可以迁移线程。迁移一个线程之前调度器会去判断这个线程上一次在这个CPU上执行是多久之前,如果小于sysctl_sched_migration_cost,那么认为他的一些数据还在cache中,那么就不迁移它了。如果把它迁移到别的CPU上,那么它会出现cache miss。它的单位是纳秒。
负载:
负载表示任务对CPU造成的压力,比如一个runqueue队列上有10个权重一样的任务排队等待执行,那么它通常就比runqueue上只有一个任务等待执行压力要更大,因此负载就更高。某个cpu的负载也就是runqueue中所有就绪的se的权重之和。所以负载时针对CPU来说的,他与他的队列中任务的个数与任务的权重相关。 CPU利用率反应的是一段时间内平均的CPU使用率,不能与负载搞混淆。
sysctl_sched_latency在调度时延中发挥的作用:
每个tick都会调用task_tick_fair -> entity_tick -> check_preempt_tick -> __sched_period -> __sched_period去检查当前的runqueue上是否有进程需要抢占当前的进程。
在下一个可抢占的时机会调用schedule函数进行重调度。 在__schedule中会调用put_prev_task,这个函数可能会把curr重新放入到runqueue的红黑树中,然后调用pick_next_task重新pick一个任务。
put_prev_task的时候按照vruntime排队,所以有可能下一次还是选择这个任务来执行。 所以这里变得很怪异了。
想象一下,假如队列上就两个任务,他们按照理想的情况按照share比例瓜分sysctl_sched_latency,如果此时唤醒了第三个任务会怎么样?把唤醒的任务加入到队列之前会调用place_entity给它一个新的vruntime。在place_entity中判断如果任务是唤醒的,那么使用runqueue的min_vruntime – 调度周期的一半。加入有A、B两个任务,调度周期是24ms,A运行了4ms,B运行了20ms,接着唤醒了C,C先运行12ms,那么A的调度延迟就变成了20+12ms=32ms。
所以,CFS中sysctl_sched_latenc根本就不能保证任务调度的延迟,即使有超高分辨率的tick也保证不了,CFS的出发点是考虑份额,其次才是延迟,它还得考虑交互性。
组调度
组调度最先是为多用户设计的,因为一台主机上有多个用户,每个用户都有自己的一组进程,需要有一种控制每个用户使用上、下限的机制。当前在容器、虚拟化场景下广泛应用。
以下就不写详细的实现,只写个人的理解,图是从网上盗的:
1、每个cpu的rq中都有一个cfs_rq,cfs_rq中有一棵红黑树tasks_timeline。当有CFS任务唤醒到CPU上,会逐级的把se加入到这颗树上。cfs_rq都是指向root cgroup的se,这个流程在sched_init –> init_tg_cfs_entry中。
2、cgroup中每个目录就是一个调度组,它下面有一组子se。 特别注意,如果user.slice下面没有任务挂在这个CPU的就绪队列中等待运行的话,那么user.slice这个sched entity就不会挂在这颗红黑树上。
1、 Cgroup中每一个目录也是一个se,也是一个task group。
2、 一个调度组(task group)下面的多个就绪的任务可能位于不同的CPU。task_group在cfs_rq[cpu]不为NULL意味着这个任务组在这个CPU上有就绪的CFS任务。特别注意group se与它的cfs_rq配合使用,group se代表cgroup中的那个文件夹或者是cgroup目录下tasks文件中的进程。
3、 像machine.slice就是一个se,它在每个cpu上都有一个cfs_rq,里面保存着machine.slice中处于就绪状态的子节点(虚拟机se),如果某个CPU上没有就绪状态的虚拟机子节点,这个cfs_rq[CPU]就是一颗空树,当他有子节点,那么它的se[cpu]就应该被挂到这个CPU的根节点的红黑树上(仔细品cfs中怎么把任务加入、移出红黑树)。
任务组是怎么插入到cpu的runqueue中的?
上面的图中还少了一个线条,se中还有一个cfs_rq,它指向tg里面的cfs_rq。 enqueue_task_fair的时候,se如果是调度组中的一个小兵,那么它是要加入到调度组父节点的红黑树上。
同样,在把任务从runqueue中移除,也会从叶子到父亲全部移除掉。因为父亲节点是一个task_group中的cfs_rq,所以与se所在同一个group的红黑树的其他节点还是保存在那里的,只是把parent从runqueue上移除了。这样一撸到底可以保证插入的时候按照vruntime重新排序各个组(在每个tick更新vruntime的时候更新se以及se的parent等的vruntime)。
Nice、shares与load.weight关系:
nice值的范围是-20~19,可以通过内核API来设置,nice为0的任务的weight为1024. 那么他们与shares是什么关系呢?
shares默认是1024,其实他就是对应nice 0。 但是shares是配置在cgroup调度组中(没有cgroup就没有share了),所以他可能是一组程序共同分享这1024(sched_group_set_shares –> update_cfs_shares)。 所以shares是设置调度组的权重,而nice是设置,某一个单独的线程的权重。
shares是一个静态的值,权重才是决定任务能获得多少时间片的关键,而权重是变化的值。在任务加入到runqueue中会触发重新计算任务在队列中的权重。
参考:https://www.cnblogs.com/LoyenWang/p/12459000.html
场景一:启动一个10U虚拟机,machine.slice shares值是1024*10,启动一个yes放在fsp调度组下下,share值设置为是1024,虚拟机中加满压力,使用trace跟踪calc_cfs_shares:
可以看出,当这个yes任务与这个10U的虚拟机的一个vcpu在争取某一个物理CPU资源的时候,他们的shares几乎是1:1,是没什么优势的。 整个machine-qemu-test1组公共的shares是10240,因为他们所有vcpu线程都在忙于工作,所以每个vcpu线程分摊的share还是1024.
Weight计算公式:task_group_weight = (task_group->load_avg + cfs_rq->tg_load_contrib – cfs_rq->load.weight);
task_group_se_weight = task_group_shares * cfs_rq->load.weight / task_group_weight(shares * 当前队列的任务的负载占整个组的负载的比例)
其实进程本身的权重不用计算,就是1024(如果没有配置nice),需要计算的是cgroup组的权重,因为时间片比较的话关系到调度组,同一个组内时间片均分。可以看出,cgroup组的权重与配置的share成正比。两个任务所在的cfs_rq的权重虽然不一样,vcpu任务占整个machine.slice组的权重的1/10,最终vcpu与I层任务算出来的share都是1024左右。
场景二: 对这个10U的虚拟机其中一个vcpu进行加压,让这个vcpu与主机上的yes跑在同一个核上:
可以看到最终的权重比是10:1,被测试的vcpu集千宠于一身。
唤醒补偿:
在place_entity函数中CFS做了“唤醒对齐补偿”。当一个任务被唤醒的时候,如果它的vruntime比当前队列中最小的vruntime还要小半个调度周期,那么把它的vruntime强制对齐到vruntime减去半个调度周期。 这个算法的出发点是:当系统中同时有batch型的任务与交互新的任务的时候,让交互型任务感受更为友好,同时又避免交互型任务一直长时间霸占CPU。
这里补偿半个周期经常会导致任务延迟大于sysctl_sched_latency,原因是thresh单位是ns,而vruntime是需要按照权重比例转换才能变为ns。
但这种方式是合理的。试想,两个权重不一样的se,他们的vruntime一样,都是cfs_rq min_runtime小半个周期,其实权重大的那个se本应该运行更久,如果按照权重进行缩放,那他们运行的绝对时间一样,那么对于权重高的se是一种不公平,因为人家的vruntime更值钱。
如果队列上有两个任务,一个4096,一个1024,那么如果给4096这个任务补偿半个周期,他将达到2个调度周期。给一个权重大的任务补偿一点点都可能导致该CPU上排队的权重小的任务等待多时。
如果只对齐最小的vruntime但不补偿也会很奇怪,对于那种权重小的任务可能非常不友好,人家好不容易唤醒运行一下下,但结果因为自己出身不好,只运行了很短暂的时间。
作者曾利用cgroup组、唤醒补偿以及唤醒抢占做过一些创新实践,可以让一些调度组下的进程很容易抢占另外一个调度组的进程,而不必去改变他们的调度类。
负载均衡
负载均衡有两个触发点,一个是周期性负载均衡,一个是new idle balance。 周期性负载均衡是周期性定时器触发的,不管CPU是idle还是繁忙,都会去做负载均衡。 New idle balance是CPU无任务可选的时候才触发的负载均衡。 idle balance和rebalance_domains都是调用load_balance进行负载均衡,那么他们有什么差别呢?
1、 如果当前new idle的cpu它的平均idle的时间很短,比sysctl_sched_migration_cost还要短(0.5ms),或者是本cpu本身就处于overloaded状态,那么就不做负载均衡了。
2、 如果该CPU的load balance的时间消耗比该CPU平均的idle时间还要短,那也没必要做idle load balance。
3、 idle balance和rebalance_domains都是从domain 0~2来进行负载均衡,但是idle balance只要有均衡了一个任务,那么就不再在后面的domain上进行负载均衡了。
rebalance_domains是有一定周期的,这个周期的值受rq->next_balance控制。 其实周期性负载均衡很频繁,它会2~4ms做一些domain 0的负载均衡,CPU数量/2ms ~CPU数量 ms做一些domain 1的负载均衡,domain2之间也有负载均衡。 在某些云的场景下可以考虑关闭周期性负载均衡。
Load_balance函数说明:
做公有云的时候应该特别注意超线程的影响。
任务切换与抢占
任务切换
当队列上没有任务的是会切换给swapper(init_task),这个任务是idle类,所以随时可以抢占,代码可参见(init_idle)。 Swapper进程是percpu的,内核启动把它设置为当前的idle进程,并调用do_idle函数进行循环。 注意不要去在乎switched_to_idle,这与context_switch没啥关系。 当希望从一个任务切换到idle任务的时候就恢复到do_idle的循环中。
从idle切换到普通任务的切换速度与idle中做的事情息息相关。如果陷入到更深的c-state,恢复的时间肯定更长。 do_idle中循环判断当前的swapper(idle调度类)是否需要重新调度,不需要就进入idle睡眠,需要的话就调用schedule重新调度。
所以ttwu_queue –> ttwu_queue_remote就会有意思了,唤醒一个其他CPU上的任务可以不用发送SCHED_IPI中断,返回值do_idle的时候会判断,发多了可能造成SCHED IPI导致的系统开销大。
抢占
抢占是一种重新调度,触发抢占通过设置进程的TIF_NEED_RESCHED标志告诉调度器需要进行抢占操作了。但是真正执行抢占还要等内核代码执行的时候发现这个标志才行,而内核代码只在设定的几个点上检查TIF_NEED_RESCHED标志,这也就是执行抢占的时机。
抢占如果发生在进程处于用户态的时候,称为User Preemption(用户态抢占);如果发生在进程处于内核态的时候,则称为Kernel Preemption(内核态抢占)。
执行User Preemption(用户态抢占)的时机:
1、从系统调用(syscall)返回用户态时
2、从中断返回用户态时如果当前进程被置上了需要重新调度的标记(比如sched的时候发现当前进程时间片用完)。
如果CPU正在运行用户态代码,那么它总是可以被抢占的,比如周期性调度器就可以打断用户态程序的执行,然后触发重新调度。但是如果进程调用了系统调用而陷入到内核态,那么它是否能被抢占呢?
内核提供了三个编译宏来控制内核态是否可以抢占:CONFIG_PREEMPT、CONFIG_PREEMPT_NONE和CONFIG_PREEMPT_VOLUNTARY。
如果编译的时候使能了CONFIG_PREEMPT_NONE,那么CPU运行于内核态的时候是不能被其他线程抢占的(它仍然能被中断打断,但是中断返回后不会重新调度别的线程)。任务会一直运行下去,除非主动调用schedule触发重新调度,或者是返回用户态的时候才会被重新调度其他线程。
如果使能CONFIG_PREEMPT,那么CPU运行于内核态运行的时候也是能抢占的(也并非所有内核代码处都能被抢占,临界区是不能被抢占的),当从中断处理返回的时候会触发一次重新调度。
如果编译的时候使能了CONFIG_PREEMPT_VOLUNTARY宏,那么在内核中调用might_sleep的时候就会调用到_cond_resched。 自愿抢占是介于CONFIG_PREEMPT与CONFIG_PREEMPT之间的一种抢占方式,除了might_sleep外,其余的行为与CONFIG_PREEMPT_NONE一样。内核代码在很多延时比较大的代码前调用函数might_sleep()。如函数名称所示,它只有在定义了CONFIG_PREEMPT_VOLUNTARY时候才有可能会把当前的进程挂起。_cond_resched中会去判断当前的进程是否可以被抢占,并且是否需要rescheduled,如果满足的话就触发重新调度,这样就避免了因为内核代码阻塞而导致其他任务出现比较大延时的情况(这通常用于桌面系统中,因为桌面系统不希望因为某个进程阻塞而导致其他进程无法得到及时调度的情况)。 但这增大了重新调度的机会,所以带来了吞吐量的下降。
内核态被抢占的时机:
1、中断处理重新返回到内核执行的时候;
2、显式或隐式的调用schedule;
3、再一次处于可抢占状态。