开始前先说点题外话。我读 CFS 代码这些年真正让我觉得“噢原来调度器是这么被拧紧的”不是红黑树的旋转也不是各种 load 统计而是min_vruntime这个看似只有几行的字段。我见过很多人面试被问“新进程 vruntime 为什么不是 0”“vruntime 会溢出吗”答得模模糊糊。这篇文章我就把这两个问题彻底拆开min_vruntime到底怎么工作、怎么防溢出、怎么决定新任务起点以及怎么在真实系统里验证。适合内核爱好者、做性能调优的工程师以及准备 Linux 调度器相关面试的人。1. 先搞懂 vruntimeCFS 的公平账本怎么记账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 增量系数-2088761约 0.0115 倍-10772约 1.33 倍010241 倍基准10110约 9.31 倍1915约 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 更小”比如选择下一个运行任务、插入红黑树、做负载均衡。如果直接比较两个 u64if (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 红黑树为什么需要相对 keyCFS 把可运行任务放在一棵以 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^63s64 还是会失效。但内核保证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); }对于新任务initial1最终一句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)); } }输出会类似cpu2 min_vruntime120355263311 curr_vruntime120367122048 delta11858737 cpu2 min_vruntime120355263311 curr_vruntime120378419909 delta23156598能看到两个关键现象第一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 timehistperf 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改成 1sysctl -w kernel.sched_child_runs_first1再跑同样的压力你会看到延迟明显变差。这个对比实验非常能说明“min_vruntime 垫底”和“子进程优先”这两个策略对交互延迟的不同影响。测完记得改回来sysctl -w kernel.sched_child_runs_first0我经常用这个实验给团队里新人演示调度器不是玄学每个设计决策都能落到可测量的延迟数字上。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就是一个典型的“牵一发动全身”字段你把它的更新路径、放置路径、回绕保护一起看调度器的一半骨架就清楚了。