编程 sched_ext 源码级深度拆解:把 Linux 调度器变成可热插拔的 BPF 程序——从 DSQ 状态机到手写一个大小核感知调度器(附完整 C 实现)

2026-08-02 02:19:25 +0800 CST views 7

sched_ext 源码级深度拆解:把 Linux 调度器变成可热插拔的 BPF 程序——从 DSQ 状态机到手写一个大小核感知调度器

如果你在 2020 年跟人说「我要换掉 Linux 的 CPU 调度器」,得到的回应大概率是三连问:你要 patch 内核?你要重新编译?崩了怎么办?

到了今天,答案变成了一条命令:

sudo scx_lavd

回车,几十毫秒之后,你这台机器上所有普通进程的调度决策,就已经交给了一段跑在内核里的 BPF 程序。Ctrl-C,一切恢复原状,不用重启,不用 kexec,不用祈祷。

这就是 sched_ext(可扩展调度器类,extensible scheduler class)。它在 Linux 6.12 进入主线,是内核调度子系统近十五年来最大的一次结构性变化——不是换了一个调度算法,而是把「调度算法」这件事本身,从内核开发者的专属领地,变成了一个人人可写、可热插拔、可安全回滚的扩展点。

这篇文章不打算写成「sched_ext 是什么,五分钟带你了解」。我们要做的是:从第一性原理讲清楚为什么调度器非改不可、sched_ext 的每一个核心抽象为什么长成这样、然后从零手写四个逐步进化的调度器(含完整可编译代码),最后给出一套能落地的生产选型与调优决策树,外加十条真实踩坑清单。

内容比较长,建议配合终端一起看。


一、先说清楚问题:为什么「改调度器」是内核里最难啃的骨头

1.1 调度器的三重矛盾

CPU 调度器要同时满足三件互相打架的事:

  1. 公平性(fairness):每个任务按权重拿到应得的 CPU 时间,不能饿死;
  2. 交互性(latency):鼠标点一下、按键敲一下、RPC 进来一个包,要尽快得到响应;
  3. 吞吐(throughput):尽量少切换、少迁移,保住 cache 局部性,把机器榨干。

这三者的权衡点,取决于工作负载。而 Linux 的 CFS(Completely Fair Scheduler,2007 年进主线)以及 6.6 之后接替它的 EEVDF(Earliest Eligible Virtual Deadline First),本质上都是一套参数固定的通用启发式,试图在所有场景下都拿到「还行」的分数。

这在 2007 年是合理的:那时候一台服务器就是几个同构核心,跑的东西也没那么分裂。但今天的现实是:

  • 同一台机器上跑的东西完全不是一类:一个 K8s 节点上,延迟敏感的在线服务、批处理的离线任务、还有一堆 sidecar,公平调度对它们同时是错的;
  • 硬件彻底异构了:Intel 的 P-core/E-core、ARM 的 big.LITTLE、AMD 的 CCD/CCX(跨 CCD 迁移要吃 L3 miss 的亏)、SMT 超线程的兄弟核干扰、多路 NUMA、以及 CXL 带来的又一层内存距离;
  • 云上还有一层虚拟化偷时间:vCPU 随时可能被 hypervisor 抢走,guest 内核以为自己在跑,其实早就 steal 掉了;
  • 能效成了一等公民:笔记本和手机上,把任务放 E-core 上慢跑,可能比放 P-core 上跑完再睡更省电(race-to-idle 不总是对的)。

一个通用调度器要覆盖上面所有场景,等于让一双 42 码的鞋同时适配所有人的脚。

1.2 真正卡住的不是算法,是迭代成本

更要命的问题不在于「写不出更好的算法」,而在于试错成本。传统路径下,你想验证一个调度想法:

写 patch → 编译内核(几分钟到几十分钟)
       → 重启进目标机(分钟级,生产机根本不敢)
       → 跑压测(小时级)
       → 发现 bug → 机器 hang 死 / panic → 重新捞机器
       → 循环

一轮迭代按天算,而且任何一个 bug 都是全系统级的——调度器崩了不是某个进程崩,是整台机器僵死。这直接导致:

  • 只有极少数内核老手敢碰调度器;
  • 上游 review 周期以季度计,因为 reviewer 也无法快速验证;
  • 企业内部的调度器改动全是私有 patch,散落在各家的 fork 里,永远回不了上游。

