一句话 + 关键问题
如何按比例分享 CPU?如何设计调度器来按比例分享 CPU?关键的机制是什么?它们效果如何?
CRUX: HOW TO SHARE THE CPU PROPORTIONALLY — How can we design a scheduler to share the CPU proportionally? What are the key mechanisms for doing so? How effective are they?
前两章的调度器都在优化周转时间或响应时间。本章换一个目标:比例份额(proportional-share)调度器,也叫公平份额(fair-share)调度器,它试图保证每个作业获得一定百分比的 CPU 时间。
我们会看三种做法:用随机数"抽签"的彩票调度(lottery scheduling)(Waldspurger 与 Weihl),确定性的步长调度(stride scheduling),以及曾经最广泛使用的真实调度器——Linux 的完全公平调度器(CFS)。这些思想在虚拟化数据中心里特别有用(比如给 Windows 虚拟机分 1/4 的 CPU),下一章讨论多处理器时也会再次遇到 CFS。
核心概念速览
彩票(ticket)
代表一个进程应得的资源份额:持有的彩票占总数的百分比 = 应得的 CPU 百分比。
彩票调度
每个时间片抽一次奖,中奖彩票的持有者运行。概率上正确,但不保证短期精确。
彩票货币 / 转让 / 通胀
货币(currency):用户用自己的"币种"分配,系统换算成全局值;转让(transfer):临时把彩票交给别的进程;通胀(inflation):临时增减自己的彩票。
公平指标 F
两个相同作业中,先完成者的完成时刻 ÷ 后完成者的完成时刻。越接近 1 越公平。
步长调度(stride)
步长 = 大数 ÷ 票数;每次运行 pass 值最小的进程,并让它的 pass 加上步长。确定性地精确按比例。
虚拟运行时间(vruntime)
CFS 为每个进程累计 vruntime,每次挑 vruntime 最小的运行;权重越大,vruntime 涨得越慢。
sched_latency / min_granularity
时间片 = sched_latency(如 48ms)按权重分给 n 个进程,但不小于 min_granularity(如 6ms)。
nice 与红黑树
nice 值 −20…+19 映射为权重;可运行进程按 vruntime 放在红黑树中,插入/删除都是 O(log n)。
图解讲解
9.1 基本概念:彩票代表份额
彩票调度的核心概念就是彩票(ticket)。例如 A 有 75 张、B 有 25 张,我们希望 A 获得 75% 的 CPU、B 获得 25%。调度器每隔一段时间(比如每个时间片)抽一次奖:知道总共 100 张,从 0–99 中随机选一个中奖号码;A 持有 0–74 号,B 持有 75–99 号,中奖号码落在谁的区间就运行谁。(计算机科学家总是从 0 开始数。)
这就是随机性带来的概率正确(probabilistic correctness):能在期望上满足比例,但不保证。两个作业竞争的时间越长,越可能接近期望的比例。
使用随机性。随机方法相比传统决策至少有三个优点:① 常常能避免奇怪的边角情况——例如 LRU 替换策略在某些循环顺序访问的负载下表现最差,随机则没有这种最坏情况;② 轻量,几乎不需要状态——传统公平份额算法要为每个进程记账,每次运行后都要更新,而随机方法只需要最少的每进程状态(比如票数);③ 很快,只要生成随机数快,决策就快。当然,需求越快,随机数就越趋向于伪随机。
9.2 彩票机制
彩票调度还提供了几种操纵彩票的机制:
- 彩票转让(ticket transfer):进程可以临时把自己的彩票交给另一个进程。这在客户端/服务器场景特别有用:客户端请求服务器替它做事时,把彩票交给服务器,让服务器更快地处理自己的请求;完成后服务器再把彩票还回来。
- 彩票通胀(ticket inflation):进程可以临时增加或减少自己持有的彩票数。在互不信任的竞争环境中这没有意义(贪婪的进程会给自己发大量彩票,占领机器);但在一组相互信任的进程之间,某个进程需要更多 CPU 时可以提高自己的票值来反映这个需求,而无需与其他进程通信。
用彩票表示份额。彩票是彩票调度和步长调度设计中最强大(也最基本)的机制。它不仅可以表示 CPU 份额,还能用得更广:Waldspurger 在后来关于虚拟机监视器内存管理的工作中,用彩票表示客户操作系统的内存份额。需要表示"所有权比例"时,这个概念可能正是你要的……彩票。
9.3 实现
彩票调度最惊人的地方是实现简单:一个好的随机数生成器选中奖号码,一个记录进程的数据结构(例如链表),以及彩票总数。假设三个进程 A、B、C 分别有 100、50、250 张彩票(共 400 张),在链表中按顺序排列。抽到中奖号码后,遍历链表,用计数器累加票数,直到计数器大于中奖号码:
为了让这个过程最高效,通常最好把链表按票数从多到少排序。顺序不影响正确性,但能保证总体上遍历次数最少,尤其是少数进程持有大部分彩票时。勾选上面的选项试试:同样的中奖号码,C 排在最前,一步就找到了(注意:换了顺序后,每个号码对应的中奖者会变,但每个进程中奖的概率不变)。
书中脚注提醒:从一个范围里正确地生成均匀随机数并不容易(例如用 rand() % n 会有偏差)。
9.4 一个例子:公平性研究
两个作业竞争 CPU,彩票数相同(各 100 张),运行时间相同(都是 R,我们会改变它)。我们希望它们大致同时完成,但由于随机性,有时一个会先完成。定义一个简单的公平指标 F:第一个作业完成的时刻除以第二个作业完成的时刻。例如 R = 10,第一个作业在 10 完成、第二个在 20 完成,则 F = 10/20 = 0.5。两个作业几乎同时完成时 F 接近 1——完美公平的调度器应当达到 F = 1。(旧版原书把这个指标称为不公平指标 U,定义相同。)
9.5 如何分配彩票?
一个我们还没解决的问题是:怎么给作业分配彩票?这是个难题,因为系统的行为强烈依赖于彩票的分配方式。一种做法是假设用户最清楚:给每个用户一些彩票,由用户分给自己运行的作业。但这其实是个"非解决方案"——它并没有告诉你该怎么做。所以给定一组作业,"彩票分配问题"仍然没有答案。
9.6 步长调度(stride scheduling)
你可能会问:为什么要用随机数?随机让调度器简单(且近似正确),但偶尔不能准确地达到比例,特别是在短时间尺度上。因此 Waldspurger 发明了步长调度——一个确定性的公平份额调度器。
每个作业有一个步长(stride),与票数成反比:用一个大数除以票数。A、B、C 有 100、50、250 张票,用 10000 去除,得到步长 100、200、40。每个进程还有一个计数器,称为行程值(pass),记录它的全局进度:每次运行,就把 pass 加上它的步长。调度时总是挑 pass 最小的进程运行:
curr = remove_min(queue); // 选 pass 最小的客户
schedule(curr); // 运行一个时间片
curr->pass += curr->stride; // 用步长更新 pass
insert(queue, curr); // 放回队列
彩票调度在时间上按概率达到比例;步长调度在每个调度周期结束时精确达到比例。那为什么还要用彩票调度?因为它有一个步长调度没有的好性质:没有全局状态。想象在上面的例子中途加入一个新作业,它的 pass 该设为多少?设为 0 的话,它会独占 CPU 很久。而彩票调度没有每进程的全局状态,只需加入新进程、更新一个全局变量(总票数)即可,因此更容易以合理的方式加入新进程。(在下方步长模拟器里可以试试"中途加入"。)
9.7 Linux 完全公平调度器(CFS)
Linux 的 CFS 以另一种方式实现公平份额,而且高效、可扩展。效率很重要:Google 数据中心的研究发现,即使经过积极优化,调度仍然占用了整个数据中心约 5% 的 CPU 时间。
基本操作
大多数调度器基于固定的时间片,CFS 不同。它的目标很简单:在所有竞争进程之间公平地平分 CPU。它用一种计数方法——虚拟运行时间(virtual runtime, vruntime)来做到这一点:每个进程运行时累积 vruntime;最基本的情况下,所有进程的 vruntime 以相同速率随物理时间增长。需要调度时,CFS 选 vruntime 最小的进程运行。
那什么时候停下当前进程?这里有张力:切换太频繁,公平性提高(即使在很短的时间窗口内每个进程也能得到自己的份额),但性能下降(上下文切换太多);切换不频繁则相反。CFS 用几个参数管理这种张力:
sched_latency(典型值 48ms):CFS 用它除以 CPU 上运行的进程数 n,得到每个进程的时间片,从而保证在这段时间内完全公平。例如 n = 4 时时间片为 12ms。min_granularity(通常约 6ms):时间片永远不会小于它。例如 10 个进程时 48/10 = 4.8ms,但 CFS 会把时间片设为 6ms——在 48ms 的目标延迟内不再完全公平,但接近,同时保持了较高的 CPU 效率。
CFS 使用周期性时钟中断(例如每 1ms 一次),因此只能在固定时间间隔做决策。如果时间片不是中断间隔的整数倍也没关系:CFS 精确地跟踪 vruntime,长期来看会逼近理想的 CPU 共享。(本页模拟器为清晰起见按精确时间片运行。)
权重(niceness)
CFS 也允许控制进程优先级。它不用彩票,而是用经典的 UNIX 机制——进程的 nice 值:可以设为 −20 到 +19,默认 0。正的 nice 值意味着低优先级,负值意味着高优先级(太 nice 了,就得不到那么多调度关注)。CFS 把 nice 值映射为权重:
有了权重,就可以计算每个进程的有效时间片(n 个进程):
time_slicek = weightk / (Σi=0n−1 weighti) · sched_latency (式 9.1)
vruntime 的计算也要调整:把进程 i 实际运行的时间 runtimei 按权重反比缩放,即乘以默认权重 1024(weight0)除以它的权重:
vruntimei = vruntimei + (weight0 / weighti) · runtimei (式 9.2)
书中的例子:A 很宝贵,nice = −5;B 讨人厌("we hates it",致敬《指环王》里的咕噜),nice = 0。权重分别是 3121 和 1024,A 的时间片约为 sched_latency 的 3/4(36ms),B 约 1/4(12ms);A 的 vruntime 以 B 的约三分之一的速率累积。
权重表的构造很巧妙:nice 值之差恒定时,CPU 比例保持不变。例如 A 的 nice 改为 5、B 改为 10,CFS 的调度方式与之前完全相同(上面的计算器里试试)。原因是表中相邻两项之比大约恒为 1.25,所以权重之比只取决于 nice 值之差。
使用红黑树
CFS 的一个重点是效率。调度器需要尽快找到下一个要运行的作业。链表这种简单结构无法扩展:现代系统有时有上千个进程,每隔几毫秒就遍历一次长链表太浪费了。CFS 把进程放在红黑树(red-black tree)中——一种平衡树,它多做一点工作来保持较低的深度,保证操作是对数时间而不是线性时间。
如果用有序链表,找下一个作业很简单(取第一个元素),但把作业按顺序放回链表需要扫描,是 O(n);查找平均也是线性的。而红黑树的插入、删除等大多数操作都是 O(log n)。n 上千时,对数比线性明显高效。CFS 并不把所有进程都放在树中,只放正在运行或可运行的进程;进程睡眠(比如等待 I/O 完成或网络包到达)时会从树中移除,在别处记录。
在合适的时候使用高效的数据结构。很多时候链表就够了,很多时候不够。早期调度器用的简单链表在现代系统上表现不好,特别是在数据中心里负载很重、有上千个活跃进程的服务器上。选择数据结构时,要仔细考虑它的访问模式和使用频率。
处理 I/O 和睡眠的进程
选 vruntime 最小者运行,对长时间睡眠的作业会出问题。设 A 一直运行,B 睡了很久(比如 10 秒)。B 醒来时,它的 vruntime 比 A 落后 10 秒;如果不小心,B 会在接下来的 10 秒里独占 CPU 追赶,A 就饿死了。CFS 的办法是:作业醒来时,把它的 vruntime 设为树中的最小值(树里只有正在运行的作业)。这样避免了饥饿,但有代价:频繁短暂睡眠的作业往往永远得不到公平的份额。(下方 CFS 模拟器的"睡眠者"预设可以对比两种做法。)
CFS 还有很多其他特性:提高缓存性能的各种启发式方法、有效处理多 CPU 的策略(下一章讨论)、能以大的进程组为单位调度等。
9.8 小结
彩票调度巧妙地用随机性实现比例份额;步长调度确定性地实现它;CFS 是本章唯一"真实"的调度器,有点像带动态时间片的加权轮转,但为在负载下扩展良好而构建,多年来一直是使用最广泛的公平份额调度器。
没有哪个调度器是万能药。公平份额调度器也有自己的问题:一是与 I/O 配合得不太好——偶尔做 I/O 的作业可能得不到公平的 CPU 份额;二是彩票或优先级分配这个难题仍悬而未决——你的浏览器该分多少票?文本编辑器的 nice 值该设多少?MLFQ 等通用调度器能自动处理这些问题,因此更容易部署。不过在很多领域里这些问题不是主要矛盾,比例份额调度器用得很成功,例如虚拟化数据中心。最后,Linux 一直在变:从 Linux 6.6 起,最早合格虚拟截止时间优先(EEVDF)调度器(另一种公平共享方法)成为默认调度器。
交互模拟器
彩票调度:每次抽奖落在哪里?份额如何收敛?
公平性 F 与作业长度(图 9.2)
步长调度:pass 值表逐步推进(图 9.3)
Linux CFS:vruntime、动态时间片与 nice 权重
| 作业 | nice | 权重 | 到达(醒来) | 运行时长 |
|---|
先预测再验证
看答案
看答案
看答案
看答案
看答案
动手实验:nice 值真的决定 CPU 份额吗?
labs/09-lottery/nice_share.c 创建两个子进程,把它们绑定到同一个 CPU(sched_setaffinity),给其中一个设置 nice 值(setpriority),然后两个进程都拼命做空循环计数若干秒。最后比较两者完成的循环次数之比,与 CFS 权重表算出的理论比例对照。
cd labs/09-lottery && make
./nice_share 0 5 3 # 子进程 1 nice=0,子进程 2 nice=5,运行 3 秒
./nice_share 5 10 3 # nice 差值同样是 5:比例应当几乎不变
sudo ./nice_share -5 0 3 # 负的 nice 需要 root 权限
示例输出(本机 WSL2 实测,你的机器上数字会不同):
$ ./nice_share 0 5 3
both children pinned to CPU 0, running 3 s
child 1: nice 0 weight 1024 loops 5841542322 share 75.5% (expected 75.3%)
child 2: nice 5 weight 335 loops 1894193102 share 24.5% (expected 24.7%)
$ ./nice_share 5 10 3
both children pinned to CPU 0, running 3 s
child 1: nice 5 weight 335 loops 3099903477 share 74.7% (expected 75.3%)
child 2: nice 10 weight 110 loops 1052312924 share 25.3% (expected 24.7%)
要观察的点:两个 CPU 密集型进程争同一个 CPU 时,份额非常接近 1024 : 335 ≈ 3 : 1。再试 ./nice_share 5 10 3,比例几乎一样——这正是"nice 差值恒定则比例不变"。注意:Linux 6.6 起默认调度器已换成 EEVDF,但它沿用了同一张 nice→权重表,按权重分配 CPU 的比例行为不变。
自测题
常见误区
- "彩票调度保证每个作业恰好得到它的比例。"它只在概率上(长期期望上)正确;短时间内可能明显偏离,如原书例子中 B 只得到 20% 而不是 25%。
- "步长 = 票数。"步长与票数成反比:票越多,步长越小,pass 增长越慢,越常被选中。
- "CFS 有固定的时间片。"CFS 的时间片是动态的:sched_latency 按权重分给当前可运行的进程,并受 min_granularity 下限约束,进程数一变时间片就变。
- "nice 值越大优先级越高。"相反,nice 越大(越"客气"),权重越小,得到的 CPU 越少。
- "比例份额调度器能解决所有调度问题。"它们与 I/O 配合不佳,并且把"该给多少票 / 设什么 nice"这个难题留给了用户。
本章小结
- 比例份额调度的目标是让每个作业获得一定百分比的 CPU,而不是优化周转或响应时间。
- 彩票调度:票数代表份额,随机抽奖决定谁运行;实现极简、无全局状态,但只是概率正确,短作业的公平性较差(图 9.2)。还有货币、转让、通胀等机制。
- 步长调度:步长 = 大数 / 票数,总是运行 pass 最小者;确定性且精确,但新作业的 pass 初值是个问题。
- Linux CFS:按 vruntime 选最小者,时间片由 sched_latency 与权重动态决定(不小于 min_granularity),nice 值通过权重表影响时间片和 vruntime 增速,用红黑树保证 O(log n),醒来的进程 vruntime 设为最小值以防饥饿。
- 下一章讨论多处理器调度:多个 CPU、缓存亲和性、单队列与多队列——Linux 的 O(1)、CFS、BFS 分别采用了哪种方式。