本篇位置 虚拟化 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,虚拟运行时间)。

规则就两条:

  1. 一个进程实际跑了 Δt,它的 vruntime 增加 Δt × (1024 / 它的权重)
  2. 每次要选人的时候,选 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 的权重是 151024 / 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_FIFOSCHED_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,动机正是这一条。

思考题

  1. 一个进程睡了一小时才醒来,它的 vruntime 远小于所有人。按规则它会一直跑到追平——那它岂不是能独占 CPU 一小时?内核必须做点什么,你猜是什么?
  2. 第五节那个 Kubernetes 尖刺:如果不许改 CPU limit,还有什么办法能减轻它?(提示:周期长度也是个可调参数。)
  3. 第四节说负载均衡倾向于不搬。那么一个进程被固定在一个核上(taskset)总是更好吗?什么情况下更差?
  4. 计时卡这个比方里,cpu.weight 是"计费倍率"。那 cpu.max 对应什么?想一个超市里真实存在的类似规则。

延伸