历史上不是没人试过。Con Kolivas 的 BFS/MuQSS 长期在社区外维护,用户口碑不错但从未进主线;Google 的 ghOSt 走的是「把调度决策上送到用户态 agent」的路子,论文(SOSP '21)效果亮眼,但它需要一套自己的内核补丁,落地门槛太高。「可插拔调度器」这个提案在 LKML 上被否掉过不止一次,核心担忧是:一旦开放接口,Linux 就会碎片化成 N 个互不兼容的调度器生态,而且会形成 ABI 包袱。

1.3 sched_ext 是怎么绕过这个死结的

sched_ext 的破局点非常聪明,一句话概括:

它不给你一个"内核模块接口",而是给你一个"BPF 沙箱 + 强制安全网"。

这带来三个上游能接受的性质:

  1. 写出来的东西过不了 verifier 就加载不进去——没有野指针、没有无界循环、没有非法内存访问;
  2. 加载进去之后跑歪了,内核会主动踢掉它——看门狗检测到任务饿死,自动卸载 BPF 调度器并回退到 EEVDF,机器活着;
  3. BPF 不是稳定 ABI——内核有权改,不承担向后兼容包袱,这一点直接消解了「ABI 绑架」的担忧。

于是迭代循环变成了:

改 .bpf.c → clang 编译(秒级)→ 加载(毫秒级)→ 压测 → Ctrl-C 卸载 → 再来

从天级压缩到分钟级。这才是 sched_ext 真正的价值——不是某个具体调度器更快,而是调度器的进化速度快了两个数量级。


二、架构总览:sched_ext 在内核里的位置

2.1 它是一个新的 sched_class

Linux 的调度类是一条优先级链,每次挑下一个任务时从高到低遍历:

stop_sched_class     ← 最高,用于 CPU 热插拔/停机
    ↓
dl_sched_class       ← SCHED_DEADLINE
    ↓
rt_sched_class       ← SCHED_FIFO / SCHED_RR
    ↓
fair_sched_class     ← CFS / EEVDF(SCHED_NORMAL、SCHED_BATCH)
    ↓
ext_sched_class      ← ★ sched_ext 在这里
    ↓
idle_sched_class     ← swapper

注意这个位置很关键:ext 排在 fair 下面。意味着如果有任何普通任务还留在 CFS 里,它会优先于 BPF 调度器管理的任务运行。

那为什么加载一个 BPF 调度器之后,感觉是「整个系统都被接管了」?因为默认行为是:加载时把所有 SCHED_NORMAL 任务全部搬迁到 ext 类。CFS 那边空了,自然就轮到 ext 说了算。

如果你只想接管一部分任务,可以在 ops flags 里打开 SCX_OPS_SWITCH_PARTIAL——这时候只有显式设置了 SCHED_EXT 策略的任务才会走 BPF 调度器,其余留在 EEVDF。这个模式在灰度上线时非常有用:先把一个服务丢进去,观察一周,再全量。

编译内核需要打开:

CONFIG_BPF=y
CONFIG_BPF_SYSCALL=y
CONFIG_BPF_JIT=y
CONFIG_DEBUG_INFO_BTF=y
CONFIG_BPF_JIT_ALWAYS_ON=y
CONFIG_SCHED_CLASS_EXT=y

主流发行版这两年已经陆续默认开了:Ubuntu 从 24.10 起随 6.11+ 内核提供,Fedora、CachyOS、Arch(配合 scx-scheds 包)都能开箱即用,Valve 的 SteamOS 更是直接把 sched_ext 用在了 Steam Deck 上。

2.2 加载机制:BPF struct_ops

sched_ext 的加载走的是 BPF 的 struct_ops 机制。你在 BPF 程序里定义一个 struct sched_ext_ops 实例,把回调函数挂上去:

SEC(".struct_ops.link")
struct sched_ext_ops my_scheduler_ops = {
    .select_cpu  = (void *)my_select_cpu,
    .enqueue     = (void *)my_enqueue,
    .dispatch    = (void *)my_dispatch,
    .running     = (void *)my_running,
    .stopping    = (void *)my_stopping,
    .init        = (void *)my_init,
    .exit        = (void *)my_exit,
    .flags       = SCX_OPS_ENQ_LAST,
    .timeout_ms  = 5000,
    .name        = "my_scheduler",
};

用户态通过 libbpf 生成的 skeleton 加载并 attach。attach 成功的那一刻,内核就开始调用你的函数做调度决策了。进程退出(或者 bpf_link 被关闭),调度器自动卸载。

这意味着 BPF 调度器的生命周期绑在用户态进程上——你 Ctrl-C,它就没了。这是一个刻意的设计:任何时候只要能杀掉那个进程,系统就能恢复。

2.3 三道安全网

这是 sched_ext 能进主线的政治基础,值得单独讲。

第一道:verifier。 BPF 验证器保证程序不会崩内核——无界循环、越界访问、未初始化指针、非法 kfunc 调用,全部在加载阶段拒绝。代价是你写代码的自由度受限(后面第六节详谈)。

第二道:runnable 看门狗。 内核维护一个定时检查:如果某个任务进入 runnable 状态之后,超过 timeout_ms(默认 30 秒,可在 ops 里调小)还没被调度上 CPU,内核判定 BPF 调度器出问题了,立即中止它,把所有任务搬回 CFS。这对付的是最典型的 BPF 调度器 bug:任务被塞进某个自定义 DSQ 之后忘了消费,永远饿死。

第三道:紧急逃生。 内核在 /sys/kernel/sched_ext/ 下暴露状态,同时提供 SysRq 热键强制卸载当前 BPF 调度器。哪怕你的调度器把交互彻底搞死了、SSH 都进不去,物理键盘上的 SysRq 组合键仍然能救回来。

另外 BPF 程序自身也能主动认输:调用 scx_bpf_error("...") 会立刻触发有序卸载,并把错误信息传给用户态。写调度器时,遇到任何「理论上不可能」的分支,直接 scx_bpf_error 认怂,比继续跑下去安全一万倍。


三、核心抽象一:DSQ——理解 sched_ext 的钥匙

如果说 sched_ext 只有一个概念必须吃透,那就是 DSQ(Dispatch Queue,派发队列)

传统 CFS 的模型是「每 CPU 一棵红黑树」,任务插到树里,CPU 从树里挑最左边的。sched_ext 把这个模型拆开了,变成一个两级队列系统

3.1 三种 DSQ

1)Local DSQ(SCX_DSQ_LOCAL)——每 CPU 一个,FIFO

每个 CPU 有且仅有一个 local DSQ。CPU 只会从自己的 local DSQ 头部取任务运行。 这是唯一的出口,所有任务最终都得进到某个 CPU 的 local DSQ 才能跑。

2)Global DSQ(SCX_DSQ_GLOBAL)——全系统一个,FIFO

内核内建的一个全局队列。当某个 CPU 的 local DSQ 空了、且 BPF 的 dispatch() 回调也没塞进东西时,内核会自动从 global DSQ 里捞一个。所以最简单的调度器可以什么都不做,把所有任务扔 global DSQ,就得到一个全局 FIFO。

3)User DSQ——你自己创建的,FIFO 或优先队列

通过 scx_bpf_create_dsq(dsq_id, node) 创建,dsq_id 是一个 u64,你自己分配。这是所有复杂调度器的基石:

  • scx_rusty 给每个「调度域」(一组共享 L3 的 CPU)建一个 DSQ;
  • scx_layered 给每个 layer(一组按 cgroup/进程名规则划分的任务)建一个 DSQ;
  • scx_lavd 建交互 / 非交互两条队列。

User DSQ 的关键能力是它既能当 FIFO 用,也能当按 vtime 排序的优先队列用——取决于你用哪个 kfunc 往里插。

3.2 三个必须记住的 kfunc

/* 把任务 p 插入 dsq_id,FIFO 语义,slice_ns 是分配的时间片 */
void scx_bpf_dsq_insert(struct task_struct *p, u64 dsq_id,
                        u64 slice, u64 enq_flags);

/* 按 vtime 有序插入(优先队列语义),vtime 小的排前面 */
void scx_bpf_dsq_insert_vtime(struct task_struct *p, u64 dsq_id,
                              u64 slice, u64 vtime, u64 enq_flags);

/* 从 dsq_id 里搬一个任务到「当前正在 dispatch 的 CPU」的 local DSQ */
bool scx_bpf_dsq_move_to_local(u64 dsq_id);

小注:这三个 kfunc 在 6.12 之前叫 scx_bpf_dispatch() / scx_bpf_dispatch_vtime() / scx_bpf_consume()。网上大量教程还在用老名字,如果你 clang 报 call to undeclared function,八成就是名字对不上了。这也是 sched_ext「BPF 不是稳定 ABI」的第一次现身。

3.3 数据流全景

把上面拼起来,一个任务从「醒来」到「上 CPU」的完整路径是:

   任务被唤醒 (wakeup / fork / 时间片用完重排队)
              │
              ▼
     ops.select_cpu()      ← 你决定它「想去」哪个 CPU(只是建议)
              │
              ▼
     ops.enqueue()         ← 你决定它进哪个 DSQ
              │
       ┌──────┴────────┬──────────────┐
       ▼               ▼              ▼
  Local DSQ       Global DSQ      User DSQ
  (直接可跑)      (内核兜底消费)   (需要你主动搬运)
       │               │              │
       │               │              ▼
       │               │      ops.dispatch()   ← CPU 饿了,你负责喂
       │               │              │
       │               │    scx_bpf_dsq_move_to_local()
       │               │              │
       └───────────────┴──────────────┘
                       ▼
              CPU 从 Local DSQ 取任务
                       │
                       ▼
                ops.running()   ← 任务上 CPU
                       │
                 (跑 slice 纳秒)
                       │
                       ▼
                ops.stopping()  ← 任务下 CPU

