从 CFS 到 EEVDF:一次不容易察觉的调度器换代
很多人的服务器还在跑 4.19 或 5.10,而内核社区已经悄悄换掉了 CFS 的调度内核。Linux 6.6 合入了 Peter Zijlstra 的 EEVDF 调度器,作为 fair 类的新实现。它仍然叫做 CFS 吗?严格说,不再叫完全公平,但调度文档里仍保留 fair_sched_class 的概念,很多日志也沿用 CFS 相关字段。

真正重要的不是名字,而是选择下一个运行任务的逻辑变了。如果你维护的是多线程服务、容器混部环境,或者对请求延迟比较敏感,这个问题值得认真理解。
CFS 的公平模型遇到了什么挑战?
CFS 的核心思路很简单:每个任务维护一个 vruntime,等于它的实际运行时间除以权重。调度器每次选择 vruntime 最小的任务运行。这个模型从 2.6.23 进入内核,之后十几年一直表现稳定。
但它有一个结构性问题:vruntime 只是历史记录,没有回答一个关键问题:这个任务应该在什么时候被调度一次?CFS 通过唤醒抢占机制、sched_min_granularity 和 wakeup_granularity 等参数来模拟“延迟预期”,可一旦任务形态变化,参数就得跟着调。
一个常见场景是混合负载:在线服务线程经常睡眠唤醒,离线分析任务几乎不睡眠。在 CFS 下,睡眠任务唤醒后,如果 vruntime 落后太多,会立刻抢占 CPU,导致离线任务的进度被打断;反过来如果补偿太保守,在线线程又会出现明显卡顿。很多团队遇到“CPU 没跑满但业务延迟抖动”的问题,根子经常就在这里。
在多租户数据平台里,白天跑交互查询,晚上跑批量计算,这种反差尤其明显。为了迁就在线任务,不少运维会改参数把唤醒抢占调得更激进,然后批量任务完成时间就变得不稳定。这类问题本质上是 CFS 缺乏对“下一次调度时刻”的保证。
EEVDF 做了什么改变?
EEVDF 是 Earliest Eligible Virtual Deadline First 的缩写,直接说就是“最早可用虚拟截止时间优先”。它的整体框架仍然是按权重分配时间,但每个任务多了一个虚拟截止时间。调度器不再是找一个 vruntime 最小的任务,而是先判断哪些任务有资格被选择,再从这些任务里挑一个虚拟截止时间最早的。
这里的关键词是 eligible。一个任务并不是永远都有资格运行,只有它的虚拟时间达到某个阈值之后才具备资格。这个设计和实时调度里的时间约束不一样,它是在公平队列基础上,把时间窗口的概念显式地放进了选择逻辑。
如果用伪代码表示一次调度决策,会比纯文字解释更直观:
for each task t in runqueue:
if t.virtual_time < t.eligible_time:
continue # 该任务还没到可调度时间
candidates.append(t)
select task with min virtual_deadline from candidates
注意,这只是剔除细节的骨架。真实的实现还要考虑调度实体之间的父子关系、cgroup 权重、唤醒预判等,但决策主体就是这个逻辑。
lag、权重和虚拟时钟之间的平衡
EEVDF 引入了一个非常重要的维护量,叫 lag,也就是“滞后”。简单理解,每个任务在虚拟时间轴上有一个“应该处于的位置”和“实际处于的位置”之差。当一个任务被换出、sleep、或者权重被修改时,系统会计算 lag,并补偿到后续的调度决策中。
为什么需要 lag?因为调度器的公平性不能只看某一瞬间,而是要看一个时间窗口内是否满足加权比例。EEVDF 通过维护 lag,可以把历史偏差“存”下来,在之后的一段时间内逐步修正。这在任务权重变化比较频繁的容器场景中尤其重要。
这也引出一个常见误区:EEVDF 是不是会绝对保证延迟?不是。EEVDF 依然是公平类调度器,它保证的是虚拟时间在窗口内对齐权重比例,而不是每个任务的响应时间上限。它只是把很多以往靠启发式参数完成的事情,收拢到了虚拟截止时间这个统一的概念里。
如果你只是运维普通服务器,不需要记住 EEVDF 的公式,但建议记住一个点:它把“公平”从长时平均转向了窗口内可预期,这是和 CFS 最本质的差别。
我经常看到三个误读:
- 误读一:EEVDF 是实时调度算法,会大幅降低延迟。实际上它仍然是 CFS 的公平模型,只是选择了更可预期的调度点。
- 误读二:升级到 6.6 之后调度行为完全不变。事实是,短任务、频繁唤醒型负载的行为会有肉眼可见差异,需要重新观测。
- 误读三:EEVDF 既然更公平,就不需要调参。公平性不会代替资源分配策略,控制组权重、亲和性仍然影响巨大。
EEVDF 与旧 CFS 的关键区别到底在哪?
| 维度 | 旧 CFS | EEVDF |
|---|---|---|
| 选择依据 | vruntime 最小 | 满足 eligible 且 virtual deadline 最小 |
| 调度时机 | 根据 tick 和唤醒判断抢占 | 有显式的虚拟时间窗口,触发点更明确 |
| 公平追踪 | vruntime 直接反映历史消耗 | 通过 lag 记录偏差并在窗口内修正 |
| 可调参数 | 多个 granularity 参数强耦合 | sched_base_slice 等配置更集中 |
| 对突发短任务 | 依赖参数补偿,容易过冲 | eligible + deadline 双重过滤,行为更可预测 |
这张表并不是说 EEVDF 一定比 CFS 快,而是它在公平性和响应性之间多了一个可控变量。对于调度器调试,这比过去黑盒式的启发式参数好解释得多。
升级到 Linux 6.6 之后,实际要注意什么?
如果你的服务已经跑在 6.6+ 内核上,默认参数下大部分场景都可以平稳切换。但以下几类工作负载值得花时间观察:
- 高并发的短任务线程池:EEVDF 对唤醒后的调度点更精确,线程池模式下的尾延迟分布通常更稳定,但如果 base_slice 太小会导致上下文切换成本上升。
- CPU cgroup 内的权重限制:旧版本里,cgroup 权重变化容易造成 vruntime 跳变,新算法通过 lag 补偿,短期分配更平滑,但跨 cgroup 的优先级效果依旧依赖权重配置。
- NUMA 环境:EEVDF 替换的是选择逻辑,并不改变负载均衡与 NUMA 迁移框架。如果你在做多节点性能调优,不要指望调度器替换能解决跨 node 访问问题。
一个建议是,先用默认参数运行一段时间,再用内核提供的统计接口观察调度延迟。下面这条命令可以查看当前 base_slice 的默认值:
cat /proc/sys/kernel/sched_base_slice
它默认输出的是纳秒单位,比如 3000000 就表示 3ms。如果遇到长尾延迟,可以尝试把 base_slice 调小,比如 2ms:
echo 2000000 > /proc/sys/kernel/sched_base_slice
注意,这是一个全局参数,调整会影响整个系统的抢占粒度。不要为了某一个服务盲目调低,最好在测试环境评估上下文切换开销和吞吐变化后再上生产。
EEVDF 也不是终点
从 6.6 到 6.7、6.8,EEVDF 本身也经历过一些修正。比如对 group entity 的处理、对 idle 时间补偿的优化,都是在社区里反复讨论后才稳定的。这说明调度器从来不是一次重写就能一劳永逸。
站在应用开发者角度,理解 EEVDF 的意义不在于立刻去改代码,而在于当调度行为出现变化时,你知道该往哪排查。CPU 使用率、负载、上下文切换和延迟分布,这些指标之间的因果关系会因为调度算法的改变而发生变化。
怎么把它用在你的系统里?
我的建议是从小范围开始:先用 6.6+ 内核跑一批非核心业务,比较 CPU 延迟和调度切换统计。如果服务形态是请求量大、任务碎片化,EEVDF 可能会带来尾延迟改善;如果是长时间高负载计算,可能看不到明显差别。调优时优先控制 base_slice,接着关注 nice/cgroup 权重,而不是一上来就改一大堆 sysctl。
公平并不是一个简单的数学定义,而是业务目标在调度器上的投影。EEVDF 让这个投影更清晰,但最终怎么映射业务,仍然要由你来决定。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/972/