【操作系统】深入内核源码,带你彻底理解Linux CFS调度器(上篇)

13 阅读13分钟

header_kv_4.jpg

这是本人大三下学期修读《操作系统》课程时,所撰写的期末大作业报告。随着课程的结束,现在本人将该报告重新编辑成上下两篇并开源出来,以供对Linux感兴趣的同学参考。

本文为上篇,主要内容为结合内核源码,对Linux CFS调度器的工作原理进行理论分析。

由于本人水平有限,文中内容如有疏漏错误,还请各路dalao指正~


一、CFS调度器提出的背景

在CFS调度器发布前的Linux2.4~Linux2.5时代,该操作系统主要依赖O(1)调度器对进程[1]进行管理。这种调度器会按进程优先级为不同的进程分配长短不同的时间片。优先级愈高的进程会被分配的愈多一些(上界为200ms),优先级愈低的进程会被分配的愈少一些(下界为10ms)。这种策略看似公平合理,但实则存在着重大缺陷——调度延迟不可控。或者更直白地说,这种调度策略比较容易导致低优先级的进程长时间处于饥饿状态。

例如,我们假设在某颗CPU核心上目前有10个任务正在运行,它们被分配到的时间片均为100ms。那么当一个优先级更低的进程被抛入该CPU核心对应的就绪队列时,在不考虑操作系统内核负载均衡机制的前提下,它至少需要等待10×100ms=1s的时间才有可能被调度执行。考虑到在计算机世界中,1s已是一个非常漫长的时间,这显然是难以容忍的!

CFS调度器的使命便在此处。一方面它需要照顾高优先级进程,在一个调度周期[2]内依据器优先级,让它们多执行一会;另一方面,它需要兼顾低优先级进程,避免它们长期处于饥饿状态,使得系统整体的调度延迟可控。

[1] 实际上Linux调度器管理的单位是task_struct,而所谓“进程”和“线程”本质上都是task_struct。但这里为写作方便,我们统一以“进程”这一术语来描述算法和实验过程。

[2] 本实验报告中,将调度周期(schedule period)定义为“将任务队列中当前所有处于TASK_RUNNING态进程都调度执行一遍的时间”。

二、CFS算法的核心概念与思想

CFS对一些常见的用于描述调度算法的概念进行了重新思考。要正确理解CFS的算法思想,我们首先需要准确把握这些概念。

首先,该调度算法中保留了“调度周期”这一概念。与其他调度算法不同的是,CFS中的调度周期特指一个理想值(我们这里先记作TT,后文中会进一步探讨它是如何被算法计算出来的)。该理想值的意义是,CFS算法期望接下来能在时间TT之内,将全体nn个进程[1]全部调度执行一遍;而非实际上这些进程全部被调度执行一遍所耗费的时间TrealT_{real}。显而易见的是,由于计算机系统的状态始终处于动态变化之中(如有进程在一轮调度周期过程中被插入任务队列、或从任务队列中被移除),在实际情况情况下很有可能TTrealT ≠ T_{real}[2]

其次,CFS调度算法中的“进程优先级”这一概念也与多数调度算法有不小的差异。在该算法中,进程优先级反映的是某个进程分配到的时间片之多寡,而非某个进程能被被优先调度之程度,或抢占其他进程能力之强弱。这看似与老式的O(1)调度器相似,但本质上却有着非常大的差异。

CFS调度算法的核心思想在于,在一轮完整的调度周期中,根据全体n个进程各自优先级的高低,将TT按相应的比例分配给它们。由于TT是在实际分配时间片前就被确定的,算法设计者只要确保CFS调度器始终能够给出一个合适的值,就天然地能避免低优先级进程长期饥饿的问题——这是O(1)调度器所无法做到的!

在进一步对CFS算法进行定量分析前,我们还需要对Linux系统中进程优先级(priority)、nice值和权重(weight)这三个概念有更进一步的认识。

我们知道,在Linux系统中进程的优先级被表示为一个取值范围为[0, 139]的整型数值,数值越小,表示进程优先级越高。其中优先级位于[0, 99]者主要为对实时性有强要求的操作系统内核进程,它们一般由其他实时调度算法(如FIFO、Round Robin等)予以管理,在本实验中我们不会予以探究;而优先级[100, 139]则主要被用于用户进程,它们均由CFS调度器进行管理。

某个用户进程ii的优先级可用公式Pi=120+niceiP_i = 120 + nice_i表示。该公式中的nice值参数的取值范围为[-20, 19],是为了方便操作系统用户微调进程优先级而引入的。每一个进程都有一个nice值。在后续的实验环节中,我们可以通过终端命令nice或renice来指定或调整一个用户进程的nice值,进而调整它的优先级;在进程内部也可借助setpriority系统调用实现。