理解这张图,你就理解了 sched_ext 的 80%。

剩下 20% 是各种边界情况:CPU 热插拔、cpumask 亲和性限制、cgroup 层级、SMT 兄弟核、以及任务在 ext 和其他 sched_class 之间迁移。


四、核心抽象二:回调时序状态机

struct sched_ext_ops 里有几十个回调,但真正必须实现的只有一两个。下面按重要性分层讲。

4.1 第一梯队:调度核心四件套

s32 (*select_cpu)(struct task_struct *p, s32 prev_cpu, u64 wake_flags)

任务被唤醒时调用,让你选一个「目标 CPU」。返回值是个建议,不是硬绑定——真正决定任务在哪跑的是它最终进了谁的 local DSQ。

但这里有个非常重要的捷径:如果你在 select_cpu() 里直接调用 scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, slice, 0),任务会被直接塞进你返回的那个 CPU 的 local DSQ,并且 enqueue() 回调会被跳过。这是唤醒路径上最快的一条道,绝大多数交互型调度器都靠它压低唤醒延迟。

内核提供了一个开箱即用的默认实现:

s32 cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);

它会按「prev_cpu 空闲 → 同 SMT 核空闲 → 同 LLC 域空闲 → 全系统找空闲」的顺序找,找到就返回并标记 is_idle。自己写调度器时,先用这个打底,再叠自己的策略。

void (*enqueue)(struct task_struct *p, u64 enq_flags)

任务变成 runnable,需要你决定它进哪个队列。这是策略的主战场:优先级、分组、限流、亲和性,都在这里体现。

void (*dispatch)(s32 cpu, struct task_struct *prev)

当某个 CPU 的 local DSQ 空了、准备进 idle 之前,内核给你最后一次机会:往这个 CPU 的 local DSQ 里塞任务。典型实现就是从你的 user DSQ 里搬:

void BPF_STRUCT_OPS(my_dispatch, s32 cpu, struct task_struct *prev)
{
    scx_bpf_dsq_move_to_local(MY_DSQ_ID);
}

注意 dispatch() 里可以塞多个任务(批量),内核提供 scx_bpf_dispatch_nr_slots() 告诉你还能塞几个。批量塞能降低回调频率,但会牺牲一点及时性——这是个真实的权衡点。

void (*running) / void (*stopping)

任务上/下 CPU 时调用。这是记账的地方:更新 vtime、统计运行时长、判断是不是主动让出(stoppingrunnable 参数为 true 表示还想继续跑,false 表示睡眠了)。

4.2 第二梯队:状态感知

  • runnable / quiescent:任务进入/离开 runnable 状态。适合维护「某个组当前有多少个可运行任务」这类计数。
  • update_idle(s32 cpu, bool idle):某个 CPU 进入/退出 idle。用来维护自己的空闲 CPU 位图(如果你不打算用内建的)。
  • set_cpumask:任务的 CPU 亲和性变了。如果你自己缓存了 cpumask,这里必须同步,否则会把任务派到它跑不了的 CPU 上,直接触发内核报错。
  • cpu_acquire / cpu_release:CPU 被更高优先级的 sched_class(比如 RT 任务)抢走 / 还回来。在混跑 RT 的环境里很重要。

4.3 第三梯队:生命周期与 cgroup

  • init / exit:调度器加载/卸载。创建 DSQ 在 init 里做。
  • init_task / exit_task:每个任务被 ext 类接管 / 离开时调用。给任务分配 per-task 存储(task_storage map)在这里做。
  • enable / disable:任务开始/停止被 BPF 调度器管理。
  • cgroup_init / cgroup_exit / cgroup_move / cgroup_set_weight:cgroup v2 CPU 控制器的对接点。想做容器级 QoS,这一组是必修。

4.4 时间片:SCX_SLICE_DFL

sched_ext 的默认时间片是 SCX_SLICE_DFL,等于 20ms。比 CFS 的动态时间片(通常几毫秒)大得多。

这个默认值背后的逻辑是:sched_ext 假设你会自己管时间片。20ms 是个「不容易出事」的保守值——足够长,避免频繁切换;但对交互型负载偏长了。

写交互型调度器时,一个常见做法是按当前可运行任务数动态压缩时间片

static u64 calc_slice(void)
{
    u32 nr = scx_bpf_dsq_nr_queued(SHARED_DSQ) + 1;
    u64 slice = SLICE_MAX / nr;
    return slice < SLICE_MIN ? SLICE_MIN : slice;
}

负载轻时给足时间片保吞吐,负载重时切碎保响应。scx_bpfland、scx_lavd 都用了这个思路的变体。

还有一个技巧:在 ops.running() 里可以直接改 p->scx.slice,实现「运行中续期」或「提前收回」。


五、实战:从零手写四个逐步进化的调度器

理论说完了,上代码。我们从一个最小可用版本开始,一步步加东西。所有代码都是可编译可运行的,你需要的环境:

# 内核 6.12+,且 CONFIG_SCHED_CLASS_EXT=y
grep -E 'SCHED_CLASS_EXT|DEBUG_INFO_BTF' /boot/config-$(uname -r)

# 工具链
sudo apt install clang llvm libbpf-dev linux-tools-common pkg-config
clang --version   # 需要 17+,struct_ops 相关支持较新

# 生成 vmlinux.h
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

还需要一份 sched_ext 的公共头文件(scx/common.bpf.h),可以从 sched-ext/scx 仓库的 scheds/include/ 目录取。

5.1 v1:全局 FIFO(最小可用,40 行)

/* minimal.bpf.c —— 一个全系统 FIFO 调度器 */
#include <scx/common.bpf.h>

char _license[] SEC("license") = "GPL";

#define SHARED_DSQ  0

/* 加载时创建共享 DSQ */
s32 BPF_STRUCT_OPS_SLEEPABLE(minimal_init)
{
    return scx_bpf_create_dsq(SHARED_DSQ, -1);
}

/* 任务变 runnable:一律扔进共享 DSQ */
void BPF_STRUCT_OPS(minimal_enqueue, struct task_struct *p, u64 enq_flags)
{
    scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
}

/* CPU 饿了:从共享 DSQ 搬一个过来 */
void BPF_STRUCT_OPS(minimal_dispatch, s32 cpu, struct task_struct *prev)
{
    scx_bpf_dsq_move_to_local(SHARED_DSQ);
}

void BPF_STRUCT_OPS(minimal_exit, struct scx_exit_info *ei)
{
    UEI_RECORD(uei, ei);
}

SEC(".struct_ops.link")
struct sched_ext_ops minimal_ops = {
    .enqueue    = (void *)minimal_enqueue,
    .dispatch   = (void *)minimal_dispatch,
    .init       = (void *)minimal_init,
    .exit       = (void *)minimal_exit,
    .name       = "minimal",
};

