升级到 6.6 之后,你机器上的进程仍然被”公平”地分配
CPU,但内核决定下一个跑谁的规则已经换了一套。Linux
6.6(2023 年 10 月)把
SCHED_NORMAL(普通进程)的核心选取逻辑从
CFS(Completely Fair Scheduler)换成了 EEVDF(Earliest
Eligible Virtual Deadline
First)。这是调度器十多年来最大的一次骨架替换。
先把结论摆在前面:
- 换掉的只是”选谁”那一块,不是整个调度器。 红黑树、
cfs_rq、sched_entity、vruntime、nice 权重表、组调度、负载均衡全部留用;被替换的是pick_next(选取)、placement(入队定位)和 preemption(抢占判定)。- CFS 只有一个参数(权重),EEVDF 加了第二个:请求大小(时间片)。 第二个参数把”延迟”变成可以直接表达的量,而 CFS 只能靠一堆启发式(
sched_latency、GENTLE_FAIR_SLEEPERS、START_DEBIT…)去凑。- 选取分两步:先用 eligibility(
lag ≥ 0)做门槛,再在合格集合里取 virtual deadline 最早的。 虚拟截止时间 \(vd_i = ve_i + r_i/w_i\),时间片越小、截止越早、越优先——这正是延迟敏感任务想要的。- 这些不是纸面概念。 本文在 6.6 上读
/sys/kernel/debug/sched/debug,把三个真实任务的vruntime、E/N合格标志、deadline一一对上公式,再用 nice 与base_slice两组实测验证权重分配与抢占节奏。- 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=y、CONFIG_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_SLEEPERS、START_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 给每个任务两个参数:
- 权重 \(w_i\):还是从 nice 来,决定该任务应得的 CPU 份额(虚拟时间流速)。
- 请求大小 \(r_i\)(时间片 / slice):该任务希望每次连续运行多久。
由这两个参数推出两个核心量。
2.1 lag:你欠它的,还是它欠你的
设系统的”理想公平虚拟时间”为 \(V\)(所有任务
vruntime 的加权平均,即零延迟点)。任务 \(i\) 的 lag
定义为它应得的服务与实得服务之差:
\[ \text{lag}_i = w_i \cdot (V - v_i) \]
其中 \(v_i\) 是任务的
vruntime。lag > 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 单参数模型做不到的。
下图用本机实测的真实数字展示这两步选取:
三、6.6 到底改了哪一块
把 EEVDF 落进内核的核心提交是
147f3efaa241(sched/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.h、debug.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
最早的那个,两者通常不是同一个节点。
这里还藏着一个延迟优化
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_ns、sched_min_granularity_ns
不见了,取而代之的是单一的
base_slice_ns。这对应 commit
e4ec3318a17f 把
sysctl_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_LAG、PLACE_DEADLINE_INITIAL、RUN_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。把数字对上模型:
- 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。一个不漏。 - 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仍在推进)。 - 正在跑的是合格集合里 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.weight 已 scale_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>/sched
的 nr_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=250
且 NO_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
约束。这些都能在你自己的内核上用同样的命令复现——换台机器、换个内核版本,记得重测,别照抄数字。
延伸阅读:
- 操作系统百科:EEVDF 取代 CFS 的新算法(概念与 latency-nice 演进)
- 操作系统百科:CFS 内部机制(被替换前的 vruntime 选取)
- 操作系统百科:调度延迟与抢占
- fsync 失败的真相:当它返回错误,你的数据可能已经没了(同一条”系统机制本机实测”主线)
- Linux 内核与 eBPF 工程索引
参考资料
规范与文档
Documentation/scheduler/sched-eevdf.rst,Linux 6.6 内核文档。
源码与提交(Linux v6.6)
- commit
147f3efaa241,sched/fair: Implement an EEVDF-like scheduling policy,Peter Zijlstra。 - commit
e4ec3318a17f,sched/debug: Rename sysctl_sched_min_granularity to sysctl_sched_base_slice。 kernel/sched/fair.c:entity_eligible()、avg_vruntime()、update_entity_lag()、__pick_eevdf()、update_deadline()、place_entity()。kernel/sched/debug.c:print_task()、print_cfs_rq()。- Ingo Molnar,[GIT PULL] Scheduler changes for v6.6,linux-kernel 邮件列表。
论文
- Ion Stoica, Hussein Abdel-Wahab, Earliest Eligible Virtual Deadline First: A Flexible and Accurate Mechanism for Proportional Share Resource Allocation, 1995。
辅助参考
- Jonathan Corbet, An EEVDF CPU scheduler for Linux, LWN.net Articles/925371。
- Jonathan Corbet, Completing the EEVDF scheduler, LWN.net Articles/969062。
返回 Linux 内核与 eBPF 工程索引 · 全部系列索引
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【操作系统百科】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 的来龙去脉。
【从零造容器】Cgroups v2:让容器不能吃掉整台机器
你给容器设了 512MB 内存限制,结果宿主机上的数据库被 OOM-kill 了。Cgroups 不是'加个限制'那么简单 — v1 的设计是个历史错误,v2 才是正确答案。本文用 C 代码从 mkdir 开始,手动创建 cgroup,设 CPU/内存/IO 限制,压测,看它怎么把进程关进笼子。
进程调度:从 CFS 到 EEVDF 的哲学演变
你把 nice 值设成了 -20,然后发现延迟反而更高了。你用 cgroup 限了 CPU,然后发现交互式 shell 卡成幻灯片。调度器不是'谁优先级高谁先跑'这么简单——它是操作系统中最复杂的博弈论。
Linux 异步 I/O:epoll 与 io_uring 对比
从就绪通知到完成通知:梳理 epoll 与 io_uring 的架构差异、系统调用开销、适用场景,并附最小可运行 C 示例与示意图。