此外,在Linux中每个进程还有一个权重的概念。权重由优先级(nice值)映射得到,其转换关系在Linux内核中被硬编码于sched_prio_to_weight数组,如图2.2.1所示。

image.png

图2.2.1 进程权重与优先级的映射关系(源码位置kernel/sched/core.c)

在把握了前文这几个概念后,现在我们对CFS算法进行进一步的定量分析。对于某进程ii,在某一轮调度过程当中,理想状态下它能够执行的时间片长度为:

Ti=T×(Wi/Wj)T_i = T × (W_i / ∑W_j)

其中Wi=sched_prio_to_weight[Pi100]Wi = sched\_prio\_to\_weight[P_i - 100]Wj=W1+W2++Wn∑W_j = W_1 + W_2 + … + W_n.

结合sched_prio_to_weight数组的定义不难发现,当某个进程ii的优先级越高(PiP_i越小),其权重WiW_i相应地越大,分配到的时间片也就越多。

同时,我们不难得出如下等式:

T1+T2++Tn=T×(W1+W2++Wn)/Wj=TT_1 + T_2 + … + T_n = T × (W_1+W_2+ … +W_n) / ∑W_j = T

这说明,理论上CFS算法的确恰好能够将T按照权重比例分配给全体nn个进程。

Linux内核中计算进程被分配时间片的核心逻辑如图2.2.2[3]所示。可见其中最关键的TT值是由__sched_period函数计算得出的,对该函数这里先不分析。__calc_delta函数则是对式①的实现。由于其使用了晦涩的位运算技巧来加速运算,这里就不予以展示了。

image.png

图2.2.2 CFS调度器计算进程被分配时间片逻辑 (源码位置kernel/sched/fair.c)

三、CFS算法中的vruntime

接着我们再来介绍一下CFS算法中另一个重要的概念——虚拟运行时间(vruntime)。假设某个进程分配到的时间片总长度为Ti,这里我们直接给出其总虚拟运行时间的定义式:

(vruntime)i=Ti×(W0/Wi)(总vruntime)_i = T_i × (W_0 / W_i)

其中W0=sched_prio_to_weight[20]=1024W_0 = sched\_prio\_to\_weight[20] = 1024. 在Linux源码中W0W_0被记作NICE_0_LOAD.

相应地,设CPU时钟中断间隔时间为Δt,这段时间内正在占用CPU的进程i的虚拟运行时间增量应为:

(Δvruntime)i=Δt×(W0/Wi)(Δvruntime)_i = Δt × (W_0 / W_i)

我们再来尝试将式①带入③式,可以得到:

(vruntime)i=T×(Wi/Wj)×(W0/Wi)=T×(W0/Wj)(总vruntime)_i = T × (W_i / ∑W_j)×(W_0 / W_i)= T × (W_0 / ∑W_j)

观察式③和式④可知,虚拟运行时间与进程的实际运行时间正相关,与进程的权重负相关。进程的权重越大(优先级越高),其虚拟运行时间增长地越慢,反之亦然。观察式⑤可知,所有进程的总虚拟运行时间均相等。基于这些性质,CFS的设计者选择将虚拟运行时间作为衡量当前调度周期中,各个进程执行进度的重要依据。后文中我们对此将进行更进一步的分析。

Linux内核中CFS调度器就绪队列的设计,与vruntime也有着密切的关系。如图2.3.1所示,CFS就绪队列采用的核心数据结构为红黑树。对每个处于就绪态正在等待调度执行的用户进程,其控制块(struct task_strcut)都会经由其内联的调度控制块(struct sched_entity)被“挂到”该红黑树之上。在这棵红黑树中,会按照用户进程当前已运行的vruntime大小对它们进行升序排序,如图2.3.2所示。位居红黑树最左侧者,即为vruntime最小者。后文中我们将看到将就绪队列设计成如此形式的原因。

image.png

图2.3.1 CFS调度器配套的红黑树形态的就绪队列

image.png

图2.3.2 进程在CFS就绪队列中依据其vruntime被插入到合适位置(源码位置kernel/sched/fair.c)

在每次时钟中断发生时,Linux内核都要在中断处理服务程序中对当前进程的vruntime进行更新(如果当前进程是用户进程的话),核心逻辑如图2.3.3所示。

image.png

图2.3.3 vruntime更新核心逻辑(源码位置kernel/sched/fair.c)

[1] 现代计算机系统一般拥有多颗CPU核心,且Linux系统会为每颗核心创建独立的CFS调度器及调度队列。为了简化问题,本实验中我们一律假设所有nn个进程至始至终均在竞争同一颗CPU核心。

[2] 因此实际上CFS只能近似实现完全公平调度。而下文也都是在不考虑这些动态变化干扰的前提下进行的纯理论分析。特此说明,后文不再重复。

[3] 在实际Linux内核源码中,涉及到大量实现细节。受篇幅限制,无法对这些细节进行一一展示分析。故笔者对完整源码进行删改注释后,仅保留核心逻辑在本实验报告中进行展示。后文亦然,不再赘述。