这就是一个能跑的完整调度器。 40 行,你的桌面环境可以在它上面正常运行——虽然会明显卡顿,因为它是纯 FIFO,交互任务和 CPU 密集任务一视同仁。

用户态 loader:

/* minimal.c */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <bpf/bpf.h>
#include <scx/common.h>
#include "minimal.bpf.skel.h"

static volatile int exit_req;
static void sigint_handler(int _) { exit_req = 1; }

int main(int argc, char **argv)
{
    struct minimal_bpf *skel;
    struct bpf_link *link;

    libbpf_set_strict_mode(LIBBPF_STRICT_ALL);
    signal(SIGINT,  sigint_handler);
    signal(SIGTERM, sigint_handler);

    skel = SCX_OPS_OPEN(minimal_ops, minimal_bpf);
    SCX_OPS_LOAD(skel, minimal_ops, minimal_bpf, uei);
    link = SCX_OPS_ATTACH(skel, minimal_ops, minimal_bpf);

    printf("minimal scheduler attached. Ctrl-C to detach.\n");
    while (!exit_req && !UEI_EXITED(skel, uei))
        sleep(1);

    bpf_link__destroy(link);
    UEI_REPORT(skel, uei);
    minimal_bpf__destroy(skel);
    return 0;
}

Makefile:

CLANG      ?= clang
ARCH       := $(shell uname -m | sed 's/x86_64/x86/')
BPF_CFLAGS := -g -O2 -target bpf -D__TARGET_ARCH_$(ARCH) \
              -I. -Iinclude -mcpu=v3

all: minimal

vmlinux.h:
	bpftool btf dump file /sys/kernel/btf/vmlinux format c > $@

minimal.bpf.o: minimal.bpf.c vmlinux.h
	$(CLANG) $(BPF_CFLAGS) -c $< -o $@

minimal.bpf.skel.h: minimal.bpf.o
	bpftool gen skeleton $< name minimal_bpf > $@

minimal: minimal.c minimal.bpf.skel.h
	$(CC) -g -O2 -Iinclude $< -lbpf -lelf -lz -o $@

clean:
	rm -f minimal *.o *.skel.h vmlinux.h

.PHONY: all clean

跑起来:

make && sudo ./minimal

另开一个终端验证确实生效了:

cat /sys/kernel/sched_ext/root/ops    # 应该输出 minimal
cat /sys/kernel/sched_ext/state       # enabled

5.2 v2:唤醒路径直投空闲 CPU

v1 的最大问题是:任务醒来之后要先进共享 DSQ,等目标 CPU 下一轮 dispatch() 才能捞到,白白多了一跳。我们用 select_cpu() 的捷径优化掉:

s32 BPF_STRUCT_OPS(v2_select_cpu, struct task_struct *p,
                   s32 prev_cpu, u64 wake_flags)
{
    bool is_idle = false;
    s32 cpu;

    cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);
    if (is_idle) {
        /* 找到空闲 CPU:直接投进它的 local DSQ,跳过 enqueue */
        scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);
        __sync_fetch_and_add(&nr_direct_dispatch, 1);
    }
    return cpu;
}

就这十行,桌面卡顿感会有肉眼可见的改善。原因很直白:在有空闲 CPU 的情况下,唤醒的任务不再需要排队。

这里 scx_bpf_select_cpu_dfl() 内部会按 CPU 拓扑逐层找:先看 prev_cpu 是否空闲(cache 最热),再看同物理核的 SMT 兄弟,再看同 LLC 域,最后全局扫。这套逻辑自己实现要写几百行,直接用内核的就好。

5.3 v3:加权虚拟时间——把公平性找回来

FIFO 的第二个问题是没有公平性:一个疯狂 CPU 密集的任务会一直占着,nice 值完全失效。我们引入 vtime(虚拟时间),复刻 CFS 的核心思想。

原理:每个任务维护一个 vtime,跑得越多、权重越低,vtime 涨得越快。DSQ 按 vtime 排序,永远先跑 vtime 最小的。

#include <scx/common.bpf.h>

char _license[] SEC("license") = "GPL";

#define SHARED_DSQ  0
#define SLICE_NS    (5ULL * 1000 * 1000)   /* 5ms */

/* 全局虚拟时钟:所有任务 vtime 的下界 */
static u64 vtime_now;

/* per-task 存储 */
struct task_ctx {
    u64 exec_runtime;   /* 累计运行时间,用于统计 */
};

struct {
    __uint(type, BPF_MAP_TYPE_TASK_STORAGE);
    __uint(map_flags, BPF_F_NO_PREALLOC);
    __type(key, int);
    __type(value, struct task_ctx);
} task_ctxs SEC(".maps");

static inline bool vtime_before(u64 a, u64 b)
{
    return (s64)(a - b) < 0;
}

s32 BPF_STRUCT_OPS(v3_init_task, struct task_struct *p,
                   struct scx_init_task_args *args)
{
    struct task_ctx *tctx;

    tctx = bpf_task_storage_get(&task_ctxs, p, 0,
                                BPF_LOCAL_STORAGE_GET_F_CREATE);
    if (!tctx)
        return -ENOMEM;

    /* 新任务的 vtime 对齐到当前全局时钟,避免"新任务无限插队" */
    p->scx.dsq_vtime = vtime_now;
    return 0;
}

void BPF_STRUCT_OPS(v3_enqueue, struct task_struct *p, u64 enq_flags)
{
    u64 vtime = p->scx.dsq_vtime;

    /*
     * 关键防护:睡了很久的任务 vtime 会远小于 vtime_now,
     * 如果不 clamp,它醒来后会长时间霸占 CPU("睡眠红利"失控)。
     * 这里最多只给它一个 slice 的补偿。
     */
    if (vtime_before(vtime, vtime_now - SLICE_NS))
        vtime = vtime_now - SLICE_NS;

    scx_bpf_dsq_insert_vtime(p, SHARED_DSQ, SLICE_NS, vtime, enq_flags);
}

void BPF_STRUCT_OPS(v3_dispatch, s32 cpu, struct task_struct *prev)
{
    scx_bpf_dsq_move_to_local(SHARED_DSQ);
}

void BPF_STRUCT_OPS(v3_running, struct task_struct *p)
{
    /* 全局时钟单调前进 */
    if (vtime_before(vtime_now, p->scx.dsq_vtime))
        vtime_now = p->scx.dsq_vtime;
}

void BPF_STRUCT_OPS(v3_stopping, struct task_struct *p, bool runnable)
{
    struct task_ctx *tctx;
    u64 used;

    /*
     * p->scx.slice 是"剩余时间片"。
     * 实际用掉的 = 分配的 - 剩余的。
     */
    used = SLICE_NS - p->scx.slice;

    /*
     * 核心公式:vtime 增量 = 实际运行时间 * 100 / 权重
     * weight 默认 100(nice 0),nice 越小 weight 越大,vtime 涨得越慢,
     * 于是在优先队列里排得越靠前 —— 优先级就实现了。
     */
    p->scx.dsq_vtime += used * 100 / p->scx.weight;

    tctx = bpf_task_storage_get(&task_ctxs, p, 0, 0);
    if (tctx)
        tctx->exec_runtime += used;
}

