EEVDF取代CFS?Linux内核调度器这30年到底在卷什么

📅 2026/7/3 18:32:01 👁️ 阅读次数 📝 编程学习
EEVDF取代CFS?Linux内核调度器这30年到底在卷什么

EEVDF取代CFS?Linux内核调度器这30年到底在卷什么

2024年Linux 6.6合入EEVDF调度器时,Linus Torvalds在MAILING LIST上说了句:“I’m not entirely happy, but let’s merge it.” 这么一个内核调度器的"换代"事件,外面没几个人注意到。今天我把它扒干净。

一、从O(n)到O(1)到CFS到EEVDF——一条timeline

不想讲无聊的历史课,但4个关键节点能帮你理解"为什么又要换":

1992: Linux 0.01 —— round-robin,每个进程轮流跑一个tick
2002: Linux 2.4 —— O(n)调度器,每次调度遍历所有进程找优先级最高的
2003: Linux 2.6 —— O(1)调度器,用bitmap+优先级数组,O(1)复杂度
2007: Linux 2.6.23 —— CFS (Completely Fair Scheduler),红黑树+vruntime,统治了17年
2024: Linux 6.6 —— EEVDF (Earliest Eligible Virtual Deadline First),一个新的开始

CFS统治了17年,够猛了吧?但为什么还是被换了?因为硬件变了,负载变了,CFS的"公平"定义满足不了需求了。

二、CFS干了什么,为什么管了17年

CFS的核心就一句话:每个进程应该得到CPU时间的"N分之一",其中N是竞争进程数。

实现手段:追踪每个进程的vruntime(虚拟运行时间)。当一个进程被调度走时,它的vruntime增加;下一次调度时,选vruntime最小的那个。

所以CFS的调度就是:

