Linux Scheduling
- Context Switch
- preemption
- Scheduler CFS
- Scheduler Deadline
- Scheduler Realtime
- Thread Priority
- Wake Preempt
引言
调度器作为操作系统的核心部件,具有非常重要的意义,其随着 linux 内核的更新也不断进行着更新。本系列文章通过 linux-3.18.3 源码进行调度器的学习和分析,一步一步将 linux 现有的调度器原原本本的展现出来。此篇文章作为开篇,主要介绍调度器的原理及重要数据结构。
调度器介绍
随着时代的发展,linux 也从其初始版本稳步发展到今天,从 2.4 的非抢占内核发展到今天的可抢占内核,调度器无论从代码结构还是设计思想上也都发生了翻天覆地的变化,其普通进程的调度算法也从 O(1)到现在的 CFS,一个好的调度算法应当考虑以下几个方面:
- **公平:**保证每个进程得到合理的 CPU 时间。
- **高效:**使 CPU 保持忙碌状态,即总是有进程在 CPU 上运行。
- **响应时间:**使交互用户的响应时间尽可能短。
- **周转时间:**使批处理用户等待输出的时间尽可能短。
- **吞吐量:**使单位时间内处理的进程数量尽可能多。
- **负载均衡:**在多核多处理器系统中提供更高的性能
而整个调度系统至少包含两种调度算法,是分别针对实时**进程和普通进程**,所以在整个 linux 内核中,实时进程和普通进程是并存的,但它们使用的调度算法并不相同,普通进程使用的是 CFS 调度算法(红黑树调度)。之后会介绍调度器是怎么调度这两种进程。
进程
上一节已经说明,在 linux 中,进程主要分为两种,一种为实时进程,一种为普通进程
- **实时进程:**对系统的响应时间要求很高,它们需要短的响应时间,并且这个时间的变化非常小,典型的实时进程有音乐播放器,视频播放器等。
- **普通进程:**包括交互进程和非交互进程,交互进程如文本编辑器,它会不断的休眠,又不断地通过鼠标键盘进行唤醒,而非交互进程就如后台维护进程,他们对 IO,响应时间没有很高的要求,比如编译器。
它们在 linux 内核运行时是共存的,实时进程的优先级为 0~99,实时进程优先级不会在运行期间改变(静态优先级),而普通进程的优先级为 100~139,普通进程的优先级会在内核运行期间进行相应的改变(动态优先级)。
调度策略
在 linux 系统中,调度策略分为
- **SCHED_NORMAL:**普通进程使用的调度策略,现在此调度策略使用的是 CFS 调度器。
- **SCHED_FIFO:**实时进程使用的调度策略,此调度策略的进程一旦使用 CPU 则一直运行,直到有比其更高优先级的实时进程进入队列,或者其自动放弃 CPU,适用于时间性要求比较高,但每次运行时间比较短的进程。
- **SCHED_RR:**实时进程使用的时间片轮转法策略,实时进程的时间片用完后,调度器将其放到队列末尾,这样每个实时进程都可以执行一段时间。适用于每次运行时间比较长的实时进程。
调度
首先,我们需要清楚,什么样的进程会进入调度器进行选择,就是处于 TASK_RUNNING 状态的进程,而其他状态下的进程都不会进入调度器进行调度。系统发生调度的时机如下
- 调用 cond_resched()时
- 显式调用 schedule()时
- 从系统调用或者异常中断返回用户空间时
- 从中断上下文返回用户空间时
当开启**内核抢占(默认开启)**时,会多出几个调度时机,如下
- 在系统调用或者异常中断上下文中调用 preempt_enable()时(多次调用 preempt_enable()时,系统只会在最后一次调用时会调度)
- 在中断上下文中,从中断处理函数返回到可抢占的上下文时(这里是中断下半部,中断上半部实际上会关中断,而新的中断只会被登记,由于上半部处理很快,上半部处理完成后才会执行新的中断信号,这样就形成了中断可重入, 但是即使是中断下半部, 也是不能够被调度的)
而在系统启动调度器初始化时会初始化一个调度定时器,调度定时器每隔一定时间执行一个中断,在中断会对当前运行进程运行时间进行更新,如果进程需要被调度,在调度定时器中断中会设置一个调度标志位,之后从定时器中断返回,因为上面已经提到从中断上下文返回时是可能有调度时机的,如果定时器中断返回时正好是返回到用户态空间, 而且调度标志位又置位了, 这时候就会做进程切换. 在内核源码的汇编代码中所有中断返回处理都必须去判断调度标志位是否设置,如设置则执行 schedule()进行调度。而我们知道实时进程和普通进程是共存的,调度器是怎么协调它们之间的调度的呢,其实很简单,每次调度时,会先在实时进程运行队列中查看是否有可运行的实时进程,如果没有,再去普通进程运行队列找下一个可运行的普通进程,如果也没有,则调度器会使用 idle 进程进行运行。之后的章节会放上代码进行详细说明。
系统并不是每时每刻都允许调度的发生,当处于中断期间的时候(无论是上半部还是下半部),调度是被系统禁止的,之后中断过后才重新允许调度。而对于异常,系统并不会禁止调度,也就是在异常上下文中,系统是有可能发生调度的。
数据结构
在这一节中,我们都是以普通进程作为讲解对象,因为普通进程使用的调度算法为 CFS 调度算法,它是以红黑树为基础的调度算法**,其相比与实时进程的调度算法复杂很多,而实时进程在组织结构上与普通进程没有太大差别,算法也较为简单**。
组成形式
图 1
从图 1 中可以看出来,每个 CPU 对应包含一个运行队列结构(struct rq),而每个运行队列又包含有其自己的实时进程运行队列(struct rt_rq)、普通进程运行队列(struct cfs_rq)、和一个我自己也不太知道有什么用的 dl 队列(struct dl_rq),也就是说每个 CPU 都有他们自己的实时进程运行队列及普通进程运行队列,为了方便,我们在图中只描述普通进程的组织结构(最复杂的也是普通进程的组织结构),红色 se 则为当前 CPU 上正在执行的程序,蓝色为下个将要执行的程序,其实图中并不规范,实际上当进程运行时,会从红黑树中剥离出来,然后设定下一个调度进程,当进程运行时间结束时,再重新放入红黑树中。而为什么 CPU0 上有两个蓝色将被调度进程,将在组调度中解释。而为什么红黑树中又有一个子红黑树,我们将在调度实体中解释。
组调度(struct task_group)
我们知道,linux 是一个多用户系统,如果有两个进程分别属于两个用户,而进程的优先级不同,会导致两个用户所占用的 CPU 时间不同,这样显然是不公平的(如果优先级差距很大,低优先级进程所属用户使用 CPU 的时间就很小),所以内核引入组调度。如果基于用户分组,即使进程优先级不同,这两个用户使用的 CPU 时间都为 50%。这就是为什么图 1 中 CPU0 有两个蓝色将被调度的程序,如果 task_group1 中的运行时间还没有使用完,而当前进程运行时间使用完后,会调度 task_group1 中的下一个被调度进程;相反,如果 task_group1 的运行时间使用结束,则调用上一层的下一个被调度进程。需要注意的是,一个组调度中可能会有一部分是实时进程,一部分是普通进程,这也导致这种组要能够满足即能在实时调度中进行调度,又可以在 CFS 调度中进行调度。 linux 可以以以下两种方式进行进程的分组:
-
**用户 ID:**按照进程的 USER ID 进行分组,在对应的/sys/kernel/uid/目录下会生成一个 cpu.share 的文件,可以通过配置该文件来配置用户所占 CPU 时间比例。
-
**cgourp(control group):**生成组用于限制其所有进程,比如我生成一个组(生成后此组为空,里面没有进程),设置其 CPU 使用率为 10%,并把一个进程丢进这个组中,那么这个进程最多只能使用 CPU 的 10%,如果我们将多个进程丢进这个组,这个组的所有进程平分这个 10%。
**注意的是,这里的进程组概念和 fork 调用所产生的****父子进程组概念不一样,文章所使用的进程组概念全为组调度中进程组的概念。**为了管理组调度,内核引进了 struct task_group 结构,如下:
|
|
在 struct task_group 结构中,最重要的成员为 struct sched_entity ** se 和 struct cfs_rq ** cfs_rq。在图 1 中,root_task_group 与 task_group1 都只有一个,它们在初始化时会根据 CPU 个数为 se 和 cfs_rq 分配空间,即在 task_group1 和 root_task_group 中会为每个 CPU 分配一个 se 和 cfs_rq,同理用于实时进程的 struct sched_rt_entity ** rt_se 和 struct rt_rq ** rt_rq 也是一样。为什么这样呢,原因就是在多核多 CPU 的情况下,同一进程组的进程有可能在不同 CPU 上同时运行,所以每个进程组都必须对每个 CPU 分配它的调度实体(struct sched_entity 和 struct sched_rt_entity)和运行队列(struct cfs_rq 和 struct rt_rq)。
调度实体(struct sched_entity)
在组调度中,也涉及到调度实体这个概念,它的结构为 struct sched_entity(简称 se),就是图 1 红黑树中的 se。其实际上就代表了一个调度对象,可以为一个进程,也可以为一个进程组。对于根的红黑树而言,一个进程组就相当于一个调度实体,一个进程也相当于一个调度实体。我们可以先看看其结构,如下:
1 /* 一个调度实体(红黑树的一个结点),其包含一组或一个指定的进程,包含一个自己的运行队列,一个父亲指针,一个指向需要调度的运行队列指针 */
2 struct sched_entity {
3 /* 权重,在数组prio_to_weight[]包含优先级转权重的数值 */
4 struct load_weight load; /* for load-balancing */
5 /* 实体在红黑树对应的结点信息 */
6 struct rb_node run_node;
7 /* 实体所在的进程组 */
8 struct list_head group_node;
9 /* 实体是否处于红黑树运行队列中 */
10 unsigned int on_rq;
11
12 /* 开始运行时间 */
13 u64 exec_start;
14 /* 总运行时间 */
15 u64 sum_exec_runtime;
16 /* 虚拟运行时间,在时间中断或者任务状态发生改变时会更新
17 * 其会不停增长,增长速度与load权重成反比,load越高,增长速度越慢,就越可能处于红黑树最左边被调度
18 * 每次时钟中断都会修改其值
19 * 具体见calc_delta_fair()函数
20 */
21 u64 vruntime;
22 /* 进程在切换进CPU时的sum_exec_runtime值 */
23 u64 prev_sum_exec_runtime;
24
25 /* 此调度实体中进程移到其他CPU组的数量 */
26 u64 nr_migrations;
27
28 #ifdef CONFIG_SCHEDSTATS
29 /* 用于统计一些数据 */
30 struct sched_statistics statistics;
31 #endif
32
33 #ifdef CONFIG_FAIR_GROUP_SCHED
34 /* 代表此进程组的深度,每个进程组都比其parent调度组深度大1 */
35 int depth;
36 /* 父亲调度实体指针,如果是进程则指向其运行队列的调度实体,如果是进程组则指向其上一个进程组的调度实体
37 * 在 set_task_rq 函数中设置
38 */
39 struct sched_entity *parent;
40 /* 实体所处红黑树运行队列 */
41 struct cfs_rq *cfs_rq;
42 /* 实体的红黑树运行队列,如果为NULL表明其是一个进程,若非NULL表明其是调度组 */
43 struct cfs_rq *my_q;
44 #endif
45
46 #ifdef CONFIG_SMP
47 /* Per-entity load-tracking */
48 struct sched_avg avg;
49 #endif
50 };
实际上,红黑树是根据 struct rb_node 建立起关系的,不过 struct rb_node 与 struct sched_entity 是一一对应关系,也可以简单看为一个红黑树结点就是一个调度实体。可以看出,在 struct sched_entity 结构中,包含了一个进程(或进程组)调度的全部数据,其被包含在 struct task_struct 结构中的 se 中,如下:
|
|
在 struct sched_entity 结构中,值得我们注意的成员是:
- **load:**权重,通过优先级转换而成,是 vruntime 计算的关键。
- **on_rq:**表明是否处于 CFS 红黑树运行队列中,需要明确一个观点就是,CFS 运行队列里面包含有一个红黑树,但这个红黑树并不是 CFS 运行队列的全部,因为红黑树仅仅是用于选择出下一个调度程序的算法。很简单的一个例子,普通程序运行时,其并不在红黑树中,但是还是处于 CFS 运行队列中,其 on_rq 为真。只有准备退出、即将睡眠等待和转为实时进程的进程其 CFS 运行队列的 on_rq 为假。
- **vruntime:**虚拟运行时间,调度的关键,其计算公式:一次调度间隔的虚拟运行时间 = 实际运行时间 * (NICE_0_LOAD / 权重)。可以看出跟实际运行时间和权重有关,红黑树就是以此作为排序的标准,优先级越高的进程在运行时其 vruntime 增长的越慢,其可运行时间相对就长,而且也越有可能处于红黑树的最左结点,调度器每次都选择最左边的结点为下一个调度进程。注意其值为单调递增,在每个调度器的时钟中断时当前进程的虚拟运行时间都会累加。单纯的说就是进程们都在比谁的 vruntime 最小,最小的将被调度。
- **cfs_rq:**此调度实体所处于的 CFS 运行队列。
- **my_q:**如果此调度实体代表的是一个进程组,那么此调度实体就包含有一个自己的 CFS 运行队列,其 CFS 运行队列中存放的是此进程组中的进程,这些进程就不会在其他 CFS 运行队列的红黑树中被包含(包括顶层红黑树也不会包含他们,他们只属于这个进程组的红黑树)。
对于怎么理解一个进程组有它自己的 CFS 运行队列,其实很好理解,比如在根 CFS 运行队列的红黑树上有一个进程 A 一个进程组 B,各占 50%的 CPU,对于根的红黑树而言,他们就是两个调度实体。调度器调度的不是进程 A 就是进程组 B,而如果调度到进程组 B,进程组 B 自己选择一个程序交给 CPU 运行就可以了,而进程组 B 怎么选择一个程序给 CPU,就是通过自己的 CFS 运行队列的红黑树选择,如果进程组 B 还有个子进程组 C,原理都一样,就是一个层次结构。
而在 struct task_struct 结构中,我们注意到有个调度类,里面包含的是调度处理函数,它具体如下:
|
|
这个调度类具体有什么用呢,实际上在内核中不同的调度算法它们的操作都不相同,为了方便修改、替换调度算法,使用了调度类,每个调度算法只需要实现自己的调度类就可以了,CFS 算法有它的调度类,SCHED_FIFO 也有它自己的调度类,当一个进程创建时,用什么调度算法就将其 task_struct->sched_class 指向其相应的调度类,调度器每次调度处理时,就通过当前进程的调度类函数进程操作,大大提高了可移植性和易修改性。
CFS 运行队列(struct cfs_rq)
我们现在知道,在系统中至少有一个 CFS 运行队列,其就是根 CFS 运行队列,而其他的进程组和进程都包含在此运行队列中,不同的是进程组又有它自己的 CFS 运行队列,其运行队列中包含的是此进程组中的所有进程。当调度器从根 CFS 运行队列中选择了一个进程组进行调度时,进程组会从自己的 CFS 运行队列中选择一个调度实体进行调度(这个调度实体可能为进程,也可能又是一个子进程组),就这样一直深入,直到最后选出一个进程进行运行为止。 对于 struct cfs_rq 结构没有什么好说明的,只要确定其代表着一个 CFS 运行队列,并且包含有一个红黑树进行选择调度进程即可。
|
|
- **load:**其保存的是进程组中所有进程的权值总和,需要注意子进程计算 vruntime 时需要用到进程组的 load。
CPU 运行队列(struct rq)
每个 CPU 都有自己的 struct rq 结构,其用于描述在此 CPU 上所运行的所有进程,其包括一个实时进程队列和一个根 CFS 运行队列,在调度时,调度器首先会先去实时进程队列找是否有实时进程需要运行,如果没有才会去 CFS 运行队列找是否有进行需要运行,这就是为什么常说的实时进程优先级比普通进程高,不仅仅体现在 prio 优先级上,还体现在调度器的设计上,至于 dl 运行队列,我暂时还不知道有什么用处,其优先级比实时进程还高,但是创建进程时如果创建的是 dl 进程创建会错误(具体见 sys_fork)。
|
|
总结
进程是操作系统虚拟出来的概念,用来组织计算机中的任务。但随着进程被赋予越来越多的任务,进程好像有了真实的生命,它从诞生就随着 CPU 时间执行,直到最终消失。不过,进程的生命都得到了操作系统内核的关照。就好像疲于照顾几个孩子的母亲内核必须做出决定,如何在进程间分配有限的计算资源,最终让用户获得最佳的使用体验。内核中安排进程执行的模块称为调度器(scheduler)。这里将介绍调度器的工作方式。
进程状态
调度器可以切换进程状态(process state)。一个 Linux 进程从被创建到死亡,可能会经过很多种状态,比如执行、暂停、可中断睡眠、不可中断睡眠、退出等。我们可以把 Linux 下繁多的进程状态,归纳为三种基本状态。
- 就绪(Ready): 进程已经获得了 CPU 以外的所有必要资源,如进程空间、网络连接等。就绪状态下的进程等到 CPU,便可立即执行。
- 执行(Running):进程获得 CPU,执行程序。
- 阻塞(Blocked):当进程由于等待某个事件而无法执行时,便放弃 CPU,处于阻塞状态。
图 1 进程的基本状态
进程创建后,就自动变成了就绪状态。如果内核把 CPU 时间分配给该进程,那么进程就从就绪状态变成了执行状态。在执行状态下,进程执行指令,最为活跃。正在执行的进程可以主动进入阻塞状态,比如这个进程需要将一部分硬盘中的数据读取到内存中。在这段读取时间里,进程不需要使用 CPU,可以主动进入阻塞状态,让出 CPU。当读取结束时,计算机硬件发出信号,进程再从阻塞状态恢复为就绪状态。进程也可以被迫进入阻塞状态,比如接收到 SIGSTOP 信号。
调度器是 CPU 时间的管理员。Linux 调度器需要负责做两件事:一件事是选择某些就绪的进程来执行;另一件事是打断某些执行中的进程,让它们变回就绪状态。不过,并不是所有的调度器都有第二个功能。有的调度器的状态切换是单向的,只能让就绪进程变成执行状态,不能把正在执行中的进程变回就绪状态。支持双向状态切换的调度器被称为抢占式(pre-emptive)调度器。
调度器在让一个进程变回就绪时,就会立即让另一个就绪的进程开始执行。多个进程接替使用 CPU,从而最大效率地利用 CPU 时间。当然,如果执行中进程主动进入阻塞状态,那么调度器也会选择另一个就绪进程来消费 CPU 时间。所谓的上下文切换(context switch)就是指进程在 CPU 中切换执行的过程。内核承担了上下文切换的任务,负责储存和重建进程被切换掉之前的 CPU 状态,从而让进程感觉不到自己的执行被中断。应用程序的开发者在编写计算机程序时,就不用专门写代码处理上下文切换了。
进程的优先级
调度器分配 CPU 时间的基本依据,就是进程的优先级。根据程序任务性质的不同,程序可以有不同的执行优先级。根据优先级特点,我们可以把进程分为两种类别。
- 实时进程(Real-Time Process):优先级高、需要尽快被执行的进程。它们一定不能被普通进程所阻挡,例如视频播放、各种监测系统。
- 普通进程(Normal Process):优先级低、更长执行时间的进程。例如文本编译器、批处理一段文档、图形渲染。
普通进程根据行为的不同,还可以被分成互动进程(interactive process)和批处理进程(batch process)。互动进程的例子有图形界面,它们可能处在长时间的等待状态,例如等待用户的输入。一旦特定事件发生,互动进程需要尽快被激活。一般来说,图形界面的反应时间是 50 到 100 毫秒。批处理进程没有与用户交互的,往往在后台被默默地执行。
实时进程由 Linux 操作系统创造,普通用户只能创建普通进程。两种进程的优先级不同,实时进程的优先级永远高于普通进程。进程的优先级是一个 0 到 139 的整数。数字越小,优先级越高。其中,优先级 0 到 99 留给实时进程,100 到 139 留给普通进程。
一个普通进程的默认优先级是 120。我们可以用命令 nice 来修改一个进程的默认优先级。例如有一个可执行程序叫 app,执行命令:
$nice -n -20 ./app
命令中的-20 指的是从默认优先级上减去 20。通过这个命令执行 app 程序,内核会将 app 进程的默认优先级设置成 100,也就是普通进程的最高优先级。命令中的-20 可以被换成-20 至 19 中任何一个整数,包括-20 和 19。默认优先级将会变成执行时的静态优先级(static priority)。调度器最终使用的优先级根据的是进程的动态优先级:
动态优先级 = 静态优先级 – Bonus + 5
如果这个公式的计算结果小于 100 或大于 139,将会取 100 到 139 范围内最接近计算结果的数字作为实际的动态优先级。公式中的 Bonus 是一个估计值,这个数字越大,代表着它可能越需要被优先执行。如果内核发现这个进程需要经常跟用户交互,将会把 Bonus 值设置成大于 5 的数字。如果进程不经常跟用户交互,内核将会把进程的 Bonus 设置成小于 5 的数。
O(n)和 O(1)调度器
下面介绍 Linux 的调度策略。最原始的调度策略是按照优先级排列好进程,等到一个进程运行完了再运行优先级较低的一个,但这种策略完全无法发挥多任务系统的优势。因此,随着时间推移,操作系统的调度器也多次进化。
先来看 Linux 2.4 内核推出的 O(n)调度器。O(n)这个名字,来源于算法复杂度的大 O 表示法。大 O 符号代表这个算法在最坏情况下的复杂度。字母 n 在这里代表操作系统中的活跃进程数量。O(n)表示这个调度器的时间复杂度和活跃进程的数量成正比。
O(n)调度器把时间分成大量的微小时间片(Epoch)。在每个时间片开始的时候,调度器会检查所有处在就绪状态的进程。调度器计算每个进程的优先级,然后选择优先级最高的进程来执行。一旦被调度器切换到执行,进程可以不被打扰地用尽这个时间片。如果进程没有用尽时间片,那么该时间片的剩余时间会增加到下一个时间片中。
O(n)调度器在每次使用时间片前都要检查所有就绪进程的优先级。这个检查时间和进程中进程数目 n 成正比,这也正是该调度器复杂度为 O(n)的原因。当计算机中有大量进程在运行时,这个调度器的性能将会被大大降低。也就是说,O(n)调度器没有很好的可拓展性。O(n)调度器是 Linux 2.6 之前使用的进程调度器。当 Java 语言逐渐流行后,由于 Java 虚拟机会创建大量进程,调度器的性能问题变得更加明显。
为了解决 O(n)调度器的性能问题,O(1)调度器被发明了出来,并从 Linux 2.6 内核开始使用。顾名思义,O(1)调度器是指调度器每次选择要执行的进程的时间都是 1 个单位的常数,和系统中的进程数量无关。这样,就算系统中有大量的进程,调度器的性能也不会下降。O(1)调度器的创新之处在于,它会把进程按照优先级排好,放入特定的数据结构中。在选择下一个要执行的进程时,调度器不用遍历进程,就可以直接选择优先级最高的进程。
和 O(n)调度器类似,O(1)也是把时间片分配给进程。优先级为 120 以下的进程时间片为:
(140–priority)×20 毫秒
优先级 120 及以上的进程时间片为:
(140–priority)×5 毫秒
O(1)调度器会用两个队列来存放进程。一个队列称为活跃队列,用于存储那些待分配时间片的进程。另一个队列称为过期队列,用于存储那些已经享用过时间片的进程。O(1)调度器把时间片从活跃队列中调出一个进程。这个进程用尽时间片,就会转移到过期队列。当活跃队列的所有进程都被执行过后,调度器就会把活跃队列和过期队列对调,用同样的方式继续执行这些进程。
上面的描述没有考虑优先级。加入优先级后,情况会变得复杂一些。操作系统会创建 140 个活跃队列和过期队列,对应优先级 0 到 139 的进程。一开始,所有进程都会放在活跃队列中。然后操作系统会从优先级最高的活跃队列开始依次选择进程来执行,如果两个进程的优先级相同,他们有相同的概率被选中。执行一次后,这个进程会被从活跃队列中剔除。如果这个进程在这次时间片中没有彻底完成,它会被加入优先级相同的过期队列中。当 140 个活跃队列的所有进程都被执行完后,过期队列中将会有很多进程。调度器将对调优先级相同的活跃队列和过期队列继续执行下去。过期队列和活跃队列,如图 2 所示。
图 2 过期队列和活跃队列(需要替换)
我们下面看一个例子,有五个进程,如表 1 所示。
表 1 进程
Linux 操作系统中的进程队列(run queue),如表 2 所示。
表 2 进程队列
那么在一个执行周期,被选中的进程依次是先 A,然后 B 和 C,随后是 D,最后是 E。
注意,普通进程的执行策略并没有保证优先级为 100 的进程会先被执行完进入结束状态,再执行优先级为 101 的进程,而是在每个对调活跃和过期队列的周期中都有机会被执行,这种设计是为了避免进程饥饿(starvation)。所谓的进程饥饿,就是优先级低的进程很久都没有机会被执行。
我们看到,O(1)调度器在挑选下一个要执行的进程时很简单,不需要遍历所有进程。但是它依然有一些缺点。进程的运行顺序和时间片长度极度依赖于优先级。比如,计算优先级为 100、110、120、130 和 139 这几个进程的时间片长度,如表 3 所示。
表 3 进程的时间片长度
从表格中你会发现,优先级为 110 和 120 的进程的时间片长度差距比 120 和 130 之间的大了 10 倍。也就是说,进程时间片长度的计算存在很大的随机性。O(1)调度器会根据平均休眠时间来调整进程优先级。该调度器假设那些休眠时间长的进程是在等待用户互动。这些互动类的进程应该获得更高的优先级,以便给用户更好的体验。一旦这个假设不成立,O(1)调度器对 CPU 的调配就会出现问题。
完全公平调度器
从 2007 年发布的 Linux 2.6.23 版本起,完全公平调度器(CFS,Completely Fair Scheduler)取代了 O(1)调度器。CFS 调度器不对进程进行任何形式的估计和猜测。这一点和 O(1)区分互动和非互动进程的做法完全不同。
CFS 调度器增加了一个虚拟运行时(virtual runtime)的概念。每次一个进程在 CPU 中被执行了一段时间,就会增加它虚拟运行时的记录。在每次选择要执行的进程时,不是选择优先级最高的进程,而是选择虚拟运行时最少的进程。完全公平调度器用一种叫红黑树的数据结构取代了 O(1)调度器的 140 个队列。红黑树可以高效地找到虚拟运行最小的进程。
我们先通过例子来看 CFS 调度器。假如一台运行的计算机中本来拥有 A、B、C、D 四个进程。内核记录着每个进程的虚拟运行时,如表 4 所示。
表 4 每个进程的虚拟运行时
系统增加一个新的进程 E。新创建进程的虚拟运行时不会被设置成 0,而会被设置成当前所有进程最小的虚拟运行时。这能保证该进程被较快地执行。在原来的进程中,最小虚拟运行时是进程 A 的 1 000 纳秒,因此 E 的初始虚拟运行时会被设置为 1 000 纳秒。新的进程列表如表 5 所示。
表 5 新的进程列表
假如调度器需要选择下一个执行的进程,进程 A 会被选中执行。进程 A 会执行一个调度器决定的时间片。假如进程 A 运行了 250 纳秒,那它的虚拟运行时增加。而其他的进程没有运行,所以虚拟运行时不变。在 A 消耗完时间片后,更新后的进程列表,如表 6 所示。
表 6 更新后的进程列表
可以看到,进程 A 的排序下降到了第三位,下一个将要被执行的进程是进程 E。从本质上看,虚拟运行时代表了该进程已经消耗了多少 CPU 时间。如果它消耗得少,那么理应优先获得计算资源。
按照上述的基本设计理念,CFS 调度器能让所有进程公平地使用 CPU。听起来,这让进程的优先级变得毫无意义。CFS 调度器也考虑到了这一点。CFS 调度器会根据进程的优先级来计算一个时间片因子。同样是增加 250 纳秒的虚拟运行时,优先级低的进程实际获得的可能只有 200 纳秒,而优先级高的进程实际获得可能有 300 纳秒。这样,优先级高的进程就获得了更多的计算资源。
以上就是调度器的基本原理,以及 Linux 用过的几种调度策略。调度器可以更加合理地把 CPU 时间分配给进程。现代计算机都是多任务系统,调度器在多任务系统中起着顶梁柱的作用。
参考资料
Author Houmin Wei
Publish July 21, 2020
LastMod August 27, 2026
License 本作品采用 CC BY-NC-ND 4.0 许可协议进行许可,转载时请注明原文链接
如果你在浏览博客的过程中发现了任何问题,欢迎在对应文章下评论。如果你有其他事情想要咨询,可以通过邮件联系我。