SEC(".struct_ops.link")
struct sched_ext_ops v3_ops = {
    .select_cpu = (void *)v2_select_cpu,
    .enqueue    = (void *)v3_enqueue,
    .dispatch   = (void *)v3_dispatch,
    .running    = (void *)v3_running,
    .stopping   = (void *)v3_stopping,
    .init_task  = (void *)v3_init_task,
    .init       = (void *)v3_init,
    .exit       = (void *)v3_exit,
    .name       = "v3_vtime",
};

这段代码有三个细节值得单独拎出来:

vtime_before() 为什么要转成 s64 再比较?

因为 vtime 是 u64 且会溢出回绕。直接比 a < b 在回绕点附近会得出完全错误的结论。(s64)(a - b) < 0 利用补码的性质,只要两个值的差距不超过 2^63,判断就是对的。这是内核里 time_before() 宏的同款套路,抄就完了。

② 为什么要 clamp 睡眠任务的 vtime?

假设一个进程睡了 10 分钟,它的 vtime 停在 10 分钟前的值。醒来后一插队,因为 vtime 极小,它会一直排在队首,连续独占 CPU 直到 vtime 追上其他任务——这期间整个系统对其他任务是饥饿的。clamp 到 vtime_now - SLICE_NS 意味着「最多给你一个时间片的插队特权」。

这个 bug 我见过至少三次有人踩,现象是「跑一段时间后系统偶发性卡死几秒」,极难定位。

used * 100 / p->scx.weight 的除法

BPF verifier 允许除法,但要小心除零。p->scx.weight 由内核保证 ≥ 1,所以这里安全。但如果你用自己算出来的权重,一定要显式判 0,否则加载时 verifier 可能过、运行时直接除零 panic(BPF 的除零返回 0 而非 panic,但结果会离谱)。

5.4 v4:大小核感知——让 sched_ext 真正发挥价值

到这一步为止,我们做的事情 CFS 都能做。真正体现 sched_ext 价值的,是加入内核通用调度器不方便做的领域知识

以异构核心(P-core / E-core)为例。策略:延迟敏感的任务优先放大核,批处理任务放小核。「延迟敏感」怎么判断?一个简单有效的启发式:平均单次运行时长短、且唤醒频繁的任务,大概率是交互型的。

#define BIG_DSQ     0
#define LITTLE_DSQ  1

/* 由用户态在启动时填充:哪些 CPU 是大核 */
const volatile u64 big_core_mask = 0;
/* 交互型判定阈值:单次平均运行 < 1ms */
#define INTERACTIVE_THRESH_NS  (1ULL * 1000 * 1000)

struct task_ctx {
    u64 exec_runtime;    /* 累计运行时间 */
    u64 nr_slices;       /* 上过多少次 CPU */
    bool is_interactive;
};

static bool cpu_is_big(s32 cpu)
{
    if (cpu < 0 || cpu >= 64)
        return false;
    return !!(big_core_mask & (1ULL << cpu));
}

void BPF_STRUCT_OPS(v4_enqueue, struct task_struct *p, u64 enq_flags)
{
    struct task_ctx *tctx = bpf_task_storage_get(&task_ctxs, p, 0, 0);
    u64 dsq = LITTLE_DSQ;
    u64 vtime = p->scx.dsq_vtime;

    if (vtime_before(vtime, vtime_now - SLICE_NS))
        vtime = vtime_now - SLICE_NS;

    if (tctx && tctx->is_interactive)
        dsq = BIG_DSQ;

    scx_bpf_dsq_insert_vtime(p, dsq, SLICE_NS, vtime, enq_flags);
}

void BPF_STRUCT_OPS(v4_dispatch, s32 cpu, struct task_struct *prev)
{
    if (cpu_is_big(cpu)) {
        /* 大核:先服务交互队列,空了再去帮小核干活(work stealing) */
        if (scx_bpf_dsq_move_to_local(BIG_DSQ))
            return;
        scx_bpf_dsq_move_to_local(LITTLE_DSQ);
    } else {
        /* 小核:只做批处理,不抢交互任务 */
        if (scx_bpf_dsq_move_to_local(LITTLE_DSQ))
            return;
        /*
         * 例外:如果交互队列积压严重,小核也来搭把手,
         * 否则大核成为瓶颈时交互任务反而更慢。
         */
        if (scx_bpf_dsq_nr_queued(BIG_DSQ) > 8)
            scx_bpf_dsq_move_to_local(BIG_DSQ);
    }
}

void BPF_STRUCT_OPS(v4_stopping, struct task_struct *p, bool runnable)
{
    struct task_ctx *tctx;
    u64 used = SLICE_NS - p->scx.slice;

    p->scx.dsq_vtime += used * 100 / p->scx.weight;

    tctx = bpf_task_storage_get(&task_ctxs, p, 0, 0);
    if (!tctx)
        return;

    tctx->exec_runtime += used;
    tctx->nr_slices++;

    /* 每 64 次重新评估一次分类,并做指数衰减防止历史包袱过重 */
    if (tctx->nr_slices >= 64) {
        u64 avg = tctx->exec_runtime / tctx->nr_slices;
        tctx->is_interactive = (avg < INTERACTIVE_THRESH_NS);
        tctx->exec_runtime >>= 1;
        tctx->nr_slices   >>= 1;
    }
}

用户态启动时探测大核(以 Intel P/E core 为例,可以读 /sys/devices/cpu_core/cpus):

static u64 detect_big_cores(void)
{
    FILE *f = fopen("/sys/devices/cpu_core/cpus", "r");
    char buf[256] = {0};
    u64 mask = 0;

    if (!f) {
        /* 非异构平台:全部当大核 */
        return ~0ULL;
    }
    if (fgets(buf, sizeof(buf), f))
        mask = parse_cpu_list(buf);   /* 解析 "0-7" / "0,2,4" 这类格式 */
    fclose(f);
    return mask ?: ~0ULL;
}

/* main() 里,在 LOAD 之前: */
skel->rodata->big_core_mask = detect_big_cores();

这个调度器在异构笔记本上的效果是可感知的:编译大项目的时候,浏览器滚动依然跟手。因为 make -j16 的编译进程平均运行时长远超 1ms,被判定为批处理,全部压到 E-core 上,P-core 留给了交互任务。

而这套逻辑,你在 EEVDF 里是没法表达的——它只有 nice 值这一个旋钮,而且 nice 是静态的、需要人工设置的。


六、Verifier 与工程约束:写 BPF 调度器的十条纪律

BPF 的沙箱是有代价的。下面这些约束,是我认为每个动手写 sched_ext 调度器的人都会撞一遍的墙。

