| 本篇位置 | 虚拟化 CPU 的最后一篇。从"排序"转到"按比例分配" |
| 运行环境 | 容器里的 Linux,Python 3。多数演示限定一个核:--cpuset-cpus=0 |
一、“公平"这个词不够用
一台机器上跑三个客户的服务。客户 A 付的钱是 B 的四倍,合同上写着他能用四倍的 CPU。
现在用第 6 篇的 MLFQ 来实现这句话。
你会发现你做不到。 MLFQ 只回答"下一个给谁”,它从来不回答"每人各拿百分之多少"。你可以把 A 放高一层——那 A 会把 B 饿死;靠规则 5 定期提升——那比例是多少?没人说得清。
这不是 MLFQ 没写好。是它压根不是为这个问题设计的。 优先级是个序关系,份额是个比例,两者不能互相换算。
云计算和容器把这个需求变成了硬要求。于是 Linux 从 2007 年起换了一套东西:CFS(Completely Fair Scheduler)。
打个比方
还是那家超市,但换了规则。
以前是排队——谁站前面谁先结。现在每人手里发一张计时卡:你被服务一分钟,卡上就记一分钟。收银员的规则只有一条:
谁卡上的数字最小,就服务谁。
不用排队,不用分层,不用定期提升。卡上数字最小的自动就是"被亏待最久的那个",服务他天然就是在补偿他。
而 VIP 呢?给他一张计费倍率不同的卡:他被服务一分钟,卡上只记 15 秒。于是他被服务的次数自然就多了四倍。
⭐ 不改排队规则,只改计费倍率,就实现了按比例分配。 这就是 CFS 的全部想法。
二、CFS:记账而不是排队
上面那张卡,Linux 里叫 vruntime(virtual runtime,虚拟运行时间)。
规则就两条:
- 一个进程实际跑了
Δt,它的 vruntime 增加Δt × (1024 / 它的权重)。 - 每次要选人的时候,选 vruntime 最小的那个。
1024 是 nice 值为 0 的权重。所以对一个普通进程,vruntime 就等于真实运行时间。而权重越大,同样的真实时间在卡上记得越少——它就会更频繁地被选中。
Linux 把所有可运行进程按 vruntime 放进一棵红黑树。插入和删除是 O(log n),而取最小值是 O(1)——内核专门缓存了那棵树的最左节点,选人的时候直接拿,一步都不用走。(顺带一提:这门课的数据结构与算法那一支里的红黑树,在这里有一个真实用途。)
⭐ 这个缓存值得留意:调度器每次切换都要"选人",但只在进程入队出队时才需要动树。 把最频繁的那个操作优化成 O(1),代价只是多存一个指针——这是这门课里"给热路径开小灶"的又一例。
跑一遍看看:把 vruntime 读出来
/proc/<pid>/sched 会把这些内部数字直接暴露出来。我们造两个一模一样的死循环,只有 nice 值不同:
# vruntime.py
import os, sys, time
def sched_field(pid, key):
for line in open(f"/proc/{pid}/sched"):
if line.startswith(key):
return float(line.split(":")[1])
return None
kids = {}
for name, nice in [("进程甲 nice=0 ", 0), ("进程乙 nice=19", 19)]:
pid = os.fork()
if pid == 0:
os.nice(nice)
x = 0
while True: x += 1
kids[name] = pid
for _ in range(3):
time.sleep(1.0)
for name, pid in kids.items():
print(f" {name} 已用 CPU {sched_field(pid,'se.sum_exec_runtime'):9.1f} ms "
f"虚拟时间 vruntime {sched_field(pid,'se.vruntime'):12.1f}")
print()
for p in kids.values():
os.kill(p, 9); os.waitpid(p, 0)
进程甲 nice=0 已用 CPU 985.9 ms 虚拟时间 vruntime 996.8
进程乙 nice=19 已用 CPU 16.9 ms 虚拟时间 vruntime 1133.2
进程甲 nice=0 已用 CPU 1973.4 ms 虚拟时间 vruntime 1984.4
进程乙 nice=19 已用 CPU 31.9 ms 虚拟时间 vruntime 2157.1
进程甲 nice=0 已用 CPU 2961.5 ms 虚拟时间 vruntime 2972.4
进程乙 nice=19 已用 CPU 43.9 ms 虚拟时间 vruntime 2976.3
盯着最后一组看:
- 真实用掉的 CPU:2961.5 ms 对 43.9 ms,差了 67 倍。
- 卡上记的虚拟时间:2972.4 对 2976.3,几乎一模一样。
⭐ CFS 拉平的从来不是真实时间,是虚拟时间。 两个进程在"被亏待程度"这个尺度上是相等的——只不过乙的计费倍率高得多。
算一下倍率对不对。从第一次采样到第三次:
- 甲:真实 +1975.6 ms,虚拟 +1975.6 → 倍率 1.00(权重 1024,
1024/1024 = 1) - 乙:真实 +27.0 ms,虚拟 +1843.1 → 倍率 68.3
而内核里 nice=19 的权重是 15,1024 / 15 = 68.27。
实测 68.3,公式 68.27。 这不是差不多,这是同一个数。
所以 nice 到底是什么
⚠️ 这里要纠正一个非常常见的误解:nice 不是"优先级",是权重。 相邻两个 nice 值的权重比大约是 1.25,所以:
- nice 差 1 → 份额差约 1.25 倍
- nice 差 5 → 份额差约 3 倍
- nice 差 19(0 vs 19)→ 份额差约 68 倍
而且它是相对的。系统里只有你一个进程时,你 nice 到 19 也照样独占 CPU——没有人跟你比,比例就没有意义。
三、时间片去哪了
CFS 里没有"时间片"这个固定值。它有的是两个参数:
- 目标延迟(
sched_latency):所有可运行进程都至少跑上一次的时间窗,默认几毫秒到几十毫秒。 - 最小粒度(
min_granularity):一个进程一旦跑起来,至少跑这么久才可能被换掉。
每个进程这一轮能跑多久 = 目标延迟 × 它的权重占比。
这就把第 5 篇那个旋钮自动化了:进程少的时候片长(切换开销小),进程多的时候片短(响应好)。但不会无限短下去——min_granularity 是地板,防止进程一多就被切换开销吃干净。
回到第 6 篇那个实测:一个核上 20 个死循环,交互型进程的唤醒延迟中位数是 0.05 毫秒。现在能解释了:那个进程一直在睡,它的 vruntime 几乎不涨;等它醒来,它是树上最小的那个,立刻就被选中。 它不需要任何"提升优先级"的规则——记账本身就带来了这个效果。
⭐ 这是 CFS 相对 MLFQ 最漂亮的地方:MLFQ 需要规则 5 那种周期性提升的"补丁"来防饥饿,CFS 不需要——被亏待的人 vruntime 自然最小,自然排在最前。
四、多核:一个队列是不行的
上面全部假设只有一个核。加到十个核,第一反应是"那就十个核共用一棵红黑树"。
这行不通。 每次调度都要改这棵树,十个核抢同一把锁——核越多越慢,最后加核反而减速。
所以真实做法是每个核一个自己的运行队列,各调度各的,互不加锁。
但这立刻带来新问题:队列会不平。0 号核上排了十个进程,7 号核空着。于是需要负载均衡——定期检查,把任务从忙的队列搬到闲的队列。
搬家不是免费的。一个进程在 0 号核上跑了一会儿,它的数据都在 0 号核的缓存里。搬到 7 号核,这些全作废,要从内存重新读。 这叫缓存亲和性(cache affinity)。
所以负载均衡是个两难:不搬,有的核闲着;搬了,搬过去的那个变慢。内核的做法是倾向于不搬,只在不平衡超过阈值时才动,而且优先在共享缓存的核之间搬。
⭐ 这就是第 1 篇那个"20 个进程为什么慢了 4 倍而不是 2 倍"的另一半答案。除了性能核和能效核不一样快,进程在核之间被搬来搬去,缓存反复变冷,也在贡献那一倍。
回到超市:多核就是开了十个收银台,每台自己排队。经理偶尔把长队匀一匀,但顾客换台要重新把整车商品搬过去——所以除非队伍差得离谱,不匀反而更快。
五、真正的份额保证:cgroup
nice 只能表达相对权重,表达不了"这个容器最多用 25%“这种硬承诺。这件事要靠 cgroup。
cgroup 提供两个完全不同的旋钮,混淆它们是生产事故的常见来源:
cpu.weight |
cpu.max |
|
|---|---|---|
| 语义 | 相对权重,机器闲时可以超用 | 硬上限,机器空着也不给你多用 |
| 机器空闲时 | 你能跑满 | 你只能用到额度 |
| 实现方式 | 就是 CFS 的权重 | 到额度就把你冻住 |
看 cpu.max 的实际效果。同样的活儿,只改额度:
docker run --rm --cpus=1.0 ... python3 quota.py # 墙上 0.80 秒
docker run --rm --cpus=0.5 ... python3 quota.py # 墙上 1.66 秒,实际占用 CPU 0.84 秒,份额 51%
限到一半,墙上时间正好翻倍,而真正消耗的 CPU 时间几乎没变(0.80 → 0.84 秒)。额度是精确执行的。
关键在它是怎么执行的。看内核的账本:
docker run --rm --cpus=0.25 ... bash -c 'python3 quota.py; cat /sys/fs/cgroup/cpu.stat'
nr_periods 171
nr_throttled 171
throttled_usec 12783175
171 个周期,171 个都被限流了,累计被冻住 12.78 秒。
⭐ 这就是 cpu.max 的真面目:它不是让你跑得慢,是让你跑一会儿、然后彻底停住、等下一个周期。 默认周期是 100 毫秒,额度 25% 意味着你跑 25 毫秒、被冻 75 毫秒。
⚠️ 这是 Kubernetes 里一个著名的坑:给容器设了 CPU limit,然后 p99 延迟出现规律性的尖刺。原因就在这儿——一个请求如果撞上被冻住的那 75 毫秒,它就得等到下一个周期。平均 CPU 使用率可能只有 10%,但延迟依然被打爆。
最后交代一件我自己测出来、但解释不了的事。在这台 Apple Silicon 上的虚拟机里,--cpus=0.25 那一档,同样的活儿消耗的 CPU 时间从 0.80 秒涨到了 4.30 秒。0.5 那一档完全正常(0.84 秒)。我没能确定这多出来的部分是什么——很可能是虚拟机的 vCPU 在被冻住期间被宿主机降频或换到了能效核,导致客户机记的"CPU 时间"含了水。这个数字我解释不了,所以不拿它论证任何事。 上面关于限流机制的结论,靠的是 cpu.stat 里那三行内核计数,那个不会骗人。
六、代价与取舍
CFS 换来了什么: 用一个记账模型同时解决了排序、防饥饿、按比例分配三件事,而且不需要 MLFQ 那一堆巫毒常量。交互式进程自动获得低延迟,不需要专门的规则。
它花了什么:
- 每次调度都要更新红黑树,O(log n)。进程很多时不便宜。
- 多核让整件事复杂了一个量级:负载均衡、缓存亲和、NUMA、性能核与能效核。这些的调优参数比 MLFQ 那些巫毒常量多得多。
它放弃了什么: CFS 完全不保证最坏情况延迟。 它保证的是长期比例,不是"我一定在 5 毫秒内跑上”。要那种保证得用实时调度类(SCHED_FIFO、SCHED_DEADLINE),它们优先级绝对高于所有 CFS 进程——所以第 4 篇那个演示里,普通用户不许设置它们。
另外,2023 年 Linux 6.6 用 EEVDF 替换了 CFS 的核心。 换的动机正是上面这条:CFS 只管长期比例,对"我需要低延迟"表达不了;EEVDF 给每个进程加了一个"期限"的概念,让延迟需求可以被表达。vruntime 记账的思想保留了下来,选人的规则变了。 这门课里凡是这种"至今还在换"的地方,我都会标出来——这是策略,不是机制。
七、小结
- MLFQ 只管排序,管不了比例。优先级是序关系,份额是比例,两者不能互换。
- CFS 不排队,它记账。 每个进程一个 vruntime,真实跑
Δt就加Δt × 1024/权重,永远选 vruntime 最小的。 - 实测:两个进程真实 CPU 用量差 67 倍,vruntime 几乎相同;虚拟时间的倍率实测 68.3,内核公式
1024/15 = 68.27——同一个数。 - ⚠️ nice 是权重不是优先级,差 1 约 1.25 倍,差 19 约 68 倍,而且是相对的。
- 记账天然防饥饿:睡了很久的进程 vruntime 最小,醒来立刻被选中。CFS 不需要 MLFQ 的规则 5。
- 多核必须每核一个队列,否则锁会把加核的收益吃光。代价是要做负载均衡,而搬家会让缓存变冷——这是第 1 篇那"多出来的一倍"的另一半原因。
cpu.weight是相对权重,cpu.max是硬上限。cpu.max靠冻住你来执行:25% 额度 = 跑 25 毫秒冻 75 毫秒,实测 171 个周期全部被限流。这是 Kubernetes 延迟尖刺的经典成因。- CFS 不保证最坏延迟。Linux 6.6 起换成了 EEVDF,动机正是这一条。
思考题
- 一个进程睡了一小时才醒来,它的 vruntime 远小于所有人。按规则它会一直跑到追平——那它岂不是能独占 CPU 一小时?内核必须做点什么,你猜是什么?
- 第五节那个 Kubernetes 尖刺:如果不许改 CPU limit,还有什么办法能减轻它?(提示:周期长度也是个可调参数。)
- 第四节说负载均衡倾向于不搬。那么一个进程被固定在一个核上(
taskset)总是更好吗?什么情况下更差? - 计时卡这个比方里,
cpu.weight是"计费倍率"。那cpu.max对应什么?想一个超市里真实存在的类似规则。