Linux 内核调度器自诞生起就是编译期决定的——O(n) 到 O(1) 到 CFS 到 EEVDF,每次调度策略变更都需要修改内核源码、重新编译、重启系统。这导致一种普遍的工程困境:你的游戏引擎需要优先调度渲染线程,你的数据库需要让 WAL writer 不受后台 vacuum 影响,但调度器对所有这些专有需求一视同仁——因为它是编译进内核的通用策略,假设 “所有任务同等重要”。
sched_ext(6.12+,CONFIG_SCHED_CLASS_EXT)改变了这一点。它引入了一个新的调度类
ext,其调度策略不是编译进内核的,而是由 BPF
程序在运行时提供的。本文拆解 sched_ext
的内核接口:struct sched_ext_ops
的每个回调语义、核心 kfunc 的内核实现、DSQ(dispatch
queue)的数据结构、与 CFS/EEVDF
的共存策略,以及两个有代表性的参考实现。
一、sched_ext 不是什么
在拆内核实现之前,先澄清三个常见误解:
- sched_ext 不是 CFS/EEVDF
的替换。默认调度器仍然调度绝大多数任务。sched_ext
是额外的调度类,只有被显式分配到
ext调度类的任务才由 BPF 调度。 - sched_ext 不是 “随便写个 BPF 就能调度”。调度 BPF 程序受到 sched_ext 框架的严格控制——包括生命周期管理、错误处理、CPU 热插拔协调。
- sched_ext
不能绕过内核的调度基础设施。它仍然运行在
__schedule()的调度类选择框架中,只是pick_next_task()的实现由 BPF 提供。
sched_ext 的真正价值是:把调度策略从 “内核编译期决定” 变为 “运行时由 BPF 程序决定”,使 workload-specific 调度器成为可能。
二、内核架构:ext 调度类
2.1 调度类注册
sched_ext 在 kernel/sched/ext.c
中注册为一个标准的调度类:
/* Linux 6.12: kernel/sched/ext.c */
DEFINE_SCHED_CLASS(ext) = {
.enqueue_task = ext_enqueue_task,
.dequeue_task = ext_dequeue_task,
.yield_task = ext_yield_task,
.wakeup_preempt = ext_wakeup_preempt,
.pick_next_task = ext_pick_next_task,
.put_prev_task = ext_put_prev_task,
.set_next_task = ext_set_next_task,
.task_tick = ext_task_tick,
.task_dead = ext_task_dead,
.rq_online = ext_rq_online,
.rq_offline = ext_rq_offline,
.update_curr = ext_update_curr,
...
};这些内核函数是 sched_ext 的
适配层——它们接收内核调度器的调用,转发到
BPF 程序的 struct_ops 回调。
2.2 调度类的选择与上下文切换
当 __schedule()
需要选择下一个任务时,内核遍历所有调度类(stop → deadline →
ext → fair →
idle),每个类返回它认为该执行的下一个任务。如果 sched_ext
的 pick_next_task() 返回 NULL,内核回退到
CFS:
/* Linux 6.12: kernel/sched/core.c (简化) */
static struct task_struct *pick_next_task(struct rq *rq, ...)
{
struct task_struct *next;
for_each_class(class) { /* stop, dl, ext, fair, idle */
next = class->pick_next_task(rq);
if (next)
return next;
}
/* 所有类都没有任务 → 选 idle */
}这意味着 sched_ext 是可选的——如果 BPF 调度器没有提供任务,CFS 仍然正常工作。
三、struct sched_ext_ops:回调全景
sched_ext_ops 定义了 10+
个回调,覆盖任务调度生命周期的每个关键决策点。以下是完整的回调及其语义。
3.1 核心调度回调
/* Linux 6.12: include/linux/sched/ext.h (简化) */
struct sched_ext_ops {
/* === 必选回调 === */
/* select_cpu() —— 唤醒路径的 CPU 选择
* 参数:被唤醒的任务 p、它之前在的 CPU prev_cpu、wake_flags
* 返回:目标 CPU 编号
* 类比 CFS 的 select_task_rq()
*/
s32 (*select_cpu)(struct task_struct *p, s32 prev_cpu, u64 wake_flags);
/* enqueue() —— 任务变为 runnable
* 参数:任务 p、enq_flags
* BPF 调度器在此将任务加入其内部数据结构(通常是 DSQ)
*/
void (*enqueue)(struct task_struct *p, u64 enq_flags);
/* dispatch() —— 内核需要任务来运行
* 参数:CPU 编号、前一个任务
* BPF 调度器从此回调中调用 scx_bpf_dispatch() 或 scx_bpf_dsq_insert()
* 以向内核提供一个任务
*/
void (*dispatch)(s32 cpu, struct task_struct *prev);
/* === 可选回调 === */
/* tick() —— 周期 tick(调度时钟中断)
* 参数:当前运行的任务
* BPF 调度器可以决定是否占先(通过 scx_bpf_kick_cpu())
*/
void (*tick)(struct task_struct *p);
/* runnable() / running() / stopping() —— 生命周期追踪 */
void (*runnable)(struct task_struct *p, u64 enq_flags);
void (*running)(struct task_struct *p);
void (*stopping)(struct task_struct *p, bool runnable);
/* quiescent() —— CPU 进入静止状态(idle)*/
void (*quiescent)(s32 cpu, u64 dsq_id);
/* cpu_acquire() / cpu_release() —— CPU 热插拔 */
s32 (*cpu_acquire)(s32 cpu);
void (*cpu_release)(s32 cpu, struct scx_cpu_release_args *args);
/* update_idle() —— CPU 空闲状态变化通知 */
void (*update_idle)(s32 cpu, bool idle);
/* init() / exit() —— 调度器生命周期 */
s32 (*init)(void);
void (*exit)(struct scx_exit_info *info);
/* 调度器标志 */
u32 flags; /* SCX_OPS_* 标志位 */
...
};3.2 任务生命周期在 sched_ext 中的流程
sequenceDiagram
participant K as 内核调度器
participant B as BPF 调度器
participant T as 任务
Note over T: 任务被唤醒
T->>K: ttwu (try_to_wake_up)
K->>B: select_cpu(p, prev_cpu, flags)
B-->>K: target_cpu
Note over T: 任务变成 runnable
K->>B: enqueue(p, enq_flags)
B->>B: 将 p 加入 DSQ 或内部数据结构
Note over K: CPU 需要下一个任务
K->>B: dispatch(cpu, prev)
B->>K: scx_bpf_dispatch(p, dsq, slice, flags)
Note over T: 任务开始运行
K->>B: running(p)
loop 每个 tick
K->>B: tick(p)
B->>B: 决定是否 kick 其他 CPU
end
Note over T: 任务停止运行
K->>B: stopping(p, runnable)
3.3 核心决策点详解
select_cpu():这是唤醒路径上的第一个决策点。BPF 调度器需要选择一个 CPU 来放置被唤醒的任务。影响决策的因素包括:cache 亲和性(prev_cpu)、CPU 负载、NUMA 节点、功耗域。
enqueue():任务已经变成了 runnable。BPF 调度器决定将它放在哪个 DSQ 中。常见策略: - FIFO:放到 per-CPU 的本地 DSQ - 优先级:放到不同优先级的 DSQ - 负载均衡:放到负载最小的 CPU 的 DSQ
dispatch():内核调用此回调,期望 BPF
调度器通过 scx_bpf_dispatch()
返回一个任务。如果调度器没有可调度的任务,dispatch()
不调用任何 dispatch kfunc 就返回——内核会回退到 CFS。
/* 典型的 dispatch() 实现:从 per-CPU 队列中取出一个任务 */
void BPF_PROG(my_dispatch, s32 cpu, struct task_struct *prev)
{
struct task_struct *p;
/* 从内部数据结构取出任务 */
p = bpf_map_lookup_elem(&per_cpu_queues, &cpu);
if (p) {
/* 交给内核执行:slice 为 5ms */
scx_bpf_dispatch(p, SCX_DSQ_LOCAL, 5000000, 0);
}
/* 没有任务 → 内核自动 fallback 到 CFS */
}tick():每个调度 tick 调用一次。BPF 调度器可以在此实现时间片到期检查和占先:
void BPF_PROG(my_tick, struct task_struct *p)
{
/* 检查任务是否用完了时间片 */
if (p->scx.slice < SCX_SLICE_DFL) {
/* 用完了,重新算时间片或 kick */
scx_bpf_kick_cpu(bpf_get_smp_processor_id());
}
}四、DSQ:Dispatch Queue 数据结构
4.1 DSQ 类型
DSQ 是 sched_ext 中的核心调度队列抽象。共有三类:
/* Linux 6.12: include/linux/sched/ext.h */
/* 预定义的 DSQ ID */
enum {
SCX_DSQ_LOCAL = 0, /* 本地 per-CPU DSQ(FIFO)*/
SCX_DSQ_GLOBAL = 1, /* 全局 DSQ(所有 CPU 共享)*/
/* 自定义 DSQ ID 从 SCX_DSQ_CUSTOM 开始 */
};| DSQ 类型 | 含义 | 使用场景 |
|---|---|---|
SCX_DSQ_LOCAL |
当前 CPU 的本地队列 | 简单 FIFO 调度 |
SCX_DSQ_GLOBAL |
全局队列(有锁) | 共享工作队列 |
| 自定义 DSQ | BPF 创建的命名队列 | 按优先级/类型隔离任务 |
4.2 自定义 DSQ 的创建
/* BPF 程序中创建自定义 DSQ */
s32 dsq_id = scx_bpf_create_dsq(SCX_DSQ_BUS_SLICE_DSQ, -1 /* 不绑定 NUMA */);
if (dsq_id < 0) {
/* 创建失败 */
}
/* 将任务分发到自定义 DSQ */
scx_bpf_dispatch(p, dsq_id, SCX_SLICE_DFL, 0);
/* 从自定义 DSQ 消费——在 dispatch() 中 */
scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL);DSQ 的内部实现是 FIFO 链表(带 RCU 保护),由内核在
kernel/sched/ext.c 中维护:
/* Linux 6.12: kernel/sched/ext.c (DSQ 内部结构简化) */
struct scx_dispatch_q {
raw_spinlock_t lock;
struct list_head list; /* 任务链表 */
u32 nr;
...
};五、核心 kfunc 的内核实现
sched_ext 的关键能力是通过一组 BPF kfunc 暴露的。这些 kfunc 不是 helper 函数——它们是内核函数,通过 BTF 标注为 “可被 BPF 调用”。
5.1 scx_bpf_dispatch()
将任务提交给内核调度:
/* Linux 6.12: kernel/sched/ext.c */
__bpf_kfunc void scx_bpf_dispatch(struct task_struct *p, u64 dsq_id,
u64 slice_ns, u64 enq_flags)
{
/* 1. 安全检查:p 必须在当前 CPU 的 runqueue 上 */
/* 2. 检查 dsq_id 的有效性 */
/* 3. 设置时间片 */
p->scx.slice = slice_ns;
/* 4. 将任务加入目标 DSQ */
dispatch_enqueue(&scx_dsq_from_id(dsq_id), p, enq_flags);
}5.2 scx_bpf_kick_cpu()
触发目标 CPU 的调度器重新选择任务:
/* Linux 6.12: kernel/sched/ext.c */
__bpf_kfunc void scx_bpf_kick_cpu(s32 cpu, u64 flags)
{
struct rq *rq = cpu_rq(cpu);
/* 设置 kick 标志 */
rq->scx.flags |= SCX_RQ_KICK;
/* 发送 IPI 到目标 CPU,让其重新调度 */
resched_curr(rq);
}5.3 其他关键 kfunc 清单
| kfunc | 功能 | 返回值 |
|---|---|---|
scx_bpf_dispatch(p, dsq, slice, flags) |
将任务分发到指定 DSQ | void |
scx_bpf_dispatch_from_dsq(dsq_id, p, flags) |
从 DSQ 中取出任务分发 | void |
scx_bpf_dispatch_vtime(p, dsq, slice, vtime, flags) |
带虚拟时间的分发 | void |
scx_bpf_kick_cpu(cpu, flags) |
强制 CPU 重新调度 | void |
scx_bpf_dsq_nr_queued(dsq_id) |
查询 DSQ 中的任务数 | u32 |
scx_bpf_dsq_insert(p, dsq_id) |
将任务插入 DSQ | void |
scx_bpf_create_dsq(dsq_id, node) |
创建自定义 DSQ | s32 |
scx_bpf_destroy_dsq(dsq_id) |
销毁自定义 DSQ | void |
scx_bpf_task_running(p) |
检查任务是否正在运行 | bool |
scx_bpf_task_cpu(p) |
获取任务当前 CPU | s32 |
scx_bpf_switch_all() |
将当前 cgroup 所有任务切换到 ext 类 | void |
六、与 CFS/EEVDF 的共存策略
6.1 SCX_OPS_SWITCH_PARTIAL:部分切换
SCX_OPS_SWITCH_PARTIAL 标志控制 sched_ext 与
CFS 的共存范围。语义以内核文档
Documentation/scheduler/sched-ext.rst
为准:
- 未设置
SCX_OPS_SWITCH_PARTIAL:BPF 调度器加载后,所有SCHED_NORMAL、SCHED_BATCH、SCHED_IDLE和SCHED_EXT策略的任务均由 sched_ext 调度。 - 已设置
SCX_OPS_SWITCH_PARTIAL:仅SCHED_EXT策略的任务由 sched_ext 调度;SCHED_NORMAL/SCHED_BATCH/SCHED_IDLE任务仍由 fair 类(CFS/EEVDF)调度,且 fair 类的调度优先级高于SCHED_EXT。
在 partial 模式下,需要显式将目标任务切换到
SCHED_EXT 策略(例如通过
sched_setscheduler() 或
scx_bpf_switch_all() 批量切换当前 cgroup
的任务):
/* 伪代码/节选:partial 模式下在 init 中切换任务策略 */
SEC("struct_ops/init")
s32 BPF_PROG(my_init)
{
if (ops.flags & SCX_OPS_SWITCH_PARTIAL)
scx_bpf_switch_all(); /* 将当前 cgroup 任务设为 SCHED_EXT */
return 0;
}6.2 错误回退
sched_ext 有内置的安全网——如果 BPF 调度器崩溃或行为异常,内核会自动回退到 CFS:
/* Linux 6.12: kernel/sched/ext.c */
/* 以下情况触发回退:
* 1. BPF 程序返回错误(dispatch/enqueue 返回 <0)
* 2. SCX_KICK 超时——长时间没有任务被 dispatch
* 3. BPF 程序被卸载或被替换
*/
static void scx_ops_bypass(atomic_t *bypass_reason)
{
/* 将所有任务从 ext 类移回 fair 类 */
scx_ops_bypass_tasks();
/* 恢复 CFS 的正常调度 */
}6.3 饥饿防护
sched_ext 保证系统永远不会因为 BPF 调度器 “忘记” 提供任务而死锁:
/* 如果 dispatch() 被反复调用但 BPF 没有返回任务,
* 内核会注入 idle 任务(SCHED_IDLE),保证 CPU 能继续运行。
* 如果所有 ext 任务都阻塞,__schedule() 自然回退到 fair/idle 类。
*/七、参考实现分析
7.1 scx_simple:最小 FIFO 调度器
scx_simple(位于
tools/sched_ext/scx_simple.bpf.c)是最小的可工作
sched_ext 调度器。以下为伪代码/节选(省略
map 定义、错误处理和 struct_ops
注册,完整实现见上游源码):
/* scx_simple 的核心逻辑(伪代码/节选)*/
/* select_cpu: 选择 prev_cpu(cache 亲和)*/
s32 BPF_PROG(simple_select_cpu, struct task_struct *p, s32 prev_cpu, u64 wake_flags)
{
return prev_cpu; /* 最简单策略:总是回到前一个 CPU */
}
/* enqueue: 添加到本地 DSQ */
void BPF_PROG(simple_enqueue, struct task_struct *p, u64 enq_flags)
{
/* 通过 dispatch() 中的 scx_bpf_dispatch 来处理 */
}
/* dispatch: 从全局 DSQ 向本地 DSQ 分发 */
void BPF_PROG(simple_dispatch, s32 cpu, struct task_struct *prev)
{
/* 从全局 DSQ 消费任务,让它们运行在当前 CPU */
scx_bpf_dispatch_from_dsq(SCX_DSQ_GLOBAL, 0);
}7.2 scx_layered:多层优先级调度
scx_layered
实现了多层调度——前台(交互式)任务 vs
后台(批处理)任务分配到不同的 DSQ 和不同的 CPU 集合:
/* scx_layered 的核心策略(简化)*/
/* 前台任务走本地 DSQ,获得低延迟 */
/* 后台任务走全局 DSQ,只在有 CPU 空闲时运行 */
void BPF_PROG(layered_dispatch, s32 cpu, struct task_struct *prev)
{
/* 优先级顺序:本地 DSQ → 前台 global DSQ → 后台 DSQ */
if (scx_bpf_dsq_nr_queued(SCX_DSQ_LOCAL))
scx_bpf_dispatch_from_dsq(SCX_DSQ_LOCAL, 0);
else if (scx_bpf_dsq_nr_queued(fg_dsq_id))
scx_bpf_dispatch_from_dsq(fg_dsq_id, 0);
else
scx_bpf_dispatch_from_dsq(bg_dsq_id, 0);
}7.3 scx_rustland:用户态调度器
scx_rustland 将调度决策完全移出内核——BPF
程序在 kernel 中只是一个 “执行代理”,实际调度决策由用户态
Rust 进程通过 BPF ring buffer 通信完成。
架构分为两层:上层是用户态 Rust
调度器(scx_rustland 进程),负责接收 BPF
传来的任务信息、实现复杂的调度策略、向 BPF 发送 dispatch
命令。下层是 BPF 内核调度器,在 enqueue()
中将任务信息发送到用户态,在 dispatch()
中从用户态接收 dispatch 命令并执行。
这种架构允许调度器使用任意语言实现任意复杂的逻辑——甚至可以是机器学习的决策模型——因为它运行在用户态,不受 verifier 的限制。
八、使用与调试
8.1 检查 sched_ext 是否可用
debugfs 路径需要内核启用
CONFIG_DEBUG_FS(并挂载 debugfs,通常为
/sys/kernel/debug):
# 检查内核配置
zcat /proc/config.gz | grep CONFIG_SCHED_CLASS_EXT
zcat /proc/config.gz | grep CONFIG_DEBUG_FS
# 检查是否有 sched_ext BPF 程序在运行
bpftool prog list | grep sched_ext
# 检查 ext 调度类是否活跃(需 CONFIG_DEBUG_FS)
cat /sys/kernel/debug/sched/ext/ops8.2 加载 scx_simple
以下 debugfs 命令同样需要
CONFIG_DEBUG_FS(见 8.1)。
# 使用 scx 命令行工具(sched_ext userspace loader)
scx simple
# 查看效果
cat /sys/kernel/debug/sched/ext/stat
# 停止——自动回退到 CFS
scx stop8.3 关键指标
# ext 调度类的统计信息
cat /sys/kernel/debug/sched/ext/stat
# 输出包含:dispatch 计数、bypass 计数、kick 计数、错误计数
# 查看当前 ext 任务的分布
bpftool map dump id <DSQ_MAP_ID>九、限制与注意事项
不保证实时性:sched_ext
在调度类优先级中低于 stop 和
deadline 类。如果需要硬实时保证,使用
SCHED_DEADLINE。
NUMA 感知需要手动实现:内核不自动为
sched_ext 做 NUMA 感知的负载均衡——BPF 调度器需要通过
select_cpu() 中的 NUMA 信息自己做决策。
不替代内核调度器:sched_ext 适合 “特定工作负载的优化调度”,不适合 “整个系统的通用调度”。绝大多数任务仍然由 CFS/EEVDF 调度。
需要 CAP_SYS_ADMIN:加载和挂载 sched_ext 调度器需要完整的内核管理权限。
十、总结
sched_ext 是 eBPF
进入内核核心子系统的标志性事件。它将调度策略从编译期常量变为运行时可编程的函数表——通过
struct sched_ext_ops 的 10+
个回调覆盖调度生命周期的所有关键决策点,通过 DSQ
机制实现灵活的任务队列管理,通过
SCX_OPS_SWITCH_PARTIAL 控制与 CFS/EEVDF
的共存范围(详见 sched-ext.rst)。
sched_ext 的设计有三个核心原则:第一,BPF 调度器是可选的——CFS 始终作为 fallback;第二,错误会自动回退——BPF 调度器崩溃不会使 CPU 饿死;第三,接口最小化——只暴露必要的调度决策点,内核保留任务状态管理和硬件协调。
本系列中,本文直接依赖 第
15 篇(蹦床与 fentry/fexit)——sched_ext 通过
struct_ops+蹦床机制将调度回调重定向到 BPF。与
第 17
篇(eBPF 安全模型) 相关——sched_ext 的 BPF
程序运行在受限的 capability 和 verifier 约束下。
参考资料
- Linux 内核源码
kernel/sched/ext.c:sched_ext 的核心实现 - Linux 内核源码
include/linux/sched/ext.h:struct sched_ext_ops和相关类型 - Linux 内核源码
tools/sched_ext/scx_simple.bpf.c:最小调度器参考实现 - Linux 内核源码
tools/sched_ext/scx_layered.bpf.c:多层调度器参考实现 - scx_rustland 项目:用户态 Rust 调度器
- Linux 内核文档
Documentation/scheduler/sched-ext.rst:sched_ext 官方文档
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【eBPF 内核实现深度拆解】蹦床(Trampoline)与 fentry / fexit:零开销内核追踪
fentry/fexit 通过 BPF 蹦床机制在目标函数的 nop 位置直接替换为 call 指令进入 BPF,避免了 kprobe 的 int3 中断开销。本文拆解 bpf_trampoline 内核实现、arch_prepare_bpf_trampoline 的架构相关栈帧构造、struct_ops 与蹦床的协作——以及蹦床在什么条件下开销并不为零。
【eBPF 内核实现深度拆解】从验证器到 JIT,从 BTF 到调度器
eBPF 内核虚拟机内部实现系统讲解:BPF 指令集与寄存器机器、验证器的抽象解释与状态裁剪、JIT 编译器后端、Map 各类型的并发与内存模型、helper 函数注册与类型检查、BTF 格式规范与 CO-RE 重定位引擎、libbpf 加载器工程、fentry/fexit 蹦床机制、sched_ext 调度器内核接口。面向想读懂 eBPF 内核源码、写生产级 BPF 程序的系统工程师。
【eBPF 内核实现深度拆解】BPF 指令集解码:寄存器机器、调用约定与指令编码
从 eBPF 虚拟机的 11 个 64-bit 寄存器和 struct bpf_insn 出发,逐条拆解 ALU64/ALU32、跳转、加载存储、call 四类指令的字段语义与编码格式,建立后续 verifier 和 JIT 讨论的精确基础。
【eBPF 内核实现深度拆解】验证器框架:从 BPF_PROG_LOAD 到 do_check()
跟踪 BPF_PROG_LOAD 系统调用的内核执行路径,逐层拆解 bpf_prog_load()→bpf_check()→do_check_main() 的调用链,建立 verifier 执行全景——这是理解 verifier 安全保证的入口。