1)循环必须有界。bpf_loop() 或者带常量上界的 #pragma unroll。想遍历所有 CPU?bpf_for(i, 0, nr_cpus) 里的 nr_cpus 必须是 verifier 能确定上界的值——通常做法是定义一个 #define MAX_CPUS 512 然后循环到它,内部判断跳过无效的。

2)栈只有 512 字节。 大结构体一律放 map 或者 BPF arena。在调度器里这个限制很容易撞到,因为你经常想在栈上放个 cpumask(512 CPU 的 mask 就是 64 字节,几个一叠就爆)。

3)cpumask 必须用 kfunc 管理,且有引用计数。

struct bpf_cpumask *mask;

mask = bpf_cpumask_create();
if (!mask)
    return -ENOMEM;

bpf_cpumask_copy(mask, p->cpus_ptr);
bpf_cpumask_and(mask, (const struct cpumask *)mask, big_cpumask);
/* ...使用... */
bpf_cpumask_release(mask);   /* ★ 忘了这行,verifier 直接拒绝加载 */

verifier 会追踪引用计数,任何一条返回路径上漏了 release 都过不了。这其实是好事——它把内存泄漏问题从「上线三天后 OOM」变成了「编译时报错」。

4)访问 task_struct 字段要在 RCU 保护下。bpf_rcu_read_lock() / bpf_rcu_read_unlock() 包起来,或者确认当前回调上下文本身就在 RCU 临界区里。搞错了会得到 R1 must be referenced or trusted 这类让人抓狂的报错。

5)kfunc 有上下文限制。 比如 scx_bpf_dsq_insert() 只能在 select_cpu() / enqueue() / dispatch() 里调;scx_bpf_dsq_move_to_local() 只能在 dispatch() 里调。在错误的回调里调用,轻则 verifier 拒绝,重则运行时 scx_bpf_error

6)没有浮点数。 所有权重、比例计算都得用定点数。常见做法是放大 1024 倍做整数运算:

#define FIXED_SHIFT 10
#define TO_FIXED(x)   ((x) << FIXED_SHIFT)
#define FROM_FIXED(x) ((x) >> FIXED_SHIFT)

7)per-CPU 数据用 BPF_MAP_TYPE_PERCPU_ARRAY,不要用全局变量做累加。 全局变量在多核上没有原子性保证,你会得到一堆丢失更新。要么用 per-CPU map,要么用 __sync_fetch_and_add()

8)不要在调度回调里做重活。 这些回调跑在调度热路径上,持有 runqueue 锁。一次 bpf_printk() 就是几微秒,在 96 核机器上每秒调用几十万次,直接把系统拖垮。调试打印一定要加采样

if ((nr_calls++ & 0xFFF) == 0)
    bpf_printk("dispatch cpu=%d queued=%d", cpu, nr);

9)BPF arena 是新的救星。 内核 6.9+ 引入的 BPF arena 允许在 BPF 程序里用近似「普通指针」的方式操作一大块共享内存,可以在里面建链表、红黑树。scx 仓库里已经有用 arena 实现复杂数据结构的调度器(比如 scx_p2dq 的部分实现)。如果你的调度算法需要真正的动态数据结构,arena 是目前唯一现实的路。

10)出错就认输。 在任何「不应该发生」的分支里调 scx_bpf_error()

if (!tctx) {
    scx_bpf_error("task_ctx lookup failed for pid %d", p->pid);
    return;
}

这会触发有序卸载,用户态能拿到完整错误信息和堆栈。比让调度器带着错误状态继续跑(然后在半小时后以一种完全无关的方式炸掉)好太多。


七、把策略搬到用户态:scx_rustland 模式

BPF 里写复杂算法很痛苦——没有浮点、没有动态内存、没有标准库、verifier 处处设卡。于是 sched_ext 生态里演化出了另一种架构:BPF 只做机械的搬运工,真正的决策放在用户态。

代表作是 scx_rustland(以及它抽出来的框架 scx_rustland_core)。架构长这样:

        内核态 (BPF)                      用户态 (Rust)
   ┌──────────────────────┐        ┌────────────────────────┐
   │  ops.enqueue()       │        │                        │
   │      │               │  ring  │  1. 读取 queued 任务     │
   │      └──► queued ────────────►│  2. 跑任意复杂的算法     │
   │                      │ buffer │     (浮点/BTreeMap/     │
   │                      │        │      机器学习模型...)    │
   │  ops.dispatch()      │        │  3. 算出 (pid, cpu,     │
   │      ▲               │        │        slice) 三元组     │
   │      └──── dispatched◄────────┤                        │
   │           (消费并投递)│  ring  │                        │
   └──────────────────────┘ buffer └────────────────────────┘

BPF 侧的 enqueue() 把任务信息写进环形缓冲区,用户态 Rust 进程读出来,用任意复杂度的算法决定这个任务该去哪个 CPU、给多长时间片,再把结果写回另一个环形缓冲区,BPF 侧的 dispatch() 消费执行。

这不是很慢吗? 确实有额外开销,主要是两次内存拷贝和一次用户态唤醒。但实测下来在很多场景可以接受,原因有几个:

  1. 调度决策不是每次 dispatch() 都需要重算,用户态可以批量处理并预填多个决策;
  2. 用户态调度进程自己被 BPF 侧特殊对待(放在最高优先级队列),保证它不会被自己的策略饿死——这是个精巧的自举设计;
  3. 对于「策略复杂但决策频率不高」的场景(比如按业务标签做长周期的资源分配),用户态的灵活性远远压过那点延迟。

scx_rusty 走的是折中路线:BPF 侧负责快路径(本域内的选核、派发),用户态 Rust 侧负责慢路径(跨调度域的负载均衡,每隔几百毫秒跑一次)。这个「快慢分离」的架构,我认为是目前 sched_ext 生态里最值得借鉴的工程模式。


八、生产调度器全景与选型决策树

scx 仓库里已经有二十来个调度器。挑几个真正在生产/发行版里被用起来的说。

8.1 主要玩家

scx_lavd(Latency-criticality Aware Virtual Deadline)

Valve 主导开发,用 Rust + BPF 写成,目标是游戏帧率稳定性。核心思想是给每个任务算一个「延迟关键度」评分,评分依据包括唤醒频率、等待时间、被唤醒者的数量(唤醒链)等,然后按虚拟截止时间调度。它是 SteamOS 上的默认选择,在 Steam Deck 这种异构低功耗 APU 上,对 1% low FPS(帧率稳定性指标)的改善是它最被称道的地方。

scx_bpfland

Canonical 的内核工程师主导,纯 BPF 实现,目标是交互延迟。它维护两条队列(交互任务 / 普通任务),按「任务的自愿上下文切换频率」来分类——频繁主动让出 CPU 的任务被判定为交互型。这个信号比我们 v4 里用的「平均运行时长」更准。它是很多 Linux 桌面用户的日常选择。

scx_rusty

多域(multi-domain)通用调度器。它按 LLC/NUMA 拓扑把 CPU 划成若干 domain,每个 domain 一个 DSQ,尽量让任务待在原 domain(保 cache 局部性),用户态 Rust 侧定期做跨域负载均衡。这是最接近「通用生产调度器」定位的一个,在多路服务器上表现稳健。

