土法炼钢兴趣小组的算法知识备份

EEVDF 调度器:Linux 6.6 为什么换掉了 CFS

文章导航

分类入口
linuxos
标签入口
#eevdf#cfs#scheduler#linux-kernel#vruntime#latency#base-slice

目录

升级到 6.6 之后,你机器上的进程仍然被”公平”地分配 CPU,但内核决定下一个跑谁的规则已经换了一套。Linux 6.6(2023 年 10 月)把 SCHED_NORMAL(普通进程)的核心选取逻辑从 CFS(Completely Fair Scheduler)换成了 EEVDF(Earliest Eligible Virtual Deadline First)。这是调度器十多年来最大的一次骨架替换。

先把结论摆在前面:

  1. 换掉的只是”选谁”那一块,不是整个调度器。 红黑树、cfs_rqsched_entityvruntime、nice 权重表、组调度、负载均衡全部留用;被替换的是 pick_next(选取)、placement(入队定位)和 preemption(抢占判定)。
  2. CFS 只有一个参数(权重),EEVDF 加了第二个:请求大小(时间片)。 第二个参数把”延迟”变成可以直接表达的量,而 CFS 只能靠一堆启发式(sched_latencyGENTLE_FAIR_SLEEPERSSTART_DEBIT…)去凑。
  3. 选取分两步:先用 eligibility(lag ≥ 0)做门槛,再在合格集合里取 virtual deadline 最早的。 虚拟截止时间 \(vd_i = ve_i + r_i/w_i\),时间片越小、截止越早、越优先——这正是延迟敏感任务想要的。
  4. 这些不是纸面概念。 本文在 6.6 上读 /sys/kernel/debug/sched/debug,把三个真实任务的 vruntimeE/N 合格标志、deadline 一一对上公式,再用 nice 与 base_slice 两组实测验证权重分配与抢占节奏。
  5. 6.6 上你无法把 CFS 切回来对拍。 “Commit to EEVDF” 后旧选取逻辑被删除,没有开关。所以”为什么换”靠 commit、论文、源码讲,不靠同机 A/B。

本文不重复 操作系统百科:EEVDF 取代 CFS 的新算法 里的概念铺陈,也不重讲 CFS 内部机制。这里只做一件事:在一台正在运行 6.6 的机器上,把 EEVDF 的每个关键变量从源码追到 /proc 和 debugfs,再用可复现的实验钉住它的行为。

实验环境(全文数据均来自此机):

$ uname -r
6.6.87.2-microsoft-standard-WSL2
$ nproc
24
$ grep -m1 'model name' /proc/cpuinfo
model name : 12th Gen Intel(R) Core(TM) i9-12900K
$ grep MemTotal /proc/meminfo
MemTotal: 32725128 kB

内核配置关键项:CONFIG_HZ_250(tick = 4ms)、CONFIG_HIGH_RES_TIMERS=yCONFIG_PREEMPT_DYNAMIC=y 且默认 CONFIG_PREEMPT_NONE。这是 WSL2 的微软内核,跑在 Hy-V guest 里,vCPU 还会被 Windows 宿主再调度一层——所以下文所有绝对时间只看相对趋势,不当裸机基线


一、CFS 的单参数模型,和它欠下的启发式债

CFS 的模型可以一句话概括:每个任务维护一个 vruntime(虚拟运行时间,运行越久涨得越多,nice 越低涨得越慢),所有可运行任务按 vruntime 挂在一棵红黑树上,永远选最左边那个——也就是 vruntime 最小、“亏得最多”的任务。长期看,大家的 vruntime 会被拉平,于是”公平”。

问题在于 CFS 只有 vruntime 这一个调度量,而它代表的是累计公平,不是延迟。长期公平允许短期极不均:一个任务连跑 12ms 再让 12ms,和另一个连跑 24ms 的任务,总量可能一样,但前者的交互延迟体验好得多。CFS 没有一个参数能表达”我这个任务每次只想跑一小会儿,但要尽快被叫到”。

