调度器:一秒钟该分给谁
一颗核心,一百个想跑的任务。调度器每秒要做上千次决定:下一个是谁?这一章讲 CFS 怎么用一个叫 vruntime 的虚拟时钟回答这个问题——而它的核心思想简单到只有一句话。
先看看老办法为什么不行
最直觉的调度方案是优先级队列:每个任务有个优先级,永远挑优先级最高的跑。
问题立刻就来:低优先级的任务会被饿死。只要高优先级的任务不停地想跑,低优先级的永远轮不到。你的后台备份任务可能一个月都没执行过一次。
传统 Unix 的补丁是动态优先级:跑得越久优先级降得越低,等得越久优先级升得越高。这确实能防饿死,但它引入了一堆魔法数字——降多少?升多快?多久重算一次?这些参数没有原则,全靠调,而且总有场景被调坏。Linux 2.6 时代的 O(1) 调度器就是这个路子,代码里塞满了各种启发式补丁,谁也说不清它为什么这么算。
Ingo Molnár 在 2007 年提出的 CFS(Completely Fair Scheduler)没有继续打补丁,而是换了个问法。
不问「谁的优先级最高」,改问:如果这台机器有无限多个核,每个任务都能独立地、按自己该得的速度往前走——那么现在,谁落后得最多?
让落后最多的那个上 CPU。就这一句话,没有魔法数字,没有启发式。
为了实现这个想法,CFS 给每个任务记一个数:vruntime(虚拟运行时间)。它不是真实时间,是「按你应得的速度折算过的时间」。
vruntime:一只走得快慢不同的表
核心公式只有一条:
// 任务跑了 delta 纳秒真实时间之后 vruntime += delta * NICE_0_WEIGHT / weight; // NICE_0_WEIGHT = 1024
读一下这个式子:
- 权重 等于 1024(nice 0):vruntime 和真实时间同速。
- 权重 大于 1024(nice 为负,高优先级):分母大,vruntime 走得慢。
- 权重 小于 1024(nice 为正,低优先级):分母小,vruntime 走得快。
而调度规则是:永远挑 vruntime 最小的那个。
于是高优先级任务的表走得慢,它的 vruntime 总是显得「落后」,就更容易被选中——它因此拿到更多的真实 CPU 时间。低优先级的表走得飞快,跑一点点就显得「超前」了,于是很久轮不到。
但注意:它的 vruntime 仍然在往前走,只是走得慢。所以只要别人也在推进,它迟早会变成最落后的那个。饿死问题从根上消失了——不是靠补丁,是靠这个数学结构本身。
下面这台调度器用的是 Linux 内核 sched_prio_to_weight[] 里一模一样的 40 个权重值,时间片和 vruntime 也是内核的公式。表格里「实际拿到」那一列是它跑完之后统计出来的,不是我填的。
请务必点开第四个预设「nice 差 1 到底差多少」,然后回来看下一节。
那张权重表,和一个精确的 25%
内核里有一张写死的表 sched_prio_to_weight[40],对应 nice 值 −20 到 +19:
/* kernel/sched/core.c,节选 */
const int sched_prio_to_weight[40] = {
/* -20 */ 88761, 71755, 56483, 46273, 36291,
/* -15 */ 29154, 23254, 18705, 14949, 11916,
/* -10 */ 9548, 7620, 6100, 4904, 3906,
/* -5 */ 3121, 2501, 1991, 1586, 1277,
/* 0 */ 1024, 820, 655, 526, 423, // ← nice 0 = 1024
/* 5 */ 335, 272, 215, 172, 137,
/* 10 */ 110, 87, 70, 56, 45,
/* 15 */ 36, 29, 23, 18, 15,
};
这些数字看起来很随意,其实有一个非常干净的规律:相邻两档之比约等于 1.25。
验算一下:1024 ÷ 820 = 1.249,820 ÷ 655 = 1.252,9548 ÷ 7620 = 1.253。整张表就是 1024 × 1.25^(-nice) 取整。
这个设计的用意是给 nice 一个明确的语义:
上面那台引擎跑「nice 差 1」那个预设,结果是 55.5% 对 44.5%——比值 1.249,和权重表算出来的一模一样。
所以 nice 不是一个模糊的「优先一点」,它是一个可以计算的份额:
- 差 1 档 → 1.25 倍
- 差 5 档 → 1.25⁵ ≈ 3 倍
- 差 10 档 → 1.25¹⁰ ≈ 9.3 倍(引擎里 nice +10 的备份任务只拿到 6.2%,两个 nice 0 的各拿 46.9%——正是这个量级)
下次你 nice -n 10 跑一个后台任务,你可以确切地说出它会拿到多少 CPU,而不是「应该会慢一些吧」。
时间片是算出来的,不是设定的
老式调度器给每个任务一个固定的时间片(比如 100 ms)。CFS 不这么干——时间片是从两个参数推出来的:
// 调度周期:这段时间内,所有可运行任务都该轮到一次 sched_latency = 24ms; // 可调:/proc/sys/kernel/sched_latency_ns // 你这一轮能跑多久 = 调度周期 × 你的权重占比 slice = sched_latency * weight / total_weight; // 但不能小于最小粒度,否则光切换就把时间吃光了 slice = max(slice, min_granularity); // 默认 3ms
这带来一个重要后果:任务越多,每人的时间片越短。3 个任务时每人 8 ms,8 个任务时每人 3 ms。
但到 3 ms 就停住了——那是 min_granularity。再多任务,内核不会继续切碎,而是把调度周期拉长。
为什么要有这个下限?因为切换本身要花钱。一次进程切换约 3 μs。如果时间片被切到 10 μs,那么 30% 的 CPU 都花在切换上了——「公平」得毫无意义。
你可以在上面那台引擎里看到这个账:切到「八个任务挤一个核」,看「光切换就烧掉」那个数字,再对比总时长。
调度器面对一个永恒的取舍:
- 时间片短 → 响应快(你的按键很快被处理),但切换频繁,吞吐低。
- 时间片长 → 吞吐高(缓存捂得热,切换少),但响应慢(你的按键要等)。
桌面系统偏向前者(sched_latency 小),服务器和计算集群偏向后者。这也是为什么很多发行版会提供 -lowlatency 和 -server 两种内核配置——同一套代码,参数取在曲线的不同点上。
CFS 之后:EEVDF
需要说清楚的是,CFS 在 Linux 6.6(2023 年)已经被 EEVDF 取代了。如果你的机器跑的是较新的内核,默认调度器已经不是严格意义上的 CFS 了。
EEVDF(Earliest Eligible Virtual Deadline First)保留了 vruntime 和权重的整套机制,但增加了一个虚拟截止时间的概念:每个任务除了「该拿多少」,还有一个「该多快拿到」的要求。这让延迟敏感的任务(比如音频、交互)可以在不提高其份额的前提下,更快地被调度到。
换句话说:CFS 只回答「分多少」,EEVDF 同时回答「分多少」和「多快分到」。前者是吞吐维度,后者是延迟维度。
但对这一章而言,核心思想没变:虚拟时钟、权重折算、挑最落后的那个。上面那台引擎实现的是经典 CFS,它依然是理解这套机制最好的模型,而 nice 与 1.25 倍的关系在 EEVDF 下同样成立。
CFS 的诞生有一段著名的公案。2007 年前后,澳大利亚的麻醉师 Con Kolivas(一位业余内核开发者)提出了 RSDL/SD「楼梯调度器」,在桌面交互性上明显好过当时的 O(1) 调度器,桌面用户社区一片叫好。
但它没有被合并。几个月后 Ingo Molnár 提交了 CFS,思路上受了 Kolivas 工作的影响,很快进了主线。Kolivas 因此离开了内核开发。
他后来又独立维护了 BFS(Brain Fuck Scheduler,名字就是态度)和 MuQSS,专门为桌面和低核数机器优化,至今仍有发行版在用。
这件事常被当作开源社区政治的案例,但也有技术上的实质:桌面交互性和服务器吞吐是两个不同的优化目标,而主线内核必须同时服务超算集群和树莓派。一个在四核桌面上表现优异的调度器,未必能在 256 核的服务器上扩展。这个张力至今存在。
「容器限了 0.5 核,到底是什么意思?」
Docker 的 --cpus=0.5 底下是 cgroup 的两个参数:
$ cat /sys/fs/cgroup/cpu.max 50000 100000 # 每 100 ms 的周期里,最多用 50 ms
注意这不是 nice,也不是权重——它是硬上限。用完了这 50 ms,你的任务会被强制暂停(throttle),一直等到下一个 100 ms 周期开始。
这带来一个非常隐蔽的性能问题,叫 CFS 限流抖动:假设你的服务平均只用 0.2 核,看起来远低于 0.5 的限额。但如果某个请求需要并行用 4 个线程各跑 20 ms,那就是 80 ms 的 CPU 时间,一下子超了 50 ms 的配额——整个进程被冻住 50 ms。
表现出来就是:平均延迟很漂亮,P99 却有一堆几十毫秒的尖刺,而 CPU 使用率图上什么都看不出来。
诊断方法是看这个文件里的 nr_throttled 和 throttled_usec:
$ cat /sys/fs/cgroup/cpu.stat
如果 nr_throttled 在涨,就是它。解法通常是放宽配额或者减少并行度(比如把线程池调小、把 JVM 的 ActiveProcessorCount 设对)。第 22 章会讲为什么 JVM 常常数错核数。
这一章的一句话
CFS 不问「谁优先级最高」,而问「谁落后得最多」——用一只按权重走快慢的虚拟表,把公平变成一个可计算的量,顺便让饿死问题从数学上消失。而 nice 每差一档,精确地就是 25%。
下一章:调度器每做一次决定,就要付一次切换的钱。那笔钱到底有多贵?答案会比你以为的大得多,因为大头是你看不见的。