本文由 AI 生成,内容可能存在错误或不准确之处,请结合可靠来源核验后再作参考。
NOTE
本文以 Linux 6.12 合入主线的 sched_ext (SCX) 框架为基础,结合内核自带的 scx_simple 调度器源码,从零讲解如何用 BPF 编写一个安全的、可动态加载的自定义 CPU 调度器。
1. 为什么需要 sched_ext?
1.1 传统调度器的痛点
Linux 内核的 CFS(完全公平调度器)是普适性设计,但无法针对特定场景优化:
- 游戏/低延迟应用:需要减少调度延迟而非公平分配
- 数据中心批处理:需要按租户/cgroup 分层调度
- NUMA 系统:需要精细化控制任务在 NUMA 节点间的分布
- 实验性调度策略:在真实生产环境快速迭代调度算法
1.2 为什么不能用内核模块?
调度类是编译时链接器段,不是运行时链表。没有 register_sched_class() 这样的接口,也没有导出任何调度类符号。这也是内核模块无法直接替换调度类的根本限制。
1.3 sched_ext 的解决思路
不在现有调度类列表中插入新类,而是新增一个专用的”调度策略壳子”,让 BPF 程序定义这个壳子的行为。
- 安全:BPF 验证器在加载时静态证明程序不会崩溃内核
- 动态:加载/卸载不需要重启、不需要重新编译内核
- 可回退:BPF 调度器崩溃或卡死?所有任务自动回退到 CFS
- 高表达力:可以实现任何调度策略,从简单 FIFO 到复杂的虚拟时钟调度
2. 环境准备
2.1 内核要求
1 2 3 4 5 6 7 8
| Linux 6.12+ (sched_ext 主线合入版本)
必需的内核配置: CONFIG_BPF=y CONFIG_BPF_SYSCALL=y CONFIG_BPF_JIT=y CONFIG_DEBUG_INFO_BTF=y CONFIG_SCHED_CLASS_EXT=y
|
2.2 构建工具链
1 2 3 4 5 6 7
| sudo apt install clang llvm libbpf-dev bpftool make
cd linux/tools/sched_ext/ make scx_simple make all
|
3. 架构全景
3.1 一个调度器 = 两个程序
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| ┌──────────────────────────────────────────┐ │ 用户态(userspace) │ │ │ │ scx_simple.c │ │ ├─ OPEN: 解析 BPF ELF,创建 Map │ │ ├─ 设置 rodata 配置参数 │ │ ├─ LOAD: 提交给 BPF 验证器 │ │ ├─ ATTACH: 挂载到 sched_ext → 激活! │ │ ├─ 主循环:读取统计,每秒打印 │ │ └─ Ctrl+C → 卸载 → 回退 CFS │ └──────────┬───────────────────────────────┘ │ bpf() syscall ┌──────────▼───────────────────────────────┐ │ 内核态(kernel) │ │ │ │ sched_ext 框架 自动调用: │ │ ┌────────────────────────────────────┐ │ │ │ ① select_cpu ─── 任务醒来选 CPU │ │ │ │ ② enqueue ─── 任务入队 │ │ │ │ ③ dispatch ─── CPU 空闲,派发任务 │ │ │ │ ④ running ─── 任务开始执行 │ │ │ │ ⑤ stopping ─── 任务停止执行 │ │ │ │ ⑥ tick ─── 每个定时器滴答 │ │ │ └────────────────────────────────────┘ │ │ │ │ 进程退出 = 自动卸载,所有任务 → CFS │ └──────────────────────────────────────────┘
|
3.2 关键抽象:DSQ(Dispatch Queue,调度队列)
DSQ 是 sched_ext 的核心抽象,解耦了”调度决策”和”实际 CPU 分配”:
1 2 3
| BPF 调度器 → 把任务插入 DSQ ↓ sched_ext 核心 → 从 DSQ 取出任务放到 CPU 上
|
DSQ 好比是 BPF 调度器和真实 CPU 之间的中间队列。
4. 核心概念:DSQ 详解
4.1 三种 DSQ 类型
| 类型 |
ID |
数量 |
排序方式 |
用途 |
SCX_DSQ_LOCAL |
0x8000000000000002 |
每 CPU 一个 |
仅 FIFO |
CPU 直接执行的队列,总是最先消费 |
SCX_DSQ_GLOBAL |
0x8000000000000001 |
系统唯一 |
仅 FIFO |
全局后备队列,CPU local 空时自动取 |
| 自定义 DSQ |
用户指定(小于 $2^{63}$) |
不限 |
FIFO 或 vtime 优先级 |
实现复杂调度策略 |
关键规则:CPU 永远优先执行其本地 DSQ 中的任务。只有当本地队列为空时,才进入 dispatch() 回调从其他地方拉取任务。
4.2 DSQ ID 编码
DSQ ID 是一个 64 位整型,bit 63 标记是否为内置队列:
1 2 3 4
| Bits: [63] [62] [61..32] [31..0] [ B] [ L] [ 保留 ] [CPU号 ] B=1: 内核内置 DSQ L=1: LOCAL_ON 变体(指定目标 CPU)
|
4.3 核心 DSQ 操作 API
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| s32 scx_bpf_create_dsq(u64 dsq_id, s32 node);
void scx_bpf_destroy_dsq(u64 dsq_id);
void scx_bpf_dsq_insert(struct task_struct *p, u64 dsq_id, u64 slice, u64 enq_flags);
void scx_bpf_dsq_insert_vtime(struct task_struct *p, u64 dsq_id, u64 slice, u64 vtime, u64 enq_flags);
void scx_bpf_dsq_move_to_local(u64 dsq_id, u64 flags);
s32 scx_bpf_dsq_nr_queued(u64 dsq_id);
|
4.4 任务在 DSQ 之间的流动
1 2 3 4 5 6 7 8 9 10 11 12 13
| select_cpu() ┌──────────┐ 发现 idle CPU ──────────────────────────┐ │ 任务唤醒 │ │ └────┬─────┘ │ │ 无 idle CPU │ ▼ ▼ enqueue() SCX_DSQ_LOCAL │ (直接执行) ▼ ┌──────────────┐ dispatch() │ 自定义 DSQ │ ────────────────────→ SCX_DSQ_LOCAL │ (vtime 排序) │ scx_bpf_dsq_ └──────────────┘ move_to_local() (CPU 执行)
|
5. 完整的 sched_ext_ops 回调表
5.1 调度核心回调(按触发顺序)
| 回调 |
触发时机 |
上下文 |
典型用途 |
select_cpu |
任务唤醒(fork/exec/wakeup) |
rq lock 持有 |
选择 CPU,遇到 idle CPU 可直发 LOCAL |
enqueue |
任务就绪但未被 select_cpu 直发 |
rq lock 持有 |
将任务插入调度队列(DSQ) |
dequeue |
任务离开 BPF 调度器托管 |
rq lock 持有 |
清理任务状态(可选) |
dispatch |
CPU 的 local/global DSQ 都空了 |
rq lock 持有 |
从自定义 DSQ 拉任务到本地 |
running |
任务开始在 CPU 上执行 |
rq lock 持有 |
记录开始时间、更新全局时钟 |
stopping |
任务停止(时间片到/阻塞/被抢占) |
rq lock 持有 |
消耗计费、更新 vtime |
tick |
每个 HZ 滴答 |
rq lock 持有 |
周期性检查(时间片管理) |
5.2 生命周期回调
| 回调 |
触发时机 |
关键特性 |
init |
调度器加载(在任何任务迁移前) |
可睡眠(SLEEPABLE) — 可安全创建 DSQ |
exit |
调度器卸载 |
记录退出信息给用户态 |
init_task |
每个任务首次进入 SCX |
可睡眠 — 初始化任务级状态 |
exit_task |
任务退出或调度器卸载 |
可睡眠 — 清理任务级状态 |
enable |
任务进入 SCX(与 disable 配对) |
初始化任务 vtime 等 |
disable |
任务离开 SCX(策略变化/调度器卸载) |
|
5.3 cgroup 回调(需要 CONFIG_EXT_GROUP_SCHED)
| 回调 |
用途 |
cgroup_init / cgroup_exit |
cgroup 创建/销毁 |
cgroup_set_weight |
cgroup 权重变更 |
cgroup_move |
任务在 cgroup 间迁移 |
5.4 高级回调
| 回调 |
用途 |
update_idle |
CPU idle 状态变化通知 |
cpu_acquire / cpu_release |
CPU 获得/释放(热插拔) |
core_sched_before |
SMT 核心调度排序 |
dump / dump_cpu / dump_task |
错误时诊断信息输出 |
5.5 重要语义
- 必填字段:只有
.name 是强制必须设置的
- 调度器托管(custody):任务被放入自定义 DSQ 时进入”托管”状态,
dequeue() 只在任务离开托管时调用
- 竞态容忍:回调可能在多个 CPU 上并发执行。
running() / stopping() 中的全局状态更新允许轻量竞态(详见 scx_simple 源码注释)
- 不允许阻塞:除标记 SLEEPABLE 的回调外,所有调度核心回调决不能睡眠(不能持有 mutex、不能用 GFP_KERNEL)
6. 从 scx_simple 入手:逐行详解
6.1 许可证和全局状态
1 2 3 4 5 6 7
| #include <scx/common.bpf.h>
char _license[] SEC("license") = "GPL";
const volatile bool fifo_sched; static u64 vtime_now; UEI_DEFINE(uei);
|
const volatile 是 BPF 中用户态与内核态共享配置的惯用模式。UEI 用于在调度器卸载时将退出原因(正常退出/错误/需重启)告知用户态。
6.2 自定义 DSQ 创建
1 2 3 4 5 6
| #define SHARED_DSQ 0
s32 BPF_STRUCT_OPS_SLEEPABLE(simple_init) { return scx_bpf_create_dsq(SHARED_DSQ, -1); }
|
init 中创建 DSQ 是最佳实践——此时还未有任何任务被迁移,且 init 是可睡眠的。
6.3 select_cpu:快路径——直接把任务发给 idle CPU
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| s32 BPF_STRUCT_OPS(simple_select_cpu, struct task_struct *p, s32 prev_cpu, u64 wake_flags) { bool is_idle = false;
s32 cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);
if (is_idle) { stat_inc(0); scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0); }
return cpu; }
|
核心优化:当有 CPU 空闲时,直接把任务”投递”过去,跳过全局队列的排队开销。这在低负载时极为高效。
6.4 enqueue:慢路径——将任务放入全局队列
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| void BPF_STRUCT_OPS(simple_enqueue, struct task_struct *p, u64 enq_flags) { stat_inc(1);
if (fifo_sched) { scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags); } else { u64 vtime = p->scx.dsq_vtime;
if (time_before(vtime, vtime_now - SCX_SLICE_DFL)) vtime = vtime_now - SCX_SLICE_DFL;
scx_bpf_dsq_insert_vtime(p, SHARED_DSQ, SCX_SLICE_DFL, vtime, enq_flags); } }
|
vtime 防饥饿机制:长时间睡眠的任务其 vtime 会远小于当前全局时钟。如果不做限制,它醒来后会获得巨额”历史欠账”,挤占所有活跃任务。限制在 vtime_now - one_slice 确保了公平。
6.5 dispatch:CPU 空闲了,给它找活干
1 2 3 4 5
| void BPF_STRUCT_OPS(simple_dispatch, s32 cpu, struct task_struct *prev) { scx_bpf_dsq_move_to_local(SHARED_DSQ, 0); }
|
dispatch() 只在 CPU 本地和全局 DSQ 都为空时被调用。每次调用只移动一个任务——内核会反复调用直到队列为空或本地队列有活。
6.6 running / stopping:vtime 计费
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| void BPF_STRUCT_OPS(simple_running, struct task_struct *p) { if (fifo_sched) return; if (time_before(vtime_now, p->scx.dsq_vtime)) vtime_now = p->scx.dsq_vtime; }
void BPF_STRUCT_OPS(simple_stopping, struct task_struct *p, bool runnable) { if (fifo_sched) return; u64 delta = scale_by_task_weight_inverse(p, SCX_SLICE_DFL - p->scx.slice); scx_bpf_task_set_dsq_vtime(p, p->scx.dsq_vtime + delta); }
|
vtime 调度原理:
- 任务开始运行时
vtime_now 前进
- 任务停止时,消耗的 CPU 时间按权重倒数缩放后加到任务的 vtime 上
- 高权重任务(nice 值低)的 vtime 增长慢 → 在队列中排得靠前 → 获得更多 CPU
- 这和 CFS 的 vruntime 是同一哲学,但只用了几十行代码
6.7 enable:任务进入时的初始化
1 2 3
| void BPF_STRUCT_OPS(simple_enable, struct task_struct *p) { scx_bpf_task_set_dsq_vtime(p, vtime_now); }
|
新任务进入 SCX 调度时,其 vtime 被设置为当前全局虚拟时钟。这样它既不会因 vtime=0 而获得不公平的优待,也不会因从未调度过而被饿死。
6.8 组装 ops 表
1 2 3 4 5 6 7 8 9 10
| SCX_OPS_DEFINE(simple_ops, .select_cpu = (void *)simple_select_cpu, .enqueue = (void *)simple_enqueue, .dispatch = (void *)simple_dispatch, .running = (void *)simple_running, .stopping = (void *)simple_stopping, .enable = (void *)simple_enable, .init = (void *)simple_init, .exit = (void *)simple_exit, .name = "simple");
|
注意:回调必须强制转换为 (void *),因为 BPF 的 struct_ops 机制要求 BPF 函数指针在 ELF 中以特殊方式编码。name 字段会出现在 /sys/kernel/sched_ext/root/ops 中,也用于调试输出。
7. BPF 数据结构和 Map 模式
7.1 统计计数器(PERCPU_ARRAY)
1 2 3 4 5 6 7 8 9 10 11
| struct { __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY); __uint(key_size, sizeof(u32)); __uint(value_size, sizeof(u64)); __uint(max_entries, 2); } stats SEC(".maps");
static void stat_inc(u32 idx) { u64 *cnt_p = bpf_map_lookup_elem(&stats, &idx); if (cnt_p) (*cnt_p)++; }
|
每个 CPU 有独立的计数器副本,避免了锁竞争。用户态汇总时遍历所有 CPU 求和。
7.2 任务级上下文(TASK_STORAGE)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| struct task_ctx { u64 start_time; u64 deadline; };
struct { __uint(type, BPF_MAP_TYPE_TASK_STORAGE); __uint(map_flags, BPF_F_NO_PREALLOC); __type(key, int); __type(value, struct task_ctx); } task_ctx_stor SEC(".maps");
struct task_ctx *ctx = bpf_task_storage_get(&task_ctx_stor, p, 0, BPF_LOCAL_STORAGE_GET_F_CREATE);
struct task_ctx *ctx = bpf_task_storage_get(&task_ctx_stor, p, 0, 0); if (ctx) { }
|
每个 task_struct 有自己的存储桶,零开销查找。适合存放任务的调度相关上下文。
7.3 BPF Arena(大数据结构)
对于复杂调度器(如 scx_qmap),Arena 提供了更大的内存空间和更灵活的数据结构:
1 2 3 4 5 6
| struct { __uint(type, BPF_MAP_TYPE_ARENA); __uint(map_flags, BPF_F_MMAPABLE); __uint(max_entries, 1 << 16); __ulong(map_extra, 0x1ull << 44); } arena SEC(".maps");
|
Arena 支持链表、自旋锁、动态分配等——相当于 BPF 中的 malloc。
7.4 BPF 约束速查
| 约束 |
影响 |
应对 |
| 最大指令数 |
默认 100 万条 |
用 bpf_for() / bpf_repeat() 替代无界循环 |
| 栈空间 |
512 字节 |
大数据放在 map 或 arena |
| 不能浮点 |
所有运算必须是整数 |
用纳秒/微秒精度替代浮点比例 |
| 函数指针受限 |
不能取函数地址 |
用 BPF_STRUCT_OPS 宏 |
| rodata 只有加载前可写 |
加载后配置不可变 |
通过 BPF map 传递动态配置 |
8. 用户态加载器模式
8.1 标准四步加载
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| #include <scx/common.h> #include "my_sched.bpf.skel.h"
int main(int argc, char **argv) { struct my_sched *skel; struct bpf_link *link; u64 ecode;
signal(SIGINT, sigint_handler); signal(SIGTERM, sigint_handler);
restart: skel = SCX_OPS_OPEN(my_ops, my_sched);
skel->rodata->slice_ns = 10 * NSEC_PER_MSEC; skel->rodata->fifo_mode = false;
SCX_OPS_LOAD(skel, my_ops, my_sched, uei);
link = SCX_OPS_ATTACH(skel, my_ops, my_sched);
while (!exit_req && !UEI_EXITED(skel, uei)) { read_and_print_stats(skel); sleep(1); }
bpf_link__destroy(link); ecode = UEI_REPORT(skel, uei); my_sched__destroy(skel);
if (UEI_ECODE_RESTART(ecode)) goto restart; return 0; }
|
8.2 UEI(User Exit Info)机制
UEI 是 BPF 调度器和用户态之间的退出通信通道:
1 2 3 4 5 6 7 8 9 10 11
| UEI_DEFINE(uei);
void BPF_STRUCT_OPS(simple_exit, struct scx_exit_info *ei) { UEI_RECORD(uei, ei); }
if (UEI_EXITED(skel, uei)) { ... } u64 ecode = UEI_REPORT(skel, uei); if (UEI_ECODE_RESTART(ecode)) goto restart;
|
退出信息包含:reason(退出原因字符串)、msg(详细消息)、exit_code(退出码)、exit_cpu。
8.3 通过 rodata 传递配置
1 2 3 4 5 6 7 8 9
| const volatile u64 slice_ns = SCX_SLICE_DFL; const volatile bool use_fifo = false; const volatile s32 numa_node = -1;
skel->rodata->slice_ns = 20 * NSEC_PER_MSEC; skel->rodata->use_fifo = true;
|
rodata 中的 const volatile 变量在 BPF 验证器眼中是”加载后不可变”的,这样验证器可以做常量折叠优化。
9. 构建与运行
9.1 构建链
1 2 3 4 5 6 7 8 9 10 11 12
| .bpf.c 源码 │ clang -target bpf -O2 -mcpu=v3 ▼ .bpf.o (BPF ELF 字节码) │ bpftool gen skeleton ▼ .bpf.skel.h (C 骨架头文件) │ 被用户态 loader #include ▼ gcc/clang 编译 ▼ 最终可执行文件(含嵌入式 BPF 字节码)
|
9.2 运行和监控
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| sudo ./build/bin/scx_simple
sudo ./build/bin/scx_simple -f
cat /sys/kernel/sched_ext/state cat /sys/kernel/sched_ext/root/ops
grep ext /proc/self/sched
|
9.3 应急恢复
sched_ext 内置了看门狗:如果 BPF 调度器在 2×SCX_SLICE_DFL 时间内没有调度任何任务,内核自动回退到 CFS。
10. 进阶:生产级调度器一览
以下调度器均随内核源码或官方 scx 仓库发布:
| 调度器 |
语言 |
核心特点 |
复杂度 |
| scx_simple |
C |
全局 vtime / FIFO,约 120 行 BPF C |
★☆☆☆ |
| scx_qmap |
C |
5 级 FIFO + BPF Arena + Core Scheduling |
★★★☆ |
| scx_flatcg |
C |
扁平化 cgroup 层级权重调度 |
★★★☆ |
| scx_central |
C |
单 CPU 集中式调度,无 tick 操作 |
★★☆☆ |
| scx_userland |
C |
将调度决策发到用户态做 |
★★☆☆ |
| scx_rusty |
Rust |
NUMA 感知多域 + 任务窃取 |
★★★★ |
| scx_layered |
Rust |
用户可配的多层 cgroup 调度(Meta 生产验证) |
★★★★ |
10.1 scx_layered:生产级案例
scx_layered 已在 Meta 生产环境中验证,它通过 JSON 配置将任务按 cgroup/PID 分层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| { "layers": [ { "name": "high-priority", "cgroups": ["/system.slice/important.service"], "cpus_range": [0, 32], "util_range": [0.8, 0.9], "slice_us": 20000 }, { "name": "batch", "cgroups": ["/system.slice/batch-*"], "cpus_range": [0, 16], "util_range": [0.7, 0.85] } ] }
|
效果:主要工作负载吞吐量提升约 6%,整个混部服务器集群提升约 3.8%。关键优势:调度策略可以用 JSON 而不是内核补丁来调整。
11. 从零实现一个调度器的检查清单
第一步:搭建骨架
第二步:实现最小回调集
第三步:用户态加载器
第四步:测试
1 2 3 4 5 6 7 8 9 10
| sudo ./my_sched & sleep 2 sudo kill %1
stress-ng --cpu 4 --timeout 10s & sudo ./my_sched
|
12. 总结
sched_ext 把”编写内核调度器”从”给内核打 9000 行补丁”变成了”用 100 行 BPF C 实现核心逻辑”。它的设计哲学是:
- BPF 负责决策(哪个任务先跑、分配多少时间、放哪个 CPU)
- sched_ext 框架负责执行(上下文切换、锁管理、安全兜底、定时器、抢占)
- DSQ 是二者之间的隔离层(解耦策略和机制)
这种方式下,调度策略的迭代周期从”编译内核 → 重启 → 测试”缩短为”编译 BPF 程序 → 运行 → Ctrl+C 回退”。