为了补这个洞,CFS 在 vruntime 之外堆了一层又一层启发式:用 sched_latency_ns / sched_min_granularity_ns 控制一个调度周期切几刀;用 GENTLE_FAIR_SLEEPERSSTART_DEBIT 处理刚睡醒任务的 vruntime 该怎么补偿才不至于抢占失控;唤醒抢占要不要立即发生也得靠经验阈值。这些参数互相牵制、难以解释,而且全是全局的——你不能对单个延迟敏感任务说”给它更短的时间片”。

EEVDF 的出发点就是:与其堆启发式,不如给调度器加上第二个参数,把延迟变成模型里的一等公民。


二、EEVDF 的两个参数:把延迟写进模型

EEVDF 来自 Ion Stoica 与 Hussein Abdel-Wahab 1995 年的论文 Earliest Eligible Virtual Deadline First: A Flexible and Accurate Mechanism for Proportional Share Resource Allocation。内核文档 Documentation/scheduler/sched-eevdf.rst 明确引用了它。EEVDF 给每个任务两个参数:

由这两个参数推出两个核心量。

2.1 lag:你欠它的,还是它欠你的

设系统的”理想公平虚拟时间”为 \(V\)(所有任务 vruntime 的加权平均,即零延迟点)。任务 \(i\) 的 lag 定义为它应得的服务与实得服务之差:

\[ \text{lag}_i = w_i \cdot (V - v_i) \]

其中 \(v_i\) 是任务的 vruntimelag > 0 表示系统还欠它 CPU 时间(它跑得不够),lag < 0 表示它已经超额。所有任务的 lag 之和恒为零——有人超额,必有人欠账。

2.2 eligibility:合格才有资格被选

EEVDF 的第一步门槛:只有 lag ≥ 0 的任务才”合格”(eligible)参与本轮选取。\(\text{lag}_i = w_i(V - v_i)\)\(w_i > 0\),合格等价于

\[ v_i \le V \]

vruntime 不高于加权平均的任务才合格。一个刚超额跑过的任务(vruntime 偏高)会暂时失去资格,直到 \(V\) 随时间推进重新追上它。这就从机制上挡住了”贪婪任务连续霸占”。

2.3 virtual deadline:合格集合里比这个

第二步:在所有合格任务里,选虚拟截止时间最早的。虚拟截止时间为

\[ vd_i = ve_i + \frac{r_i}{w_i} \]

\(ve_i\) 是该任务变得合格的虚拟时间点,\(r_i/w_i\) 是把请求大小折算成虚拟时间的增量。关键含义:请求越小(时间片越短),\(vd_i\) 越早,越容易被优先选中。延迟敏感任务只要声明一个小时间片,就能在不破坏长期公平的前提下抢到更早的执行机会——这正是 CFS 单参数模型做不到的。

下图用本机实测的真实数字展示这两步选取:

EEVDF 选取:在 eligible 区(v ≤ V)里取虚拟截止时间最早的任务

三、6.6 到底改了哪一块

把 EEVDF 落进内核的核心提交是 147f3efaa241sched/fair: Implement an EEVDF-like scheduling policy,Peter Zijlstra,并入 6.6)。它的 commit message 把意图说得很直白:

EEVDF has two parameters: - weight, or time-slope: which is mapped to nice just as before - request size, or slice length: which is used to compute the virtual deadline as: vd_i = ve_i + r_i/w_i

看这个 patch 的改动面就能确认”换的是哪一块”:它主要动 kernel/sched/fair.c(+292 −46),外加 sched.hdebug.c 等少量字段,而 fair_sched_class 的权重计算、组调度、PELT 负载、enqueue/dequeue 主干基本不动。换言之,EEVDF 重写的是 placement、pick、preempt 三处,复用了 CFS 的其余全部基础设施

3.1 eligibility 在源码里长什么样

entity_eligible()kernel/sched/fair.c,v6.6,L738)直接对应 \(v_i \le V\),注释把推导也写出来了:

