1. Linux sched_ext:用 BPF 编写用户态调度器完整教程
    1. 1. 1. 为什么需要 sched_ext?
      1. 1.1. 1.1 传统调度器的痛点
      2. 1.2. 1.2 为什么不能用内核模块?
      3. 1.3. 1.3 sched_ext 的解决思路
    2. 2. 2. 环境准备
      1. 2.1. 2.1 内核要求
      2. 2.2. 2.2 构建工具链
    3. 3. 3. 架构全景
      1. 3.1. 3.1 一个调度器 = 两个程序
      2. 3.2. 3.2 关键抽象:DSQ(Dispatch Queue,调度队列)
    4. 4. 4. 核心概念:DSQ 详解
      1. 4.1. 4.1 三种 DSQ 类型
      2. 4.2. 4.2 DSQ ID 编码
      3. 4.3. 4.3 核心 DSQ 操作 API
      4. 4.4. 4.4 任务在 DSQ 之间的流动
    5. 5. 5. 完整的 sched_ext_ops 回调表
      1. 5.1. 5.1 调度核心回调(按触发顺序)
      2. 5.2. 5.2 生命周期回调
      3. 5.3. 5.3 cgroup 回调(需要 CONFIG_EXT_GROUP_SCHED)
      4. 5.4. 5.4 高级回调
      5. 5.5. 5.5 重要语义
    6. 6. 6. 从 scx_simple 入手:逐行详解
      1. 6.1. 6.1 许可证和全局状态
      2. 6.2. 6.2 自定义 DSQ 创建
      3. 6.3. 6.3 select_cpu:快路径——直接把任务发给 idle CPU
      4. 6.4. 6.4 enqueue:慢路径——将任务放入全局队列
      5. 6.5. 6.5 dispatch:CPU 空闲了,给它找活干
      6. 6.6. 6.6 running / stopping:vtime 计费
      7. 6.7. 6.7 enable:任务进入时的初始化
      8. 6.8. 6.8 组装 ops 表
    7. 7. 7. BPF 数据结构和 Map 模式
      1. 7.1. 7.1 统计计数器(PERCPU_ARRAY)
      2. 7.2. 7.2 任务级上下文(TASK_STORAGE)
      3. 7.3. 7.3 BPF Arena(大数据结构)
      4. 7.4. 7.4 BPF 约束速查
    8. 8. 8. 用户态加载器模式
      1. 8.1. 8.1 标准四步加载
      2. 8.2. 8.2 UEI(User Exit Info)机制
      3. 8.3. 8.3 通过 rodata 传递配置
    9. 9. 9. 构建与运行
      1. 9.1. 9.1 构建链
      2. 9.2. 9.2 运行和监控
      3. 9.3. 9.3 应急恢复
    10. 10. 10. 进阶:生产级调度器一览
      1. 10.1. 10.1 scx_layered:生产级案例
    11. 11. 11. 从零实现一个调度器的检查清单
      1. 11.1. 第一步:搭建骨架
      2. 11.2. 第二步:实现最小回调集
      3. 11.3. 第三步:用户态加载器
      4. 11.4. 第四步:测试
    12. 12. 12. 总结

Linux sched_ext:用 BPF 编写用户态调度器完整教程

本文由 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 # 编译所有 C 调度器

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
// 创建自定义 DSQ(node=-1 无 NUMA 亲和,或指定 NUMA 节点 ID)
s32 scx_bpf_create_dsq(u64 dsq_id, s32 node);

// 销毁自定义 DSQ
void scx_bpf_destroy_dsq(u64 dsq_id);

// FIFO 插入:插入到队列尾部
// 可指定目标:SCX_DSQ_LOCAL、SCX_DSQ_GLOBAL、SCX_DSQ_LOCAL_ON|cpu_id、自定义 DSQ
void scx_bpf_dsq_insert(struct task_struct *p, u64 dsq_id,
u64 slice, u64 enq_flags);
// slice: 任务的时间片(纳秒),如 SCX_SLICE_DFL(默认 5ms)

// vtime 优先级插入:按虚拟时间排序,vtime 越小越优先
// 仅支持自定义 DSQ!
void scx_bpf_dsq_insert_vtime(struct task_struct *p, u64 dsq_id,
u64 slice, u64 vtime, u64 enq_flags);

// 从非本地 DSQ 迁移一个任务到当前 CPU 的本地 DSQ
void scx_bpf_dsq_move_to_local(u64 dsq_id, u64 flags);

// 查询 DSQ 中排队的任务数
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; // 用户态通过 rodata 设置
static u64 vtime_now; // 全局虚拟时钟(近似全局公平性的基础)
UEI_DEFINE(uei); // 退出信息记录

const volatile 是 BPF 中用户态与内核态共享配置的惯用模式。UEI 用于在调度器卸载时将退出原因(正常退出/错误/需重启)告知用户态。

6.2 自定义 DSQ 创建