四、CFS算法中调度周期TT的计算规则

通过前文分析,我们不难发现CFS调度算法的核心为调度周期TT,它直接决定了每一轮调度过程中能够分配给各个进程的时间总量。不难推测,算法在确定TT值时,既不能取值太小,又不能取值太大。前者会导致每个进程分得的时间片过少,从而引发频繁的上下文切换,对系统资源造成极大浪费;后者又会导致调度延迟劣化,重蹈O(1)调度器的覆辙。

Linux内核中实际的计算规则如图2.4.1所示。

image.png

图2.4.1 Linux内核CFS调度周期的计算规则 (源码位置kernel/sched/fair.c)

通过前文分析,我们已经知道这部分代码当中的nr_running表示的是当前CFS调度器管理的进程总数。

分析这部分代码可知,当进程总数没有超过阈值sched_nr_latency时,调度器会得到一个常量sysctl_sched_latency作为调度周期——这体现了CFS调度器在避免调度延迟不可控方面的考虑。

而当进程总数过多时,再取一个固定的调度周期就不那么合适了,于是就以nr_running * sysctl_sched_min_granularity作为调度周期,进程越多、周期越长——这体现了CFS调度器在避免上下文切换次数劣化方面的考虑。由此可见,__sched_period函数的机制设计是完全符合我们推测的!

此外通过变量命名前缀可知,作为用户,我们可以通过sysctl命令来直接修改参数sysctl_sched_latency和sysctl_sched_min_granularity,从而影响CFS调度器的运行效果。

五、CFS算法的抢占调度规则与实际抢占调度的实现

最后,我们再来分析一下CFS算法的抢占调度规则,即CFS调度器会在什么情况下让其他进程抢占当前正在运行的进程、这个抢占者进程又是如何被确定的?

在Linux内核中,当CPU时钟中断发生时,如果正在运行的进程恰好为用户进程,CFS调度器的entity_tick函数就会被中断处理程序触发。

image.png

图2.5.1 entity_tick函数 (源码位置kernel/sched/fair.c)

而后check_preempt_tick函数会被进一步调用。在该函数中,我们可以清晰地看到CFS调度器的抢占调度规则。

image.png

图2.5.2 CFS调度器的抢占调度规则 (源码位置kernel/sched/fair.c)

从图2.5.2中可见,与其他调度算法类似,当前进程时间片耗尽时,势必需要放弃CPU。

此外还有一个CFS特有的抢占规则,即如果当前进程的vruntime已经远大于就绪队列中进程vruntime最小值时,也要发生抢占。什么样的进程会有最小的vruntime呢?依据vruntime的定义我们知道,优先级相同的进程,实际运行时间最少者,其vruntime最小;实际运行时间相同的进程,优先级最高者,其vruntime最小。因此我们惊喜地发现,无论从哪个角度看,vruntime最小的进程,一定是接下来最迫切想要得到CPU的那一个。调度器势必不能让它再这么饿着,得尽快调度执行才行。这一规则体现了CFS调度算法在时间片之下,通过引入vruntime的概念,巧妙地实现了更微观意义上的“完全公平”。

在check_preempt_tick函数中通过调用resched_curr将当前进程标记后,当前进程并不会立马放弃CPU。真正发生抢占调度的时机为当前进程尝试退出中断处理程序,恢复到用户态的时候。在x86_64架构下,具体调用链路如图2.5.3所示:

error_exit(arch/x86/entry/entry_64.S)
-> prepare_exit_to_usermode(arch/x86/entry/common.c)
-> exit_to_usermode_loop(发现当前进程被标记为_TIF_NEED_RESCHED)
-> schedule(kernel/sched/core.c)
-> __schedule

图2.5.3 Linux内核抢占调度执行链路

这里我们简单地看一眼__schedule。由于它非常复杂,这里我对它进行了大幅删改,仅保留最核心的操作,如图2.5.4所示。从中可见最关键的一步是执行pick_next_task函数完成对接下来要调度执行进程的选取。

image.png

图2.5.4 __schedule函数 (源码位置kernel/sched/core.c)

不难分析得出,pick_next_task函数最终会进一步调用CFS调度器的pick_next_task_fair函数,如图2.5.5所示。从中可知它又会进一步调用pick_next_entity函数,而在最后的最后,__pick_first_entity函数会被调用。

image.png

图2.5.5 pick_next_task_fair函数 (源码位置kernel/sched/fair.c)

如图2.5.6所示,我们看到__pick_first_entity函数的逻辑是非常简单的,仅仅是获取并返回红黑树中最左侧节点(vruntime最小者)罢了。这与我们之前对check_preempt_tick函数的分析是相呼应的。

image.png

图2.5.6 __pick_first_entity函数(源码位置kernel/sched/fair.c)