/*
 * Entity is eligible once it received less service than it ought to have,
 * eg. lag >= 0.
 *
 * lag_i = S - s_i = w_i*(V - v_i)
 * lag_i >= 0 -> V >= v_i
 */
int entity_eligible(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    struct sched_entity *curr = cfs_rq->curr;
    s64 avg = cfs_rq->avg_vruntime;
    long load = cfs_rq->avg_load;

    if (curr && curr->on_rq) {
        unsigned long weight = scale_load_down(curr->load.weight);

        avg += entity_key(cfs_rq, curr) * weight;
        load += weight;
    }

    return avg >= entity_key(cfs_rq, se) * load;
}

这里 entity_key()se->vruntime - cfs_rq->min_vruntime,比较是整数化、避免除法精度损失的形式,但语义就是 \(v_i \le V\)。注意它把当前运行的任务 curr 也折进了平均——这点稍后对实测数字很重要。\(V\)avg_vruntime()(L671)给出,返回 min_vruntime + 加权平均偏移

3.2 虚拟截止时间在哪算

update_deadline()(L1019)每次任务用完它的请求时重算截止时间,几乎是把公式照抄:

static void update_deadline(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    if ((s64)(se->vruntime - se->deadline) < 0)
        return;

    /*
     * For EEVDF the virtual time slope is determined by w_i (iow.
     * nice) while the request time r_i is determined by
     * sysctl_sched_base_slice.
     */
    se->slice = sysctl_sched_base_slice;

    /* EEVDF: vd_i = ve_i + r_i / w_i */
    se->deadline = se->vruntime + calc_delta_fair(se->slice, se);

    if (cfs_rq->nr_running > 1) {
        resched_curr(rq_of(cfs_rq));
        clear_buddies(cfs_rq, se);
    }
}

两件事值得记住:一是 se->slice = sysctl_sched_base_slice——6.6 里请求大小是个全局值,不是 per-task(第六、七节再展开);二是 calc_delta_fair(slice, se) 把墙钟时间片按权重折成虚拟时间增量,nice 越低折出来越小,截止越早。

3.3 选取:增广红黑树上的 O(log n) 搜索

__pick_eevdf()(L875)是选取核心。树仍按 vruntime 排序,但每个节点额外缓存 min_deadline = min(自身, 左右子树),于是可以在合格子树里做 EDF 式堆搜索:

static struct sched_entity *__pick_eevdf(struct cfs_rq *cfs_rq)
{
    struct rb_node *node = cfs_rq->tasks_timeline.rb_root.rb_node;
    struct sched_entity *curr = cfs_rq->curr;
    struct sched_entity *best = NULL;
    /* ... */
    while (node) {
        struct sched_entity *se = __node_2_se(node);

        /* 不合格就往左子树找(vruntime 更小的更可能合格) */
        if (!entity_eligible(cfs_rq, se)) {
            node = node->rb_left;
            continue;
        }

        /* 在合格节点里按 deadline 取最优 */
        if (!best || deadline_gt(deadline, best, se))
            best = se;
        /* ... 借助 min_deadline 剪枝,避免遍历整棵树 ... */
    }
    /* ... */
}

和 CFS 的差别一句话:CFS 取最左(最小 vruntime);EEVDF 取合格集合里 deadline 最早的那个,两者通常不是同一个节点。

__pick_eevdf 在增广红黑树上的选取与剪枝

这里还藏着一个延迟优化 RUN_TO_PARITY:被选中的任务会一直跑到它不再合格(lag 归零)或拿到新片,而不是每个 tick 都重新参选,从而减少不必要的切换抖动。features 里能看到它默认开启(第六节)。


四、亲手观测:把 vruntime、eligible、deadline 对上公式

光看源码不够,直接在本机把这些量读出来。先看调度器旋钮目录——它本身就是 EEVDF 落地的证据:

$ sudo ls /sys/kernel/debug/sched/
base_slice_ns  debug  features  latency_warn_ms  latency_warn_once
migration_cost_ns  nr_migrate  preempt  tunable_scaling  verbose

