开始前先说点题外话。我读 CFS 代码这些年,真正让我觉得“噢原来调度器是这么被拧紧的”,不是红黑树的旋转,也不是各种 load 统计,而是min_vruntime这个看似只有几行的字段。我见过很多人面试被问“新进程 vruntime 为什么不是 0”“vruntime 会溢出吗”,答得模模糊糊。这篇文章我就把这两个问题彻底拆开:min_vruntime到底怎么工作、怎么防溢出、怎么决定新任务起点,以及怎么在真实系统里验证。适合内核爱好者、做性能调优的工程师,以及准备 Linux 调度器相关面试的人。
1. 先搞懂 vruntime:CFS 的公平账本怎么记账
1.1 为什么需要虚拟运行时间
CFS 的目标是让每个任务在理想情况下获得与它的权重成比例的 CPU 时间。但 CPU 只有一个,任务在排队,如果直接按“真实运行时间”排序,那权重高的任务并没有任何优势,公平就变成平均主义了。所以 CFS 引入了一个间接层:vruntime,虚拟运行时间。
每次时钟 tick 或者其他调度点触发update_curr()时,CFS 会把当前任务本次实际运行的时间delta_exec按权重折算后累加到se->vruntime上:
static void update_curr(struct cfs_rq *cfs_rq) { struct sched_entity *curr = cfs_rq->curr; u64 now = rq_clock_task(rq_of(cfs_rq)); u64 delta_exec; if (unlikely(!curr)) return; delta_exec = now - curr->exec_start; if (unlikely((s64)delta_exec <= 0)) return; curr->exec_start = now; curr->sum_exec_runtime += delta_exec; curr->vruntime += calc_delta_fair(delta_exec, curr); update_min_vruntime(cfs_rq); }关键在calc_delta_fair():
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se) { if (unlikely(se->load.weight != NICE_0_LOAD)) delta = __calc_delta(delta, NICE_0_LOAD, &se->load); return delta; }NICE_0_LOAD是 nice 0 对应的权重,数值是 1024。权重高的任务,同样跑 1ms 真实时间,折算出的 vruntime 增量反而更小;权重低的任务,vruntime 涨得飞快。这样 CFS 把“好刀用在刀刃上”变成了一种可量化的账本:vruntime 小 = 欠了 CPU 时间 = 应该优先被调度。
1.2 一个表格看懂权重对 vruntime 的影响
很多文章只给公式,不给感觉。我直接列几个典型 nice 值对应的权重和 vruntime 增速关系:
| nice 值 | 权重 weight | 相同真实运行时间下的 vruntime 增量系数 |
|---|---|---|
| -20 | 88761 | 约 0.0115 倍 |
| -10 | 772 | 约 1.33 倍 |
| 0 | 1024 | 1 倍(基准) |
| 10 | 110 | 约 9.31 倍 |
| 19 | 15 | 约 68.3 倍 |
增量系数就是NICE_0_LOAD / weight。换句话说,nice 19 的任务跑 1 毫秒,账本上记了约 68 毫秒的“虚拟账”;nice -20 的任务跑 87 毫秒,账本上才记 1 毫秒。调度器每次都挑 vruntime 最小的任务跑,所以权重高的任务看起来“总是有资格跑”,这就是公平的量化实现。
这里顺带说一个很多人忽略的点:vruntime 累计的是“虚拟时间”,它跟墙上时钟差很远。你通过/proc/<pid>/schedstat看到的sum_exec_runtime是真实运行时间,不是 vruntime。这两个字段别搞混。
2. 溢出问题的真实形态:不是跑满 584 年,是比较穿越了
2.1 entity_before 与有符号差值
vruntime的类型是u64,单位是纳秒。u64 最大值大约是 1844 亿亿纳秒,换算成年份大概是 584 年。单个进程要连续跑 584 年才会让计数器本身归零,这在工程上基本不可能。所以如果面试官问“vruntime 会溢出吗”,单纯回答“会,但 584 年才溢出”是不够的,甚至不准确。
真正危险的溢出发生在比较和排序环节。调度器经常要判断“谁的 vruntime 更小”,比如选择下一个运行任务、插入红黑树、做负载均衡。如果直接比较两个 u64:
if (a->vruntime < b->vruntime)当两个值跨越 U64_MAX 回绕点的时候,比较结果就是错的。为了避免这个,内核里判断先后顺序用的是差值:
static inline bool entity_before(struct sched_entity *a, struct sched_entity *b) { return (s64)(a->vruntime - b->vruntime) < 0; }a->vruntime - b->vruntime在 C 里是无符号减法,但结果被强制转换成s64。只要两个 vruntime 的真实差距不超过2^63,这个差值就能正确表达大小关系,哪怕其中一个值刚从 0 附近回绕过。这其实就是用有符号数来处理无符号回绕的经典技巧。
我补充一个直觉类比:你手上有一个只能显示 0 到 99 的计数器,转一圈回到 0。要判断 3 和 98 谁“更新”,直接比较数字会得出 3 < 98,但如果用“两者的差,且差值在 ±50 内时数字大的是新值”,3 和 98 的差是 -95,明显超过半圈,说明 98 其实是更老的,3 绕回来更新。内核里的(s64)判断就是这个思路。
2.2 红黑树为什么需要相对 key
CFS 把可运行任务放在一棵以 vruntime 为排序键的红黑树里,最左节点就是下一个要跑的进程。但插入树里的 key 并不是 vruntime 绝对值,而是相对min_vruntime的偏移:
static inline s64 entity_key(struct cfs_rq *cfs_rq, struct sched_entity *se) { return (s64)(se->vruntime - cfs_rq->min_vruntime); }所有节点的 key 都是“我的 vruntime 减去队列最小值”,这个值被压缩在一个很小的范围内(通常就是几个调度周期的量级),远小于 s64 的一半。即使某个任务的 vruntime 绝对值恰好处于回绕边界附近,减掉min_vruntime之后,树内节点之间的相对顺序依然正确。
这就是min_vruntime的深层价值之一:它不只是记录“最小运行时间”,它还是整棵红黑树的坐标原点。有了这个原点,树里的比较全部变成小范围相对量比较,从根上规避了大数回绕导致的排序错乱。
注意:如果
min_vruntime严重滞后,导致某个任务 vruntime 与它的差值超过 2^63,s64 还是会失效。但内核保证min_vruntime总是跟随队列中最左节点的 vruntime 推进,正常情况下永远不会出现这种量级的差距。
3. min_vruntime 的更新细节,逐行读代码
3.1 update_min_vruntime 是怎么工作的
min_vruntime的更新集中在update_min_vruntime(),我先贴出核心逻辑:
static void update_min_vruntime(struct cfs_rq *cfs_rq) { u64 vruntime = cfs_rq->min_vruntime; if (cfs_rq->curr) vruntime = cfs_rq->curr->vruntime; if (cfs_rq->rb_leftmost) { struct sched_entity *se = rb_entry(cfs_rq->rb_leftmost, struct sched_entity, run_node); if (!cfs_rq->curr || vruntime < se->vruntime) vruntime = se->vruntime; } /* ensure we never gain time by being placed backwards. */ cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime); #ifdef CONFIG_64BIT smp_wmb(); #endif cfs_rq->min_vruntime_copy = cfs_rq->min_vruntime; }一步步拆。第一步默认取当前运行任务cfs_rq->curr的 vruntime;第二步看红黑树最左节点的 vruntime,如果它更大,就取它。这里有个反直觉点:按“最小”二字,为什么不是取“更小”的那个?
因为min_vruntime的语义不是“树里最小的 vruntime”,而是“CFS 认可的公平时间基线”。如果当前任务 vruntime 已经比左节点还小,说明当前任务落后更多、理应继续跑,此时如果硬把min_vruntime压到左节点的值,会让“欠时间最多的任务”和“基线”的差距被抹平,反而破坏公平。所以这一步是向右取:min_vruntime可以比当前任务的 vruntime 大,但不能被当前任务拖低。
最后一步用max_vruntime()做保护,防止min_vruntime倒退,这个下一节重点讲。
3.2 max_vruntime 保护的含义:min_vruntime 不能倒退
max_vruntime()的实现也很短:
static inline u64 max_vruntime(u64 max_vruntime, u64 vruntime) { s64 delta = (s64)(max_vruntime - vruntime); if (delta < 0) max_vruntime = vruntime; return max_vruntime; }注意这个函数名有迷惑性。它不只是取两个 u64 的最大值,而是用s64做环绕安全的“取较大者”:当max_vruntime - vruntime为负数,说明新值比旧值大,更新;如果差值为正,说明新值比旧值小,保留旧值;如果差值跨越了 s64 边界,它也会给出一个合理的方向。
所以cfs_rq->min_vruntime = max_vruntime(cfs_rq->min_vruntime, vruntime)这句话的意思是:min_vruntime只能往前走,不能往后退。这非常重要,因为min_vruntime是所有新任务、唤醒任务、迁移任务的“落脚参考点”。如果它倒退,新任务可能被放到一个比老任务落后特别多的位置,相当于给新任务发了一笔“时间债”的红利,它会长时间霸占 CPU。
内核注释写得很直白:ensure we never gain time by being placed backwards。翻译成人话就是:我们不能通过把一个任务放在比基线更早的位置,让系统“凭空获得时间”。这是公平性的底线。
我见过有些分析文章把这一步解释成“保证 min_vruntime 单调递增”,严格说不够准确,回绕边界上它允许从高位跳到低位,但它保证同一个任务不能被放置到自己的过去。理解到这一层,才算真正读懂了这几行代码。
3.3 边界情况:空队列、睡眠唤醒、负载均衡
只读懂函数体还不够,还要知道它在哪些路径被调用,以及队列为空时会发生什么。
update_min_vruntime()在update_curr()、enqueue_entity()、dequeue_entity()、entity_tick()等路径都会触发。最核心的场景是当前任务运行期间周期性更新:每次 tick 都会调用update_curr(),于是min_vruntime跟随着调度周期的节奏慢慢推进。
空队列是一个容易被忽略的边界。当可运行队列空了,cfs_rq->curr通常不是 NULL(当前任务还在运行),但rb_leftmost是 NULL。这时vruntime只会取自curr->vruntime,然后max_vruntime保底。所以即使树是空的,min_vruntime也会跟随当前任务缓慢前移。
但如果连curr都是 NULL(比如 CPU 上的调度实体已经切走),min_vruntime就不会被更新。这会带来一个隐患:长时间没有任务运行的 CPU,它的cfs_rq->min_vruntime停留在旧值。等任务迁移过来时,新任务的 vruntime 被垫到一个很旧的时间点,相当于让它一上来就“欠了很多时间”,从而可能长时间占用 CPU。所以内核在idle_balance()等路径里也会主动推进空运行队列的min_vruntime,避免跨 CPU 迁移时出现这种不公平。实际阅读代码时,你可以关注cpu_load相关的回调里对空队列的处理,这是一个非常值得看的补充细节。
4. 新任务初始化:vruntime 从 0 开始是最坏方案
4.1 fork 路径上的 vruntime 赋值
先看sched_fork()和__sched_fork()。新任务在__sched_fork()里被初始化:
static void __sched_fork(unsigned long clone_flags, struct task_struct *p) { ... p->se.vruntime = 0; ... }注意,这里是 0,不是min_vruntime。所以新任务的 vruntime 起步确实是 0。但为什么最终运行红黑树时,新任务不会以 0 直接插入?因为真正入队前还有一个关键函数task_fork_fair():
static void task_fork_fair(struct task_struct *p) { struct cfs_rq *cfs_rq; struct sched_entity *se = &p->se, *curr; ... cfs_rq = task_cfs_rq(current); curr = cfs_rq->curr; if (curr) { update_curr(cfs_rq); se->vruntime = curr->vruntime; } place_entity(cfs_rq, se, 1); ... }新进程的 vruntime 会先继承当前进程(父进程所在 CPU 上正在运行的任务)的 vruntime,再交给place_entity()做最终修正。这个“继承”是为了让子进程大致从父进程的“时间位置”开始,不会因为 fork 而凭空获得一个超前或落后的起点。
4.2 place_entity 的垫底逻辑
place_entity()是理解整个问题的最后一环。旧版本内核里的实现非常直观:
static void place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial) { u64 vruntime = cfs_rq->min_vruntime; if (initial && sched_feat(ATTACH_AGE_LOAD)) vruntime -= se->vruntime; if (sched_feat(CACHE_HOT_BUDDY) && !initial) { if (se->vruntime < vruntime) vruntime -= sysctl_sched_min_granularity; else vruntime += sysctl_sched_min_granularity; } se->vruntime = max_vruntime(se->vruntime, vruntime); }对于新任务(initial=1),最终一句se->vruntime = max_vruntime(se->vruntime, vruntime)是关键:新任务的 vruntime 被抬高到“至少不低于cfs_rq->min_vruntime”的位置。
结合前面的继承逻辑:子进程先继承了父进程的 vruntime,但父进程 vruntime 可能比当前队列最小值还低(说明父进程已经很“欠跑”了)。这时max_vruntime会用min_vruntime兜底,让子进程的起点不低于整棵树的公平基线。
为什么这一步这么重要?你可以设想一个没有place_entity()的系统:每个 fork 出来的进程 vruntime 都是 0。系统运行一段时间后,所有老任务的 vruntime 可能已经累计到几千万甚至几十亿纳秒,此时新进程以 0 入队,直接成为红黑树最左节点,立刻抢占当前任务。更糟的是,它会一直排在前面,直到它的 vruntime 追上老任务。这种“新进程风暴”会造成剧烈的调度延迟毛刺,甚至让交互程序长时间卡顿。min_vruntime就是给新任务画了一条“公平地平线”,告诉它:别从远古时代开始排队,从当前的底线开始。
新版内核里place_entity()引入了avg_vruntime和lag的概念,代码复杂不少,但核心思想没变:新任务不能直接落在 0,要以队列当前公平基线为参考,并且要通过max_vruntime式的保护避免落到“过去”。
4.3 对调度延迟的实际影响与 sched_child_runs_first
理解了新任务起点逻辑,你就能解释很多实际现象。比如:为什么 fork 出一个大任务,它不会立刻把当前交互任务踹走?因为新任务的 vruntime 被放在min_vruntime附近,大概率落在红黑树中间或偏右位置,当前任务仍然是左节点,可以继续运行。只有当前任务的时间片用完或者主动睡眠,调度器才会切到新任务。这个“延迟抢占”的设计极大保护了交互式任务的响应性。
还有一个小开关值得提:/proc/sys/kernel/sched_child_runs_first。它控制 fork 后是否让子进程先运行。默认值是 0,也就是不强制子进程优先;改成 1 后,内核会尝试把子进程放在更靠左、更容易被选中的位置。这个选项依赖的基础仍然是place_entity()之后做的“提前调度”处理,不是简单把 vruntime 置成最小值。它是观察“vruntime 放置影响行为”的一个很直观的开关,我建议你在虚拟机里改一改,对比看perf sched的差异。
实操时不要在生产环境全局改
sched_child_runs_first,它会影响一大批应用的 fork 行为。想实验,用容器或者专门的测试机,改完观察完就恢复。
5. 实操:在真实 Linux 上观察这些字段
5.1 用 bpftrace 看 min_vruntime 和 vruntime
理论说再多,不如实际看一眼。现在主流发行版的内核都带 BTF,可以直接用 bpftrace 挂update_curr观察:
bpftrace -e 'kprobe:update_curr { $cfs = (struct cfs_rq *)arg0; if ($cfs->curr != 0) { printf("cpu=%d min_vruntime=%llu curr_vruntime=%llu delta=%lld\n", cpu, $cfs->min_vruntime, $cfs->curr->vruntime, (int64)($cfs->curr->vruntime - $cfs->min_vruntime)); } }'输出会类似:
cpu=2 min_vruntime=120355263311 curr_vruntime=120367122048 delta=11858737 cpu=2 min_vruntime=120355263311 curr_vruntime=120378419909 delta=23156598能看到两个关键现象:第一,min_vruntime变化比curr_vruntime平滑,它不会频繁跳跃;第二,当前任务的 vruntime 通常大于等于min_vruntime,差距就是该任务“已经领先基线”的程度。
如果机器上没有 bpftrace,也可以用内核提供的 tracepoint 做近似观察:
trace-cmd record -e sched_switch -e sched_wakeup trace-cmd reporttrace-cmd不直接给出 vruntime,但结合perf sched timehist:
perf sched timehist -s可以间接看到等待时间和运行时间的分布,配合前面的 bpftrace 就能把“理论字段”和“宏观调度行为”对应起来。
5.2 验证新任务不会直接抢占的小实验
你可以用一个小实验直观验证新任务放置逻辑。写一个高优先级交互进程,比如不断 sleep 1ms 再 wakeup,记录它的调度延迟;另一个循环里反复 fork 并立刻退出子进程。如果 wruntime 初始化逻辑失效,你会看到交互进程 wakeup 延迟出现尖刺。
简化验证步骤:
# 终端1:跑一个周期任务,统计调度延迟 cyclictest -p 95 -i 1000 -l 100000 # 终端2:疯狂 fork,制造新任务风暴 for i in $(seq 1 100000); do /bin/true; done正常情况下,fork 风暴不会让 cyclictest 的 max 延迟出现灾难性增大。如果把sched_child_runs_first改成 1:
sysctl -w kernel.sched_child_runs_first=1再跑同样的压力,你会看到延迟明显变差。这个对比实验非常能说明“min_vruntime 垫底”和“子进程优先”这两个策略对交互延迟的不同影响。测完记得改回来:
sysctl -w kernel.sched_child_runs_first=0我经常用这个实验给团队里新人演示:调度器不是玄学,每个设计决策都能落到可测量的延迟数字上。
6. 常见问题速查与面试避坑
6.1 高频问题简答
我整理了一张速查表,基本覆盖面试里关于min_vruntime的高频问题:
| 问题 | 核心答案 |
|---|---|
| vruntime 会溢出吗? | 单个计数器要 584 年才回绕,真正要防的是比较和排序时的回绕错误 |
| 新任务 vruntime 为什么不是 0? | 继承父任务 vruntime 后经 place_entity 垫到 min_vruntime 之上,避免新任务抢占老任务 |
| min_vruntime 会倒退吗? | 不会,max_vruntime 保护保证它不会因任务放置而回退 |
| 空队列时 min_vruntime 还更新吗? | 当前任务还在运行时会跟随 curr 推进;完全空队列在负载均衡路径有单独处理 |
| 权重对 vruntime 的影响? | calc_delta_fair 按 NICE_0_LOAD / weight 折算,权重越高 vruntime 增长越慢 |
| 红黑树排序键是什么? | se->vruntime 减去 cfs_rq->min_vruntime 后的相对值,用 s64 保存 |
面试时如果被追问“min_vruntime 取 curr 和左节点哪个”,千万不要背答案,要把逻辑讲清楚:先默认取 curr,因为是当前正在跑的、最应该获得公平基线;再看左节点,如果左节点更靠后,就向右移动到左节点的位置,保证基线不会落后于候选任务。这样既体现了代码理解,又体现了对这个字段语义的把握。
6.2 我踩过的几个坑
最后分享几个我实际踩过的坑。
第一个坑是调试时直接打印se->vruntime和min_vruntime,却没有注意单位。CFS 内部用 ns,但/proc/schedstat和一些 trace 工具输出的字段单位可能已经转成了 us 甚至 ms,放在一起对比容易得出完全错误的结论。建议统一用 bpftrace 直接读内核字段,不要混合不同来源的数据。
第二个坑是修改sysctl_sched_min_granularity做实验时,以为它只影响调度周期,其实它还参与place_entity()的CACHE_HOT_BUDDY逻辑,会影响唤醒任务的 vruntime 偏移。我一度在测试里把 min_granularity 调得很小,结果唤醒任务频繁抢占,延迟分布变得很奇怪。所以调参前先想清楚这个参数到底管了几条路径。
第三个坑是在多核系统上看min_vruntime,每个 CPU 的cfs_rq是独立的,字段值不相等是正常的。跨 CPU 迁移时,任务 vruntime 会根据目标队列的min_vruntime做修正,所以你看到迁移后的进程 vruntime 突变不要惊讶,这是负载均衡逻辑在正常工作。不要拿两个 CPU 的min_vruntime直接比较来判断“哪个 CPU 更忙”,要用load_avg或nr_running。
最后一个建议:阅读这些代码时,一定把struct cfs_rq、struct sched_entity的定义打开放在旁边,把函数调用链画在纸上。CFS 的坑往往不在单点逻辑,而在多个函数之间的隐式约束。min_vruntime就是一个典型的“牵一发动全身”字段,你把它的更新路径、放置路径、回绕保护一起看,调度器的一半骨架就清楚了。