while(1){next=rb_tree.min()// 从红黑树取最左侧节点(vruntime最小)context_switch(current,next)current.vruntime+=delta_exec*nice_weight rb_tree.update(current)// 重新插入红黑树}

这看起来完美,对吧?问题是CFS是work-conserving的——它只保证所有进程分到公平的时间,不保证单个进程的延迟。

三、CFS的死穴:延迟敏感型进程被打爆了

考虑一个场景:

进程A:交互式计算(编辑器、游戏、UI线程) 进程B:CPU密集型编译(gcc -j8)

CFS下,当进程B长时间运行,它的vruntime增长很快。如果进程A突然被唤醒(用户敲了个键),A的vruntime远小于B,所以CFS立刻抢断B,让A跑。

这看起来没问题?但在高负载下会翻车。

  • A被唤醒 → 插入红黑树最左边
  • B被抢断 → B的vruntime还没赶上A
  • A跑了一个tick → A的vruntime增加了
  • 但还有C、D、E、F……都在等CPU
  • 结果A再次被调度时,发现它的vruntime又不是最小的了
  • A的响应延迟完全取决于竞争进程的"重量"

更致命的是:CFS没有明确的延迟边界。它只能保证"越等越有机会被调度",但不保证"最多等多久"。

四、EEVDF的基本原理

EEVDF(Earliest Eligible Virtual Deadline First)来自1995年的一篇论文(Stoica et al.),但因为实现复杂,一直没进内核。直到2022-2023年,Peter Zijlstra(内核调度维护者)重写了一遍。

它的核心思想:

每个进程有一个"eligible time"(最早可调度时间)和一个"virtual deadline"(虚拟截止时间)。调度器永远选eligible且deadline最早的进程。

进程P的调度参数: weight: 优先级权重(同CFS的nice值) lag: 当前落后于公平份额的程度 slice: 时间片长度(由weight和系统负载决定) Eligible time: 如果P的lag < 0(落后了),立即eligible 如果P的lag >= 0(超前了),等到lag=0才eligible Virtual deadline: eligible_time + slice

Yes, 落后了

No, 超前了

进程就绪队列

计算每个进程
eligible time

lag < 0?

eligible = now

eligible = now + abs(lag)

virtual deadline = eligible + slice

选择 deadline 最早的
eligible 进程

运行该进程

进程时间到/被抢占

更新 lag 和 slice

这里最关键的概念是lag:

lag = 进程实际获得的CPU时间 - 应该获得的公平时间 lag > 0: 这个进程占便宜了(跑得比应得的多了) lag < 0: 这个进程吃亏了(跑得比应得的少了)

Eligibility规则是:占便宜的等一会儿再跑,吃亏的立刻就能跑。这就自然实现了"补偿性调度"——而且比CFS的vruntime更精确,因为lag是和全局公平线对比的,不是和其他进程对比。

五、EEVDF vs CFS:关键区别

维度CFSEEVDF
调度依据vruntime最小eligible且deadline最早
公平定义所有进程vruntime趋于相等每个进程lag≈0
延迟保证无明确边界有明确的deadline
抢占粒度取决于负载,不可预测slice固定,可预测
唤醒延迟取决于红黑树深度取决于队列中eligible进程数
大量短进程场景红黑树频繁插入删除,开销大可以用特殊优化
实现复杂度相对简单更复杂(lag追踪、deadline管理)

六、lag的加减博弈——EEVDF最难实现的地方

lag的实现比看上去复杂一万倍。为什么?

因为进程在动态变化:新进程fork出来、进程退出、nice值变化、cgroup权重调整……每一次变化,所有进程的lag都要重新计算相对于新的"公平线"。

Peter Zijlstra在patch里有一段代码,我看了一下午才看懂:

// kernel/sched/fair.c 中的 place_entity 函数(简化理解版)staticvoidplace_entity(structcfs_rq*cfs_rq,structsched_entity*se,intflags){u64 vslice=calc_vslice(se);// 基于weight计算虚拟时间片u64 vlag=0;if(flags&ENQUEUE_WAKEUP){// 被唤醒的进程:保留之前的lag(但不能太大)vlag=div_s64(se->vlag,se->load.weight);se->vlag=min_vlag(vlag,se->load.weight);}// EEVDF的核心:放置entityse->deadline=se->vruntime+vslice;if(se->vlag<0){// 落后了→立刻eligiblese->eligible_time=se->vruntime;}else{// 超前了→等lag消掉才eligiblese->eligible_time=se->vruntime+se->vlag;}}

这看着也还好?问题是这个vlag在以下场景都会变:

  1. 进程状态变化(睡眠→唤醒、fork、exit)
  2. 优先级变化(renice)
  3. CPU频率变化(涉及scaling)
  4. cgroup权重调整
  5. SMT/超线程协同调度

每一次变化,都要重新计算所有受影响进程的lag,还要保证lag总量守恒(所有进程lag之和为0)。如果加起来不是0,系统就会逐渐失去公平性。

七、EEVDF配上的新基础设施:0-lag 迁移

EEVDF还带来了一个我特别喜欢的设计:0-lag迁移

在多核系统中,进程在不同CPU之间迁移时,CFS的做法是把vruntime拉到目标CPU的"公平线"附近。这会导致进程的"历史欠账"丢失。

EEVDF的做法是:迁移时确保lag=0。如果一个进程在CPU0上欠了1ms(lag=-1ms),迁移到CPU1时,目标CPU上的调度器会认这笔账。

CPU 1

CPU 0

CFS 迁移

EEVDF 迁移

进程P
lag = -1ms
落后1ms

进程P
lag after migrate
CFS: lag≈0
(历史丢失)

进程P
lag after migrate
EEVDF: lag=-1ms
(历史保留)

这对延迟敏感型负载(比如数据库事务、网络服务)是大福音——进程的历史惩罚/补偿不会因为迁移而丢失。

八、实际表现怎么样

Phoronix做过EEVDF vs CFS的基准测试,我挑几个有意思的结果:

数据库负载(PgBench):

  • CFS: 9500 TPS,延迟P99=42ms
  • EEVDF: 10200 TPS,延迟P99=28ms
  • 延迟降低33%,吞吐提升7%

编译负载(Linux kernel defconfig, -j32):

  • CFS: 完成时间 128s
  • EEVDF: 完成时间 131s
  • 退化约2%——因为EEVDF为了保证短进程的延迟,牺牲了一点吞吐

混合负载(编译 + 交互进程):

  • CFS: 交互进程平均响应 45ms,P99=210ms
  • EEVDF: 交互进程平均响应 12ms,P99=38ms
  • 响应延迟提升3-5倍

这个tradeoff是很明显的:EEVDF牺牲了纯吞吐场景的2-3%,换来了延迟敏感场景3-5倍的改善。在云原生、实时容器、游戏服务器场景下,这个交换太值了。

九、EEVDF没解决的问题

写东西要客观,EEVDF也不是银弹:

  1. cgroup交互复杂度——EEVDF + cgroup v2 + CPUSET的组合下,lag计算会变得极其复杂,已经有多个kernel bug上报
  2. 功耗管理干扰——intel_pstate频率缩放和EEVDF的deadline计算有微妙交互,某些场景下deadline会误判
  3. 内核开销略增——每次调度多算一次deadline和eligibility检查,在百万级线程的极端场景下,调度延迟多出约5-8%
  4. 实时的硬实时仍然不够——EEVDF提供的是"软实时保证",不是硬实时。真正的硬实时还是得靠PREEMPT_RT + SCHED_DEADLINE

十、总结

如果你只是在服务器上跑批处理任务(模型训练、批处理SQL、离线分析),EEVDF对你几乎没影响。

但如果你是做:

  • 数据库/缓存中间件(Redis、MySQL、MongoDB)
  • 游戏服务器
  • Web服务/API网关
  • 实时音视频处理
  • 云端容器编排
  • 边缘计算

那EEVDF带来的延迟改进会让你爽到。你的进程不会再被"公平"地塞到队列末尾,而是在lag负了之后立刻获得补偿。

我第一次读到EEVDF patch的时候,心里想的是:“CFS那个vruntime最小就调度,原来不是一个唯一解啊……”转头想想,17年的调度器被换掉,内核社区不是闲得慌,是真的需要变。


“CFS是个好调度器,但它不是个好实时调度器。”——这就是EEVDF存在的全部理由。

内核调度器不止有CFS/EEVDF,还有RT(实时)、DEADLINE(硬deadline)、IDLE(空闲)这几个调度类。EEVDF替换的是CFS这个SCHED_NORMAL / SCHED_OTHER的位置。更硬核的实时场景,还有另外两个调度类撑着呢。

看了下/sys/kernel/debug/sched_features,确认一下EEVDF在你系统上跑没跑:

cat/sys/kernel/debug/sched_features# 如果输出里有 EEVRUN 那就是 EEVDF 再跑# 如果在 6.6+ 内核上,默认就是 EEVDF

下次你的服务器负载高了但响应还很快,说不定就是EEVDF在默默干活。