旧 CFS 时代的 sched_latency_nssched_min_granularity_ns 不见了,取而代之的是单一的 base_slice_ns。这对应 commit e4ec3318a17fsysctl_sched_min_granularity 改名成 sysctl_sched_base_slice。当前值与启用的特性:

$ sudo cat /sys/kernel/debug/sched/base_slice_ns
3000000
$ sudo cat /sys/kernel/debug/sched/features
PLACE_LAG PLACE_DEADLINE_INITIAL RUN_TO_PARITY NO_NEXT_BUDDY CACHE_HOT_BUDDY
WAKEUP_PREEMPTION NO_HRTICK NO_HRTICK_DL ... (输出经删减)

base_slice_ns = 3000000(3ms),PLACE_LAGPLACE_DEADLINE_INITIALRUN_TO_PARITY 都是 EEVDF 专属特性。

现在制造可控的争抢:编译一个纯自旋程序,把三个实例绑到同一个逻辑 CPU 上,让它们真正竞争一个核。

/* /tmp/spin.c —— cc -O2 -o /tmp/spin spin.c */
int main(void) { volatile unsigned long x = 0; for (;;) { x++; } return 0; }
CPU=15
taskset -c $CPU /tmp/spin & taskset -c $CPU /tmp/spin & taskset -c $CPU /tmp/spin &
sleep 1.5
sudo cat /sys/kernel/debug/sched/debug > /tmp/dump.txt

从同一次转储里取出该 CPU 运行队列的 EEVDF 字段,以及三个 spin 任务行(同一次 dump,时间一致):

cfs_rq[15]:/init.scope
  .left_vruntime    : 1898.500602
  .min_vruntime     : 1897.552272
  .avg_vruntime     : 1898.714894
  .right_vruntime   : 1900.091810
  .nr_running       : 3

runnable tasks:
 S  task   PID    tree-key   switches prio ...
 R  spin  9628  1900.091810 N 1903.081103 3.000000  ... 130 120 ...
>R  spin  9629  1897.552272 E 1900.541831 3.000000  ... 133 120 ...
 R  spin  9630  1898.500602 E 1901.492952 3.000000  ... 134 120 ...

先说列:debugfs 里这张表的表头没跟着 EEVDF 更新(还写着 tree-key switches prio),但实际每行的字段顺序由 print_task()kernel/sched/debug.c L574)决定,是:

SEQ_printf(m, "%15s %5d %9Ld.%06ld %c %9Ld.%06ld %9Ld.%06ld %9Ld.%06ld %9Ld %5d ",
    p->comm, task_pid_nr(p),
    SPLIT_NS(p->se.vruntime),                                   /* tree-key 列 = vruntime */
    entity_eligible(cfs_rq_of(&p->se), &p->se) ? 'E' : 'N',     /* 合格标志 */
    SPLIT_NS(p->se.deadline),                                   /* 虚拟截止时间 */
    SPLIT_NS(p->se.slice),                                      /* 请求大小 */
    SPLIT_NS(p->se.sum_exec_runtime), ...);

