调度器的实现
内存中保存了对每个进程的唯一描述,并通过若干结构与其他进程连接起来。调度器面对的情形就是这样,其任务是在程序之间共享CPU时间,创造并行执行的错觉。正如以上的讨论,该任务分为两个不同部分:一个涉及调度策略,另一个涉及上下文切换。
一、概观
内核必须提供一种方法,在各个进程之间尽可能公平地共享CPU时间,而同时又要考虑不同的任务优先级。完成该目的有许多方法,各有其利弊,无须在此讨论。主要关注Linux内核采用的解决方案。
schedule函数是理解调度操作的起点。该函数定义在kernel/sched.c中,是内核代码中最常调用的函数之一。调度器的实现受若干因素的影响而稍显模糊。
在多处理器系统上,必须要注意几个细节,以避免调度器自相干扰。
不仅实现了优先调度,还实现了Posix标准需要的其他两种软实时策略。备注:Posix可移植操作系统接口(Portable Operating System Interface of UNIX,缩写为 POSIX )。
使用goto以生成最优的汇编语言代码。这些语句在C代码中来回地跳转,与结构化程序设计的所有原理背道而驰。但如果小心翼翼地使用它,该特性就可以发挥作用。
下面暂时忽略实时进程,只考虑完全公平调度器(稍后再考虑实时进程)。Linux调度器的一个杰出特性是,它不需要时间片概念,至少不需要传统的时间片。经典的调度器对系统中的进程分别计算时间片,使进程运行直至时间片用尽。在所有进程的所有时间片都已经用尽时,则需要重新计算。相比之下,当前的调度器只考虑进程的等待时间,即进程在就绪队列(run-queue)中已经等待了多长时间。对CPU时间需求最严格的进程被调度执行。
调度器的一般原理是,按所能分配的计算能力,向系统中的每个进程提供最大的公正性。或者从另一个角度来说,它试图确保没有进程被亏待。这听起来不错,但就CPU时间而论,公平与否意味着什么呢?考虑一台理想计算机,可以并行运行任意数目的进程。如果系统上有N个进程,那么每个进程得到总计算能力的1/N,所有的进程在物理上真实地并行执行。假如一个进程需要10分钟完成其工作。如果5个这样的进程在理想CPU上同时运行,每个会得到计算能力的20%,这意味着每个进程需要运行50分钟。但所有的5个进程都会刚好在该时间段之后结束其工作,没有哪个进程在此段时间内处于不活动状态!
在真正的硬件上这显然是无法实现的。如果系统只有一个CPU,至多可以同时运行一个进程。只能通过在各个进程之间高频率来回切换,来实现多任务。对用户来说,由于其思维比转换频率慢得多,切换造成了并行执行的错觉,但实际上不存在并行执行。虽然多核CPU系统能改善这种情况并完美地并行执行少量进程,但情况总是CPU数目比要运行的进程数目少,这样上述问题又出现了。
如果通过轮流运行各个进程来模拟多任务,那么当前运行的进程,其待遇好于哪些等待调度器选择的进程,即等待的进程受到了不公平的对待。不公平的程度正比于等待时间。
每次调用调度器时,它会挑选具有最高等待时间的进程,把CPU提供给该进程。如果经常发生这种情况,那么进程的不公平待遇不会累积,不公平会均匀分布到系统中的所有进程。
图2-12说明了调度器如何记录哪个进程已经等待了多长时间。由于可运行进程是排队的,该结构称之为就绪队列。
图2-12 调度器通过将进程在红黑树中排序,跟踪进程的等待时间
所有的可运行进程都按时间在一个红黑树中排序,所谓时间即其等待时间。等待CPU时间最长的进程是最左侧的项,调度器下一次会考虑该进程。等待时间稍短的进程在该树上从左到右排序。
红黑树,该数据结构对所包含的项提供了高效的管理,该树管理的进程数目增加时,查找、插入、删除操作需要的时间只会适度地增加。红黑树是内核的标准数据结构。
除了红黑树外,就绪队列还装备了虚拟时钟。该时钟的时间流逝速度慢于实际的时钟,精确的速度依赖于当前等待调度器挑选的进程的数目。假定该队列上有4个进程,那么虚拟时钟将以实际时钟四分之一的速度运行。如果以完全公平的方式分享计算能力,那么该时钟是判断等待进程将获得多少CPU时间的基准。在就绪队列等待实际的20秒,相当于虚拟时间5秒。4个进程分别执行5秒,即可使CPU被实际占用20秒。
假定就绪队列的虚拟时间由fair_clock给出,而进程的等待时间保存在wait_runtime。为排序红黑树上的进程,内核使用差值fair_clock - wait_runtime。fair_clock是完全公平调度的情况下进程将会得到的CPU时间的度量,而wait_runtime直接度量了实际系统的不足造成的不公平。
在进程允许运行时,将从wait_runtime减去它已经运行的时间。这样,在按时间排序的树中它会向右移动到某一点,另一个进程将成为最左边,下一次会被调度器选择。但请注意,在进程运行时fair_clock中的虚拟时钟会增加。这实际上意味着,进程在完全公平的系统中接收的CPU时间份额,是推演自在实际的CPU上执行花费的时间。这减缓了削弱不公平状况的过程:减少wait_runtime等价于降低进程受到的不公平对待的数量,但内核无论如何不能忘记,用于降低不公平性的一部分时间,实际上属于处于完全公平世界中的进程。再次假定就绪队列上有4个进程,而一个进程实际上已经等待了20秒。现在它允许运行10秒:此后的wait_runtime是10,但由于该进程无论如何都会得到该时间段中的10/4 = 2秒,因此实际上只有8秒对该进程在就绪队列中的新位置起了作用。
遗憾的是,该策略受若干现实问题的影响,已经变得复杂了。
进程的不同优先级(nice值)必须考虑,更重要的进程必须比次要进程更多的CPU时间份额。
进程不能切换得太频繁,因为上下文切换,即从一个进程改变到另一个,是有一定开销的。
在切换发生得太频繁时,过多时间花费在进程切换的过程中,而不是用于实际的工作。
另一方面,两次相邻的任务切换之间,时间也不能太长,否则会累积比较大的不公平值。对多媒体系统来说,进程运行太长时间也会导致延迟增大。
理解调度决策的一个好方法是,在编译时激活调度器统计。这会在运行时生成文件/proc/sched_debug,其中包含了调度器当前状态所有方面的信息。
二、数据结构
调度器使用一系列数据结构,来排序和管理系统中的进程。调度器的工作方式与这些结构的设计密切相关。几个组件在许多方面彼此交互,图2-13概述了这些组件的关联。
图2-13 调度子系统各组件概观
可以用两种方法激活调度。一种是直接的,比如进程打算睡眠或出于其他原因放弃CPU;另一种是通过周期性机制,以固定的频率运行,不时检测是否有必要进行进程切换。将这两个组件称为通用调度器( generic scheduler)或核心调度器( core scheduler)。本质上,通用调度器是一个分配器,与其他两个组件交互。
(1) 调度类用于判断接下来运行哪个进程。内核支持不同的调度策略(完全公平调度、实时调度、在无事可做时调度空闲进程),调度类使得能够以模块化方法实现这些策略,即一个类的代码不需要与其他类的代码交互。
在调度器被调用时,它会查询调度器类,得知接下来运行哪个进程。
(2) 在选中将要运行的进程之后,必须执行底层任务切换。这需要与CPU的紧密交互。
每个进程都刚好属于某一调度类,各个调度类负责管理所属的进程。通用调度器自身完全不涉及进程管理,其工作都委托给调度器类。
1、task_struct的成员
各进程的task_struct有几个成员与调度相关。
linux/sched.h
并不是系统上的所有进程都同样重要。不那么紧急的进程不需要太多关注,而重要的工作应该尽可能快速完成。为确定特定进程的重要性,给进程增加了相对优先级属性。
但task_struct采用了3个成员来表示进程的优先级: prio和normal_prio表示动态优先级,static_prio表示进程的静态优先级。静态优先级是进程启动时分配的优先级。它可以用nice和sched_setscheduler系统调用修改,否则在进程运行期间会一直保持恒定。
normal_priority表示基于进程的静态优先级和调度策略计算出的优先级。因此,即使普通进程和实时进程具有相同的静态优先级,其普通优先级也是不同的。进程分支时,子进程会继承普通优先级。
但调度器考虑的优先级则保存在prio。由于在某些情况下内核需要暂时提高进程的优先级,因此需要第3个成员来表示。由于这些改变不是持久的,因此静态和普通优先级不受影响。
rt_priority表示实时进程的优先级。该值不会代替先前讨论的那些值!最低的实时优先级为0,而最高的优先级是99。值越大,表明优先级越高。这里使用的惯例不同于nice值。
sched_class表示该进程所属的调度器类。
调度器不限于调度进程,还可以处理更大的实体。这可以用于实现组调度:可用的CPU时间可以首先在一般的进程组(例如,所有进程可以按所有者分组)之间分配,接下来分配的时间在组内再次分配。
这种一般性要求调度器不直接操作进程,而是处理可调度实体。一个实体由 sched_entity 的一个实例表示。
在最简单的情况下,调度在各个进程上执行,这也是我们最初关注的情形。由于调度器设计为处理可调度的实体,在调度器看来各个进程必须也像是这样的实体。因此 se 在 task_struct中内嵌了一个 sched_entity 实例,调度器可据此操作各个 task_struct(请注意 se 不是一个指针,因为该实体嵌入在 task_struct 中)。
policy保存了对该进程应用的调度策略。Linux支持5个可能的值。
SCHED_NORMAL用于普通进程,主要讲述此类进程。它们通过完全公平调度器来处理。SCHED_BATCH和SCHED_IDLE也通过完全公平调度器来处理,不过可用于次要的进程。SCHED_BATCH用于非交互、 CPU使用密集的批处理进程。调度决策对此类进程给予“冷处
理”:它们决不会抢占CF调度器处理的另一个进程,因此不会干扰交互式进程。如果不打算用nice降低进程的静态优先级,同时又不希望该进程影响系统的交互性,此时最适合使用该调度类。
在调度决策中SCHED_IDLE进程的重要性也比较低,因为其相对权重总是最小的。
注意,尽管名称是SCHED_IDLE,但SCHED_IDLE不负责调度空闲进程。空闲进程由内核提供单独的机制来处理。
SCHED_RR和SCHED_FIFO用于实现软实时进程。SCHED_RR实现了一种循环方法,而SCHED_FIFO则使用先进先出机制。这些是由实时调度器类处理。
辅助函数rt_policy用于判断给出的调度策略是否属于实时类( SCHED_RR和SCHED_FIFO)。task_has_rt_policy用于对给定进程判断该性质。
kernel/sched.c
cpus_allowed是一个位域,在多处理器系统上使用,用来限制进程可以在哪些CPU上运行。
run_list和time_slice是循环实时调度器所需要的,但不用于完全公平调度器。run_list是一个表头,用于维护包含各进程的一个运行表,而time_slice则指定进程可使用CPU的剩余时间段。
2、调度器类
调度器类提供了通用调度器和各个调度方法之间的关联。调度器类由特定数据结构中汇集的几个函数指针表示。全局调度器请求的各个操作都可以由一个指针表示。这使得无需了解不同调度器类的内部工作原理,即可创建通用调度器。
除去针对多处理器系统的扩展,该结构如下所示:
<linux/sched.h>
对各个调度类,都必须提供struct sched_class的一个实例。调度类之间的层次结构是平坦的:实时进程最重要,在完全公平进程之前处理;而完全公平进程则优先于空闲进程;空闲进程只有CPU无事可做时才处于活动状态。next成员将不同调度类的sched_class实例,按上述顺序连接起来。注意这个层次结构在编译时已经建立:没有在运行时动态增加新调度器类的机制。
下面是各个调度类可以提供的操作。
enqueue_task向就绪队列添加一个新进程。在进程从睡眠状态变为可运行状态时,即发生该操作。
dequeue_task提供逆向操作,将一个进程从就绪队列去除。事实上,在进程从可运行状态切换到不可运行状态时,就会发生该操作。内核有可能因为其他理由将进程从就绪队列去除,比如,进程的优先级可能需要改变。
就绪队列( run queue),各个调度类无须用简单的队列来表示其进程。完全公平调度器对此使用了红黑树。
在进程想要自愿放弃对处理器的控制权时,可使用sched_yield系统调用。这导致内核调用yield_task。
在必要的情况下,会调用check_preempt_curr, 用一个新唤醒的进程来抢占当前进程。例如,在用wake_up_new_task唤醒新进程时,会调用该函数check_preempt_curr。
pick_next_task用于选择下一个将要运行的进程,而put_prev_task则在用另一个进程代替当前运行的进程之前调用。要注意,这些操作并不等价于将进程加入或撤出就绪队列的操作,如enqueue_task和dequeue_task。相反,它们负责向进程提供或撤销CPU。但在不同进程之间切换,需要执行一个底层的上下文切换。
在进程的调度策略发生变化时,需要调用set_curr_task。
task_tick在每次激活周期性调度器时,由周期性调度器调用。
task_new用于建立fork系统调用和调度器之间的关联。每次新进程建立后,则用task_new通知调度器。
标准函数activate_task和deactivate_task调用前述的函数,提供进程在就绪队列的入队和离队功能。此外,它们还更新内核的统计数据。
kernel/sched.c
在进程注册到就绪队列时,嵌入的sched_entity实例的on_rq成员设置为1,否则为0。
此外,内核定义了便捷方法check_preempt_curr,调用与给定进程相关的调度类的check_preempt_curr方法:
kernel/sched.c
用户层应用程序无法直接与调度类交互。它们只知道上文定义的常量SCHED_xyz。在这些常量和可用的调度类之间提供适当的映射,这是内核的工作。SCHED_NORMAL、 SCHED_BATCH和SCHED_IDLE映射到fair_sched_class,而SCHED_RR和SCHED_FIFO与rt_sched_class关联。fair_sched_class和rt_sched_class都是struct sched_class的实例,分别表示完全公平调度器和实时调度器。
3、就绪队列
核心调度器用于管理活动进程的主要数据结构称之为就绪队列。各个CPU都有自身的就绪队列,各个活动进程只出现在一个就绪队列中。在多个CPU上同时运行一个进程是不可能的。
就绪队列是全局调度器许多操作的起点。但要注意,进程并不是由就绪队列的成员直接管理的!这是各个调度器类的职责,因此在各个就绪队列中嵌入了特定于调度器类的子就绪队列。
就绪队列是使用下列数据结构实现的。
kernel/sched.c
nr_running指定了队列上可运行进程的数目,不考虑其优先级或调度类。
load提供了就绪队列当前负荷的度量。队列的负荷本质上与队列上当前活动进程的数目成正比,其中的各个进程又有优先级作为权重。每个就绪队列的虚拟时钟的速度即基于该信息。由于负荷及其他相关数量的计算是调度算法的一个重要部分。
cpu_load用于跟踪此前的负荷状态。
cfs和rt是嵌入的子就绪队列,分别用于完全公平调度器和实时调度器。
curr指向当前运行的进程的task_struct实例。
idle指向idle进程的task_struct实例,该进程亦称为idle线程,在没有其他可运行进程时执行。
clock和prev_clock_raw用于实现就绪队列自身的时钟。每次调用周期性调度器时,都会更新clock的值。另外内核还提供了标准函数update_rq_clock,可在操作就绪队列的调度器中多处调用,例如,在用wakeup_new_task唤醒新进程时。
系统的所有就绪队列都在runqueues数组中,该数组的每个元素分别对应于系统中的一个CPU。在单处理器系统中,由于只需要一个就绪队列,数组只有一个元素。
kernel/sched.c
static DEFINE_PER_CPU_SHARED_ALIGNED(struct rq, runqueues);
内核也定义了一些便利的宏,其含义很明显。
kernel/sched.c
4、调度实体
由于调度器可以操作比进程更一般的实体,因此需要一个适当的数据结构来描述此类实体。其定义如下:
linux/sched.h
如果编译内核时启用了调度器统计,那么该结构会包含很多用于统计的成员。如果启用了组调度,那么还会增加一些成员。目前感兴趣的内容主要是上面列出的几项。各个成员的含义如下。
struct load_weight {
unsigned long weight, inv_weight;
};
load指定了权重,决定了各个实体占队列总负荷的比例。计算负荷权重是调度器的一项重任,因为CFS所需的虚拟时钟的速度最终依赖于负荷。
run_node是标准的树结点,使得实体可以在红黑树上排序。
on_rq表示该实体当前是否在就绪队列上接受调度。
在进程运行时,需要记录消耗的CPU时间,以用于完全公平调度器。sum_exec_runtime即用于该目的。跟踪运行时间是由update_curr不断累积完成的。调度器中许多地方都会调用该函数,例如,新进程加入就绪队列时,或者周期性调度器中。每次调用时,会计算当前时间和exec_start之间的差值, exec_start则更新到当前时间。差值则被加到sum_exec_runtime。
在进程执行期间虚拟时钟上流逝的时间数量由vruntime统计。
在进程被撤销CPU时,其当前sum_exec_runtime值保存到prev_sum_exec_runtime。此后,在进程抢占时又需要该数据。但请注意,在prev_sum_exec_runtime中保存sum_exec_runtime的值,而sum_exec_runtime则持续单调增长。
由于每个task_struct都嵌入了sched_entity的一个实例,所以进程是可调度实体。