1
2
3
4
5
6
#define SHARED_DSQ 0   // 自定义 DSQ ID(< 2^63,非内置)

s32 BPF_STRUCT_OPS_SLEEPABLE(simple_init) {
return scx_bpf_create_dsq(SHARED_DSQ, -1);
// -1 = 全局可见,不绑定特定 NUMA 节点
}

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;

// 委托给内核默认的 idle CPU 选择逻辑
s32 cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &is_idle);

if (is_idle) {
stat_inc(0); // 本地分发计数
// 目标 CPU 空闲 → 直接把任务插入它的本地 DSQ
// 绕过 enqueue(),这是最快的路径!
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) {
// FIFO 模式:简单插入尾部
scx_bpf_dsq_insert(p, SHARED_DSQ, SCX_SLICE_DFL, enq_flags);
} else {
// vtime 模式:按虚拟截止时间插入优先级队列
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) {
// 从自定义共享队列中取一个最小 vtime 的任务
// 迁移到调用此函数的 CPU 的本地 DSQ
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;
// 全局虚拟时钟前进到正在运行的任务的 vtime
// 这里存在并发竞态,但只影响精度,不影响正确性
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;
// 实际消耗的 CPU 时间 = 分配的时间片 - 剩余时间片
u64 delta = scale_by_task_weight_inverse(p, SCX_SLICE_DFL - p->scx.slice);
// delta 已按权重缩放:高权重任务消耗"更少"的 vtime
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); // [0]=local分发, [1]=global入队
} 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");

// 在 init_task 中创建:
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) { /* 使用 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" // bpftool 自动生成的骨架头文件

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:
// ① OPEN:libbpf 解析 BPF ELF,创建所有 map 对象
skel = SCX_OPS_OPEN(my_ops, my_sched);

// ② 设置配置(必须在 LOAD 之前)
skel->rodata->slice_ns = 10 * NSEC_PER_MSEC;
skel->rodata->fifo_mode = false;

// ③ LOAD:BPF 验证器静态分析 + 加载到内核
SCX_OPS_LOAD(skel, my_ops, my_sched, uei);

// ④ ATTACH:激活!所有 SCHED_NORMAL 任务迁移到 SCX
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);

// CPU 热插拔时自动重启
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
// BPF 侧:
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
// BPF 侧(.bpf.c):
const volatile u64 slice_ns = SCX_SLICE_DFL; // 默认 5ms
const volatile bool use_fifo = false;
const volatile s32 numa_node = -1;

// 用户态侧(.c):
skel->rodata->slice_ns = 20 * NSEC_PER_MSEC;
skel->rodata->use_fifo = true;
// 必须在 SCX_OPS_LOAD() 之前设置!

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
# 以 vtime 模式运行
sudo ./build/bin/scx_simple

# 以 FIFO 模式运行
sudo ./build/bin/scx_simple -f

# 监控调度器状态
cat /sys/kernel/sched_ext/state # enabled / disabled
cat /sys/kernel/sched_ext/root/ops # 当前调度器名称

# 查看某个进程是否被 SCX 调度
grep ext /proc/self/sched # ext.enabled: 1

# Ctrl+C 停止 → 所有任务自动回退 CFS

9.3 应急恢复

1
2
3
# 如果 BPF 调度器卡死或异常:
# SysRq-S:强制中止当前 SCX 调度器,回退到 CFS
# SysRq-D:触发诊断信息输出(不中止调度器)

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. 从零实现一个调度器的检查清单

第一步:搭建骨架

  • 创建 my_sched.bpf.cmy_sched.c
  • 编写 Makefile(可直接复用 scx_simple 的构建系统)
  • 设置 LICENSE("GPL")UEI_DEFINE(uei)

第二步:实现最小回调集

  • init() — 创建 DSQ
  • select_cpu() — 至少调用 scx_bpf_select_cpu_dfl()
  • enqueue() — 至少插入 SCX_DSQ_GLOBAL
  • dispatch() — 如果用了自定义 DSQ
  • exit() — 记录 UEI

第三步:用户态加载器

  • SCX_OPS_OPEN() → 配置 rodata → SCX_OPS_LOAD()SCX_OPS_ATTACH()
  • 主循环中读取统计
  • 信号处理 + 自动重启

第四步:测试

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

# 验证无任务被饿死(观察 5 分钟)

12. 总结

sched_ext 把”编写内核调度器”从”给内核打 9000 行补丁”变成了”用 100 行 BPF C 实现核心逻辑”。它的设计哲学是:

  1. BPF 负责决策(哪个任务先跑、分配多少时间、放哪个 CPU)
  2. sched_ext 框架负责执行(上下文切换、锁管理、安全兜底、定时器、抢占)
  3. DSQ 是二者之间的隔离层(解耦策略和机制)

这种方式下,调度策略的迭代周期从”编译内核 → 重启 → 测试”缩短为”编译 BPF 程序 → 运行 → Ctrl+C 回退”。