也就是说每行依次是:状态 comm pid vruntime E/N deadline slice sum_exec switches prio。把数字对上模型:

  1. eligibility 完全等于 \(v_i \le V\) 本次 $V = $ avg_vruntime \(= 1898.71\)。9629(\(v=1897.55\))和 9630(\(v=1898.50\))都 \(\le V\),标 E;9628(\(v=1900.09\)\(> V\),标 N。一个不漏。
  2. virtual deadline 完全等于 \(v + r/w\) 三者都是 nice 0、slice=3.000000,于是 \(vd = v + 3.0\)\(1897.55+3.0 = 1900.55 \approx 1900.54\)\(1898.50+3.0 = 1901.49\)\(1900.09+3.0 = 1903.08\),逐个吻合(小数点后第二位的零头来自读取瞬间 vruntime 仍在推进)。
  3. 正在跑的是合格集合里 deadline 最早的那个。 合格的是 9629、9630,其中 9629 的 \(vd=1900.54 < 1901.49\),所以它被标 >R(current)。这就是 __pick_eevdf 选取规则在本机的直接呈现。

把这三条连起来,就是一次完整的 EEVDF 决策:9628 因为超额(vruntime 偏高)暂时出局,9629 在两个合格者里截止更早所以正在跑。 三个相同 nice 的任务长期会被轮流拉平——这点下一节用累计 CPU 时间验证。


五、nice 还是那张权重表

EEVDF 换了选取逻辑,但份额分配是否还遵守 CFS 的 nice 权重?做个对照:同一个 CPU 上跑 nice 0 和 nice 5 各一个自旋进程,6 秒后读各自的累计运行时间 se.sum_exec_runtime

CPU=11
nice -n 0 taskset -c $CPU /tmp/spin &   # A
nice -n 5 taskset -c $CPU /tmp/spin &   # B
sleep 6
grep -E 'sum_exec_runtime|load.weight' /proc/$A/sched /proc/$B/sched

结果(sum_exec_runtime 单位 ms,load.weightscale_load 放大 1024 倍):

进程 nice load.weight 折算权重 6s 累计运行 (ms)
A 0 1048576 1024 4529.61
B 5 343040 335 1485.35

CPU 时间比 \(4529.61 / 1485.35 = 3.05\),权重比 \(1024 / 335 = 3.06\),两者几乎相等。说明 EEVDF 完全沿用 CFS 的 sched_prio_to_weight[] 权重表:nice 0 对应权重 1024、nice 5 对应 335,每差一级约 1.25 倍,份额按权重严格比例分配。换选取逻辑没有改变”nice 决定多少份额”这件事,只改变了”在同一份额下何时被叫到”。


六、时间片、tick 与 RUN_TO_PARITY:base_slice 实验

base_slice_ns 是 6.6 调节抢占节奏的主旋钮。直觉上,时间片越小、抢占越频繁、切换越多。实测一下:两个自旋进程绑同核,5 秒窗口内数其中一个进程的上下文切换次数(/proc/<pid>/schednr_switches),三档 base_slice

for ns in 3000000 1000000 12000000; do
  sudo bash -c "echo $ns > /sys/kernel/debug/sched/base_slice_ns"
  # 起两个 spin 绑同核,取 5s 内 nr_switches 增量
done
base_slice 5 秒内 nr_switches 平均切换间隔
3 ms 623 ≈ 8.0 ms
1 ms 618 ≈ 8.1 ms
12 ms 165 ≈ 30 ms

注意 1ms 和 3ms 几乎没差别,只有调到 12ms 才显著变稀。原因要从这台机的配置找:CONFIG_HZ_250 意味着调度 tick 每 4ms 一次,而 features 里是 NO_HRTICK——抢占决策在 tick 边界上判定,没有高精度定时器把时间片切得更细。所以当 base_slice 小到接近或低于 tick 粒度(4ms)时,缩小它并不能换来更频繁的抢占,切换率被 tick 卡住;只有当 base_slice 明显大于一个 tick(12ms ≈ 3 个 tick)时,任务才会被允许连续跑更久,切换才真正变稀。叠加 RUN_TO_PARITY(当前任务跑到不再合格才让位),实测切换间隔(3ms 档约 8ms ≈ 2 个 tick)也比”每片就切”更长,符合这个机制。

这条结论的边界很清楚:它依赖 HZ=250NO_HRTICK。在 HZ=1000 或开了 HRTICK 的内核上,小 base_slice 能换来更细的抢占,曲线会不一样。换了内核就得重测,别照搬这里的数字。


七、行为变化、回归与版本边界

最后交代三件不能含糊的事。

一、6.6 上没法把 CFS 切回来对拍。 系列提交里的 sched/fair: Commit to EEVDF 把旧选取逻辑直接删了,没有 sysctl 开关。所以本文不做”同机 CFS vs EEVDF”的 A/B——那种对比只能跨内核版本做,且要严格对齐负载与配置,否则就是 WRITING_GUIDE 里点名禁止的”挑结果”。

二、换调度器会改变行为,包括变差的情形。 6.6 的调度器合并邮件(Ingo Molnar 的 [GIT PULL] Scheduler changes for v6.6)原话就承认:EEVDF “完全重写了 placement、preemption、picking”,绝大多数负载会变好,但”对抗性负载”(adversarial loads)和那些”在旧代码下碰巧占了便宜”的负载可能变差。换内核大版本后,盯紧 P99 延迟和吞吐回归,是该做的功课。

三、per-task 时间片是 6.12 才补齐的,6.6 没有。 本文第三节看到 update_deadline()se->slice = sysctl_sched_base_slice 是全局值;/proc/self/sched 里也能看到 se.slice : 3000000 跟全局一致。让单个延迟敏感任务通过 sched_setattr() 请求自定义时间片,是 LWN Completing the EEVDF scheduler(Articles/969062)描述的 6.12 工作,连同对睡眠任务 lag 的 delayed-dequeue 改进一起落地。所以在你这台 6.6 上,调延迟的旋钮只有全局的 base_slice_ns 和 nice,没有 per-task slice——这是真实的版本边界,别按 6.12 的文章去找 6.6 上不存在的接口。


八、小结

EEVDF 对 Linux 调度器的改动,本质是给”公平”加了一个”延迟”维度:CFS 用单一 vruntime 选最左,长期公平但短期延迟不可控;EEVDF 用 lag ≥ 0 的 eligibility 当门槛、再用 \(vd = ve + r/w\) 的虚拟截止时间在合格集合里选最早,把”我想要短而及时的时间片”变成了可表达、可调的参数。而它只重写了选取/入队/抢占,复用了 CFS 的权重、树和负载均衡——这也是为什么 6.6 能相对平滑地完成这次替换。

本文在一台 6.6 机器上把这套机制逐项落实:sched/debug 里每个任务的 E/N 标志精确等于 \(v_i \le V\)deadline 精确等于 \(v + r/w\),正在跑的就是合格集合里截止最早的;nice 的份额分配仍走老权重表;base_slice 的抢占效果受 HZ/HRTICK 约束。这些都能在你自己的内核上用同样的命令复现——换台机器、换个内核版本,记得重测,别照抄数字。

延伸阅读:

参考资料

规范与文档

源码与提交(Linux v6.6)

论文

辅助参考


返回 Linux 内核与 eBPF 工程索引 · 全部系列索引

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-18 · os

【操作系统百科】CFS 内部:vruntime 与红黑树

CFS(Completely Fair Scheduler)从 2.6.23 统治 Linux 18 年。本文讲它的核心数据结构:sched_entity、cfs_rq、按 vruntime 排序的红黑树;vruntime 随 nice 加权的公式;sched_latency_ns/min_granularity 如何决定周期;wake-up preemption、autogroup、group scheduling 的来龙去脉。

2026-04-03 · linux / containers

【从零造容器】Cgroups v2:让容器不能吃掉整台机器

你给容器设了 512MB 内存限制,结果宿主机上的数据库被 OOM-kill 了。Cgroups 不是'加个限制'那么简单 — v1 的设计是个历史错误,v2 才是正确答案。本文用 C 代码从 mkdir 开始,手动创建 cgroup,设 CPU/内存/IO 限制,压测,看它怎么把进程关进笼子。

2025-07-15 · algorithms

进程调度:从 CFS 到 EEVDF 的哲学演变

你把 nice 值设成了 -20,然后发现延迟反而更高了。你用 cgroup 限了 CPU,然后发现交互式 shell 卡成幻灯片。调度器不是'谁优先级高谁先跑'这么简单——它是操作系统中最复杂的博弈论。

2026-06-21 · linux / io_uring

Linux 异步 I/O:epoll 与 io_uring 对比

从就绪通知到完成通知:梳理 epoll 与 io_uring 的架构差异、系统调用开销、适用场景,并附最小可运行 C 示例与示意图。


By .