scx_layered

Meta 生产环境的主力,思路完全不同:它是配置驱动的。你用 JSON 描述一组「层」(layer),每层有匹配规则(cgroup 路径、进程名、命令行、nice 值等)和调度策略(独占哪些 CPU、时间片多长、是否可被抢占):

[
  {
    "name": "latency_critical",
    "matches": [[{"CgroupPrefix": "system.slice/frontend-"}]],
    "kind": {"Grouped": {
      "cpus_range": [8, 32],
      "util_range": [0.6, 0.9],
      "preempt": true,
      "slice_us": 1000
    }}
  },
  {
    "name": "batch",
    "matches": [[{"CgroupPrefix": "system.slice/batch-"}]],
    "kind": {"Confined": {
      "cpus_range": [1, 16],
      "util_range": [0.0, 0.5],
      "slice_us": 20000
    }}
  }
]

它的价值在于:运维可以在不写一行 BPF 代码的情况下,表达"这类服务要低延迟、那类服务只能吃剩下的"这种业务级意图。 对大规模混部(在线+离线共池)来说,这是刚需。

其他值得知道的

  • scx_flash:EDF(最早截止时间优先)变体,主打确定性延迟;
  • scx_p2dq:多队列 + 工作窃取,用 arena 实现,性能取向;
  • scx_simple / scx_qmap / scx_central:官方示例,学习用,scx_central 演示「单 CPU 集中决策」这种极端架构;
  • scx_chaos故障注入调度器,故意制造调度延迟、随机抢占,用来测试你的应用在恶劣调度条件下会不会崩——这是个被严重低估的测试工具;
  • scx_mitosis:实验性,按 cgroup 动态划分 CPU「细胞」,探索容器隔离的新形态。

8.2 选型决策树

你的场景是?
│
├─ 桌面 / 游戏 / 笔记本
│   ├─ 有大小核 或 玩游戏为主 ──────────► scx_lavd
│   └─ 通用桌面,要跟手 ────────────────► scx_bpfland
│
├─ 服务器:单一类型负载
│   ├─ 多路 NUMA / 多 CCD ──────────────► scx_rusty
│   └─ 延迟确定性要求高(实时性边缘)──► scx_flash
│
├─ 服务器:混部(在线 + 离线共池)
│   └─ ─────────────────────────────────► scx_layered
│       (先用 Confined 层把离线圈起来,
│         再逐步给在线层加 preempt)
│
├─ 我有非常特殊的领域知识
│   ├─ 策略简单,能用 BPF 表达 ─────────► 自己写(参考本文 v3/v4)
│   └─ 策略复杂,需要浮点/复杂数据结构 ► 基于 scx_rustland_core 写
│
└─ 我只是想测试应用的抗压性 ────────────► scx_chaos

8.3 上线的正确姿势

别一上来就全量。 推荐路径:

# 1. 先在测试机上跑,观察是否有 exit(异常退出)
sudo scx_rusty --verbose

# 2. 建立基线:换调度器前后跑同一套压测
#    关注 P99/P999 延迟,不要只看吞吐和平均值

# 3. 用 SWITCH_PARTIAL 模式做灰度(如果调度器支持)
#    只把目标服务的进程切到 SCHED_EXT

# 4. 生产上跑 systemd 单元,配好 Restart 和监控
sudo systemctl enable --now scx_loader

核心风控点:一定要有「调度器异常退出」的告警。 BPF 调度器被内核踢掉后,系统会自动回退到 EEVDF——机器不会挂,但你的性能预期全部失效,而且没有任何显式报错,只有 dmesg 里一行日志。不监控的话,你可能几周后才发现「诶怎么延迟又回去了」。


九、可观测与调试

9.1 sysfs 接口

# 当前是否启用、用的哪个调度器
cat /sys/kernel/sched_ext/state
cat /sys/kernel/sched_ext/root/ops

# 切换次数、当前 enable 序号(每次加载 +1)
cat /sys/kernel/sched_ext/enable_seq

# 看看是不是被踢过
sudo dmesg | grep -i sched_ext

典型的「被踢掉」日志长这样:

sched_ext: BPF scheduler "my_sched" errored, disabling
sched_ext: runnable task stall (kworker/3:1[142] failed to run for 5.2s)

看到 runnable task stall 就是典型的任务塞进 DSQ 之后没人消费——回去检查 dispatch() 逻辑,八成是某个 DSQ 没有对应的消费者。

9.2 退出码

用户态 loader 通过 UEI_REPORT 拿到的退出信息包含 kind

退出类型含义排查方向
SCX_EXIT_UNREG用户态主动卸载正常
SCX_EXIT_SYSRQSysRq 强制卸载有人按了热键
SCX_EXIT_ERRORBPF 侧调用了 scx_bpf_error看 msg 里的自定义信息
SCX_EXIT_ERROR_STALL看门狗超时DSQ 消费逻辑漏了
SCX_EXIT_ERROR_BPFBPF 运行时错误除零、非法访问
SCX_EXIT_UNREG_KERN内核主动注销(如热插拔冲突)检查 CPU hotplug 处理

9.3 scxtop

scx 仓库提供了 scxtop,一个 TUI 工具,可以实时看:

  • 每个 DSQ 的排队长度和延迟分布;
  • 每个 CPU 的利用率、上下文切换率;
  • 任务在 DSQ 之间的流转。
sudo scxtop

调优 DSQ 划分时,这个工具几乎是必需品——你能直接看到「哪个 DSQ 积压了」。

9.4 性能剖析

想知道调度回调本身耗了多少 CPU:

# 看 BPF 程序的运行统计(需要先开启)
sudo sysctl kernel.bpf_stats_enabled=1
sudo bpftool prog show | grep -A2 struct_ops

# 输出里的 run_time_ns / run_cnt 就是每次回调的平均耗时

经验值:单次回调平均耗时应该在几百纳秒到 1~2 微秒量级。 如果你看到十几微秒,说明回调里做了不该做的事(复杂循环、大量 map 查找、printk)。

调度器造成的整体开销可以用最朴素的方式量化:

# 换调度器前后各跑一次,比对
perf stat -e context-switches,cpu-migrations,cache-misses \
    -a -- sleep 60

十、生产踩坑清单

按我见过的「让人熬夜」程度排序:

1)DSQ 建了没人消费 → 5 秒后整机僵住再自动恢复。
最经典的翻车。任何 scx_bpf_create_dsq() 都必须在 dispatch() 里有对应的 scx_bpf_dsq_move_to_local() 路径。写完调度器,先画一遍「每个 DSQ 的生产者和消费者」表格。

2)忘了处理 p->cpus_ptr 亲和性。
任务被 taskset 绑定到 CPU 0-3,你把它派给了 CPU 7 → 内核直接 error 卸载。所有自定义选核逻辑都必须先 bpf_cpumask_test_cpu(cpu, p->cpus_ptr) 尤其注意 per-CPU kworker,它们的 cpumask 只有一个 bit。

