土法炼钢兴趣小组的算法知识备份

【eBPF 内核实现深度拆解】sched_ext 深度:用 BPF 写内核调度器

文章导航

分类入口
kernelebpf
标签入口
#ebpf#sched-ext#scheduler#scx#struct_ops#dsq#scx_layered#scx_rustland#linux-kernel

目录

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 不是什么

在拆内核实现之前,先澄清三个常见误解:

  1. sched_ext 不是 CFS/EEVDF 的替换。默认调度器仍然调度绝大多数任务。sched_ext 是额外的调度类,只有被显式分配到 ext 调度类的任务才由 BPF 调度。
  2. sched_ext 不是 “随便写个 BPF 就能调度”。调度 BPF 程序受到 sched_ext 框架的严格控制——包括生命周期管理、错误处理、CPU 热插拔协调。
  3. 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 为准:

在 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/ops

8.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 stop

8.3 关键指标

# ext 调度类的统计信息
cat /sys/kernel/debug/sched/ext/stat
# 输出包含:dispatch 计数、bypass 计数、kick 计数、错误计数

# 查看当前 ext 任务的分布
bpftool map dump id <DSQ_MAP_ID>

九、限制与注意事项

不保证实时性:sched_ext 在调度类优先级中低于 stopdeadline 类。如果需要硬实时保证,使用 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 约束下。

参考资料

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-12 · kernel / ebpf

【eBPF 内核实现深度拆解】从验证器到 JIT,从 BTF 到调度器

eBPF 内核虚拟机内部实现系统讲解:BPF 指令集与寄存器机器、验证器的抽象解释与状态裁剪、JIT 编译器后端、Map 各类型的并发与内存模型、helper 函数注册与类型检查、BTF 格式规范与 CO-RE 重定位引擎、libbpf 加载器工程、fentry/fexit 蹦床机制、sched_ext 调度器内核接口。面向想读懂 eBPF 内核源码、写生产级 BPF 程序的系统工程师。


By .