一句话 + 关键问题
没有完备的先验知识,如何调度?如何设计一个调度器,既能让交互式作业的响应时间最小,又能在不知道作业长度的前提下让周转时间最小?
THE CRUX: HOW TO SCHEDULE WITHOUT PERFECT KNOWLEDGE? — How can we design a scheduler that both minimizes response time for interactive jobs while also minimizing turnaround time without a priori knowledge of job length?
上一章留下了两难:SJF/STCF 周转时间好,但需要知道作业长度;RR 响应时间好,但周转时间差。多级反馈队列(Multi-level Feedback Queue, MLFQ)要同时解决这两件事,而且不需要任何先验知识:它一边运行,一边观察作业的行为,用过去预测未来。
MLFQ 最早由 Corbató 等人于 1962 年在兼容分时系统(CTSS)中提出,这项工作连同后来的 Multics 让 Corbató 获得了图灵奖。BSD UNIX 的衍生系统、Solaris、Windows NT 及其后续版本都以某种形式的 MLFQ 作为基础调度器。本章按书中的节奏,从两条基本规则出发,每发现一个漏洞就加一条规则,最终得到 5 条规则。
核心概念速览
多个队列 = 多个优先级
每个队列有一个优先级;任一时刻,一个就绪作业只在一个队列里。
规则 1、2
优先级高的先运行;优先级相同的轮转(RR)。
反馈(feedback)
优先级不是固定的,而是根据作业观察到的行为调整:频繁让出 CPU 的保持高优先级,长期霸占 CPU 的逐步降级。
时间配额(allotment)
作业在某一优先级上可以使用的 CPU 时间总量,用完就降级。最简单时配额 = 一个时间片。
近似 SJF
新作业先假定"可能很短"放在最高优先级;真短就很快完成,真长就一路沉到底。
饥饿与优先级提升
交互作业太多时长作业会饥饿(starvation);规则 5 每隔 S 把所有作业提升(boost)到最高队列。
愚弄调度器(gaming)
在配额用完前主动发起 I/O 就能永远留在高优先级。规则 4 的"完整计账"堵住这个漏洞。
巫毒常量(voo-doo constants)
队列数、时间片、配额、S 都很难设定,只能靠经验调优——Ousterhout 定律:尽量避免它们。
图解讲解
8.1 基本规则
MLFQ 有若干个不同的队列,每个队列一个优先级。任何时刻一个就绪作业只在一个队列中。MLFQ 用优先级决定运行谁;同一队列里可能有多个作业(优先级相同),它们之间用轮转。于是得到前两条规则:
- 规则 1:如果 A 的优先级 > B 的优先级,运行 A(不运行 B)。
- 规则 2:如果 A 的优先级 = B 的优先级,A 和 B 轮转运行。
MLFQ 调度的关键在于如何设置优先级。它不给作业固定优先级,而是根据观察到的行为改变优先级:如果一个作业反复在等待键盘输入时让出 CPU(交互式进程的典型行为),MLFQ 就保持它的高优先级;如果一个作业长时间密集使用 CPU,MLFQ 就降低它的优先级。
8.2 尝试 1:如何改变优先级
我们的工作负载是:一些运行时间短、频繁让出 CPU 的交互式作业,加上一些需要大量 CPU、不在乎响应时间的长时间"CPU 密集型"作业。先引入配额(allotment):作业在某个优先级上能花的时间,用完就降级。先简单地假定配额 = 一个时间片。
- 规则 3:作业进入系统时,放在最高优先级(最上层队列)。
- 规则 4a:如果作业在运行中用完了它的配额,就降低它的优先级(移入下一层队列)。
- 规则 4b:如果作业在配额用完之前主动放弃 CPU(例如发起 I/O),它保持在同一优先级(配额重置)。
下面这些图都由本页模拟器按规则实时生成(时间片 10ms,配额 = 时间片,三个队列 Q2 > Q1 > Q0)。每个队列一行,行内每个作业占一条"泳道";实心块 = 正在运行,细线 = 在该队列中等待。
例 1:一个长时间运行的作业。作业以最高优先级 Q2 进入;运行一个 10ms 时间片后降到 Q1;再运行一个时间片后降到最低的 Q0,并一直留在那里。
例 2:来了一个短作业。A 是长时间运行的 CPU 密集型作业,已经跑了一段时间,沉在 Q0;B 在 T=100 到达,是一个只需 20ms 的短作业。B 被放入最高队列,两个时间片后(还没沉到底)就运行完毕,然后 A 以低优先级继续运行。
这就是 MLFQ 的主要目标之一:因为它不知道一个作业是短是长,就先假设它是短作业并给予高优先级。如果真的很短,它会很快完成;如果不是,它就会慢慢沉下去,证明自己是一个长时间运行的批处理型进程。MLFQ 以这种方式近似 SJF。
例 3:有 I/O 的情况。按规则 4b,交互式作业 B 每次只用 1ms CPU 就执行 I/O(这里 I/O 需要 5ms),在配额用完之前就让出 CPU,所以一直留在最高优先级;A 只能在 B 做 I/O 的空隙中运行。
目前的 MLFQ 有什么问题?
看起来不错:长作业之间公平共享 CPU,短作业和 I/O 密集的交互作业能快速运行。但这个版本有严重缺陷(请尽可能"邪恶"地想一想):
① 饥饿(starvation)
系统中交互式作业"太多"时,它们合起来会吃掉所有 CPU,长作业一点 CPU 都得不到。
② 愚弄调度器(game the scheduler)
聪明的用户可以改写程序:在配额用完前发一个 I/O(比如对文件)主动让出 CPU,就能留在同一队列,获得更高比例的 CPU。做得好(如用掉配额的 99% 再让出),几乎可以垄断 CPU。
③ 行为会变化
一个程序可能先是 CPU 密集型,后来转为交互式。按目前的规则,它已经沉到底,不会再被当作交互式作业对待。
调度必须能抵御攻击。你可能觉得调度策略(无论在 OS 内部,还是在分布式存储系统的 I/O 请求处理中)不是安全问题,但在越来越多的场景下它恰恰是。现代数据中心里,来自世界各地的用户共享 CPU、内存、网络和存储;如果策略设计和执行不当,一个用户就可能损害他人并为自己谋利。
8.3 尝试 2:优先级提升
为了避免饥饿,最简单的办法是周期性地提升(boost)所有作业的优先级:
- 规则 5:经过一段时间 S 后,把系统中的所有作业都移到最高队列。
规则 5 一次解决两个问题:第一,进程不会饿死——在最高队列里,它会与其他高优先级作业轮转,最终得到服务;第二,如果一个 CPU 密集型作业变成了交互式的,提升之后调度器就会正确地对待它。
下面的例子中,长作业 A 与两个交互式短作业 B、C 竞争(B、C 在 T=100 到达,每运行 2ms 就做一次 2ms 的 I/O)。左边没有优先级提升,B、C 到达后 A 就饿死了;右边每 100ms 提升一次(这个值可能太小了,但用于举例),A 至少能周期性地回到最高优先级运行一下。
说明:原书图 8.4 是示意图(A 每 30ms 在 Q2 运行一次,与正文"每 100ms 提升一次"并不严格对应)。本页的图完全按规则 1–5 逐毫秒模拟,定性结论相同:没有提升时 A 饿死;有提升时 A 每个周期都能得到少量 CPU。你可以在下方模拟器里把 S 改成 30 或 50 对比。
那 S 应该设成多少?John Ousterhout 把这类值称为巫毒常量(voo-doo constants),因为它们似乎需要某种黑魔法才能设对。S 设得太大,长作业会饿死;太小,交互式作业又得不到合适的 CPU 份额。这通常留给系统管理员去调,或者在现代,越来越多地交给基于机器学习的自动方法。
避免巫毒常量(Ousterhout 定律)。能避免就避免;可惜往往很难。常见结果是一个充满默认参数的配置文件,老练的管理员在出问题时去调整——而这些值通常没人动,我们只能祈祷默认值在实际中工作良好。
8.4 尝试 3:更好的计账
还剩一个问题:如何防止愚弄调度器?罪魁祸首是规则 4a 和 4b——它们允许作业在配额到期前让出 CPU 从而保住优先级。解决办法是对每一层的 CPU 使用做更好的计账(accounting):作业做 I/O 时,调度器不再"忘记"它在本层已经用了多少配额,而是一直累计;用完就降级,不管是一次用完还是分很多次用完。于是把规则 4a、4b 合并重写为:
- 规则 4:一旦作业用完了它在某一层的时间配额(无论中间主动放弃了多少次 CPU),就降低它的优先级(移入下一层队列)。
下面的工作负载试图愚弄调度器:A 是长作业,B 在 T=100 到达,每运行 5ms 就发起一次 1ms 的 I/O(总是在 10ms 配额用完之前让出 CPU)。
细看右图有个有趣的细节:A 在 123–130 只运行了 7ms。这是因为 A 在 Q0 的这个时间片里之前已经被 B 抢占过 3 次、每次运行 1ms;被高优先级作业抢占不会重置 A 的时间片,所以 A 只剩 7ms。
8.5 调优 MLFQ 及其他问题
MLFQ 还有一个大问题:如何参数化?应该有多少个队列?每个队列的时间片多长?配额多大?多久提升一次优先级?这些问题没有简单答案,只有积累对工作负载的经验并据此调优。
例如,大多数 MLFQ 变体允许不同队列有不同的时间片长度。高优先级队列通常时间片较短(例如 10ms 或更少),因为里面是交互式作业,快速切换合理;低优先级队列则是 CPU 密集型的长作业,较长的时间片(例如数百毫秒)效果更好。下图中,两个作业在最高队列运行 20ms(时间片 10ms),在中间队列运行 40ms(时间片 20ms),在最低队列时间片为 40ms:
| 实现 | 做法 |
|---|---|
| Solaris 分时调度类(TS) | 特别容易配置:提供一组表,精确规定进程优先级在生命周期中如何变化、每个时间片多长、多久提升一次。默认 60 个队列,时间片从 20ms(最高优先级)缓慢增加到几百毫秒(最低),大约每 1 秒提升一次。管理员可以修改这张表来改变调度行为。 |
| FreeBSD(4.3 版) | 不用表,而是用数学公式根据进程用了多少 CPU 计算优先级;使用量随时间衰减(decay),以另一种方式实现了优先级提升。可参考 Epema 关于衰减使用(decay-usage)调度的论文。 |
| 其他特性 | 一些调度器为操作系统工作保留最高的几个优先级,普通用户作业永远得不到;一些系统允许用户给出建议,例如用命令行工具 nice 提高或降低作业的优先级。 |
尽可能使用建议(advice)。操作系统很少知道什么对每个进程最好,因此提供接口让用户或管理员给出一些提示通常很有用。这类提示称为建议:OS 不一定要理会,但可以参考它们做出更好的决定。调度器(nice)、内存管理器(madvise)、文件系统(informed prefetching and caching)都有这样的接口。
8.6 小结:完整的 MLFQ 规则
- 规则 1:如果 A 的优先级 > B 的优先级,运行 A(不运行 B)。
- 规则 2:如果 A 的优先级 = B 的优先级,A 和 B 使用该队列的时间片以轮转方式运行。
- 规则 3:作业进入系统时,放在最高优先级(最上层队列)。
- 规则 4:一旦作业用完了它在某一层的时间配额(无论中间主动放弃了多少次 CPU),就降低其优先级(移入下一层队列)。
- 规则 5:经过一段时间 S 后,把系统中的所有作业移到最高队列。
MLFQ 有意思的地方在于:它不要求对作业性质有先验知识,而是观察作业的执行并据此确定优先级。这样它能两全其美:对运行时间短的交互式作业,性能接近 SJF/STCF;对长时间运行的 CPU 密集型作业,也公平并能保证进展。
从历史中学习。MLFQ 是用过去预测未来的一个绝佳例子。这类方法在操作系统中很常见(在计算机科学的其他领域也是,例如硬件分支预测器和缓存算法)。当作业有阶段性的行为、因而可预测时,这种方法就有效;但必须小心,预测可能出错,让系统做出比完全不知道时还糟糕的决定。
交互模拟器
MLFQ 调度器:规则逐条开关,逐毫秒单步
| 作业 | 到达 | 运行时长 | 每运行几 ms 发 I/O | I/O 时长 |
|---|
先预测再验证
看答案
看答案
看答案
看答案
看答案
自测题
常见误区
- "MLFQ 需要知道作业长度。"恰恰相反,MLFQ 的全部意义在于不需要先验知识,而是从作业的运行历史中学习。
- "有了优先级提升就不怕愚弄了。"提升解决的是饥饿;愚弄要靠规则 4 的完整计账。两者缺一不可。
- "配额就是时间片。"本章开头为了简单令二者相等;一般来说配额是作业在某一层能用的 CPU 总量,可以是多个时间片(如图 8.6 中 Q2 的配额是 20ms、时间片是 10ms)。
- "交互式作业在 MLFQ 里会一直保持高优先级。"在规则 4 下,即使频繁做 I/O,只要累计用完了配额也会降级;它能回到高优先级靠的是规则 5 的提升。
- "被高优先级作业抢占后,时间片会重新开始。"在书中的模型(以及 mlfq.py)里不会:被抢占的作业仍在队首,只剩下未用完的那部分时间片。
本章小结
- MLFQ 用多个优先级队列 + 基于行为的反馈,在不知道作业长度的情况下兼顾周转时间(近似 SJF)和响应时间(交互作业留在高优先级)。
- 五条规则:1 高优先级先运行;2 同级轮转;3 新作业进最高队列;4 用完本层配额就降级(完整计账,防愚弄);5 每隔 S 全部提升(防饥饿、适应行为变化)。
- 参数(队列数、各层时间片、配额、S)难以设定,是"巫毒常量";Solaris 用表配置,FreeBSD 用衰减公式,还可以借助
nice等用户建议。 - 下一章换一个目标:不优化周转或响应时间,而是保证每个作业获得一定比例的 CPU——比例份额调度(彩票、步长、Linux CFS)。第 10 章再把这些思想推广到多处理器。