3)vtime 睡眠红利没 clamp。
前面 v3 讲过。现象是偶发卡顿,且只在有长睡进程的机器上出现,测试环境复现不了。

4)在回调里 bpf_printk 打爆日志。
高负载下每秒几十万次 printk,trace_pipe 打满,系统被 I/O 拖死。调试打印必须采样,上线前必须删干净。

5)CPU 热插拔没处理。
云上缩容、echo 0 > /sys/devices/system/cpu/cpu5/online、或者 suspend/resume,都会触发 CPU 上下线。如果你缓存了 CPU 数量或者 cpumask,必须在 update_idle / cpu_online 相关路径里同步。测试方法:写个脚本疯狂上下线 CPU,跑一小时。

6)和 RT 任务混跑时的优先级反转。
ext 类排在 rt 下面。如果系统里有 SCHED_FIFO 任务(比如音频线程、某些驱动线程)长时间占用 CPU,你的 BPF 调度器管理的任务会被完全饿死,直接触发看门狗。cpu_release / cpu_acquire 回调感知这种情况,必要时把任务迁走。

7)cgroup 权重没接,容器 CPU limit 失效。
如果你没实现 cgroup_set_weight,Docker 的 --cpu-shares、K8s 的 CPU requests 会静默失效。这在容器环境里是重大事故——你以为在做资源隔离,实际上大家在裸奔。

8)内核版本升级导致 kfunc 改名。
scx_bpf_dispatchscx_bpf_dsq_insert 这种改名是常态。生产环境务必锁定 scx 版本和内核版本的配对关系,scx 仓库的 BREAKING_CHANGES.md 要在升级前读一遍。

9)BTF 不匹配导致加载失败。
容器里编译、宿主机上加载,如果两边内核 BTF 不一致会挂。CO-RE 能处理字段偏移变化,但处理不了字段消失。最稳的做法是在目标内核版本上编译,或者用 bpftool btf dump 出目标机器的 BTF 做交叉编译。

10)压测方法不对,得出错误结论。
只看平均延迟和吞吐,看不出调度器的差异。必须看 P99 / P999,必须看长尾。 推荐工具:schbench(专门测调度延迟分布)、hackbench(测上下文切换开销)、stress-ng --cpu-sched。还有一点:测试时长要够,很多调度器问题要跑够十几分钟、系统进入稳态之后才暴露。


十一、总结:调度器进入「可编程」时代意味着什么

回头看,sched_ext 做的事情,和过去十年基础设施领域反复发生的事情是同一件:把一个僵化的内置策略,变成一个可编程的扩展点。

  • 网络:iptables 的固定规则链 → eBPF/XDP 任意可编程数据面;
  • 存储:固定的 I/O 调度器 → blk-mq 多队列 + 可插拔;
  • 可观测:预定义的埋点 → eBPF 任意探针;
  • 现在轮到了:固定的 CFS/EEVDF → sched_ext 任意调度策略。

这个模式的共同结论是:当扩展点开放之后,创新速度不是线性提升,而是指数提升,因为参与者从「几十个内核 maintainer」变成了「几万个应用工程师」。eBPF 在可观测领域已经证明过一次了。

那么,你应该现在就上吗?

我的判断是分层的:

桌面/工作站用户:可以,现在就能上。 scx_lavd / scx_bpfland 已经足够成熟,收益(交互跟手度)是能直接感知的,风险(掉回 EEVDF)是可接受的。发行版包管理器一条命令的事。

服务器通用场景:谨慎观望,但值得测。 如果你的服务是「单一类型、CPU 不吃紧」,换调度器的收益接近于零,别折腾。真正有收益的是那些CPU 是瓶颈、且负载有明显异构性的场景。

大规模混部场景:这可能是近几年最值得投入的方向之一。 在线离线共池的 CPU 隔离,用 cgroup CPU 配额做是很粗糙的(限流会引入延迟毛刺,共享又会互相干扰),而 scx_layered 这类调度器能在调度层面表达业务意图,天花板高得多。如果你在做这块,这是必修课。

做特殊硬件/特殊负载的团队:这是你们的机会窗口。 异构核心、CXL 内存分层、GPU 协同调度、实时音视频——这些场景里通用调度器一定是次优的,而现在你有了一个不用维护内核 fork 就能定制调度器的通道。

一个更长期的判断

我觉得三到五年内,会出现「调度策略即配置」这一层。就像今天没人手写 iptables 规则、而是声明 NetworkPolicy 一样,未来大概率会有某种高层描述语言,让运维声明「这个 workload 的延迟敏感度是 X、可容忍抖动是 Y」,然后由一个编译器/运行时生成或选择对应的 BPF 调度策略。scx_layered 的 JSON 配置就是这个方向的第一个雏形。

到那时候,「我们把 CPU 调度器换了」会像今天说「我们换了个负载均衡算法」一样平常。

而现在,我们正好站在这个转变的开头。四十行 BPF 代码,就是入场券。


附:动手清单

想真正上手,按这个顺序走:

# 1. 确认内核支持
grep CONFIG_SCHED_CLASS_EXT /boot/config-$(uname -r)

# 2. 装现成的调度器先感受一下
#    Ubuntu/Debian
sudo apt install scx-scheds
#    Arch
sudo pacman -S scx-scheds
#    Fedora
sudo dnf install scx-scheds

# 3. 跑起来(另开终端跑你的日常负载,感受区别)
sudo scx_bpfland     # 桌面交互
sudo scx_rusty       # 服务器通用
sudo scxtop          # 实时观测

# 4. 克隆源码,从 scx_simple 读起
git clone https://github.com/sched-ext/scx
cd scx/scheds/c
less scx_simple.bpf.c    # 全文不到 200 行,是最好的入门材料

# 5. 照着本文的 v1 → v4 自己写一遍

读完 scx_simple.bpf.c、再自己写出一个能跑的 v3,你对 Linux 调度器的理解会比读十篇 CFS 源码分析都深——因为你被迫做了每一个决策

内核文档在 Documentation/scheduler/sched-ext.rst,是最权威也最简洁的参考,强烈建议对着源码读一遍。

推荐文章

api远程把word文件转换为pdf
2024-11-19 03:48:33 +0800 CST
PHP如何进行MySQL数据备份?
2024-11-18 20:40:25 +0800 CST
PyMySQL - Python中非常有用的库
2024-11-18 14:43:28 +0800 CST
Vue3中如何处理组件的单元测试?
2024-11-18 15:00:45 +0800 CST
Vue3中如何处理WebSocket通信?
2024-11-19 09:50:58 +0800 CST
全栈工程师的技术栈
2024-11-19 10:13:20 +0800 CST
Redis函数在PHP中的使用方法
2024-11-19 04:42:21 +0800 CST
【SQL注入】关于GORM的SQL注入问题
2024-11-19 06:54:57 +0800 CST
程序员茄子在线接单