编程 sched_ext 深度拆解:当 Linux 决定把调度器「按 cgroup 分包出租」——从 DSQ 阻抗匹配到 cid 拓扑 ID 与子调度器的架构手术

2026-08-09 00:55:14 +0800 CST views 5

sched_ext 深度拆解:当 Linux 决定把调度器「按 cgroup 分包出租」——从 DSQ 阻抗匹配到 cid 拓扑 ID 与子调度器的架构手术

一句话总结:sched_ext 让「写一个 CPU 调度器」从「改内核 + 重编译 + 重启 + 祈祷不 panic」变成了「cargo build && ./scx_xxx,Ctrl-C 退回默认」。而 2026 年这一版内核里新增的 cid(拓扑有序 CPU ID)sub-sched(按 cgroup 挂载的子调度器),正在把它从「可插拔调度器」推向「调度权可分包转租的多租户调度基础设施」。


一、开场:为什么「通用调度器」注定不够用

先说个每个后端都遇到过的场景。

你有一台 128 核的机器,上面跑着三类东西:

  • 一个 p99 敏感的在线服务(比如网关、推荐打分)
  • 一堆离线批处理(Spark executor、模型训练的 dataloader)
  • 一坨系统噪音(日志采集、监控 agent、容器运行时)

你用 cgroup v2 的 cpu.weight 做了隔离,用 cpuset 做了绑核,甚至上了 cpu.max 限流。然后线上还是出现了这样的现象:在线服务的 p99 在批处理任务起来的那一刻从 12ms 抖到 80ms,但 CPU 利用率只有 55%。

你去翻内核,发现原因可能是:

  • CFS/EEVDF 的 wakeup 路径把任务放到了跨 LLC 的核上,L3 冷启动 + 跨 CCX 的 cache line 迁移吃掉了 30% 的时间
  • select_idle_sibling() 的搜索范围在你这台机器的拓扑上是个「差不多但不对」的启发式
  • 时间片粒度对你的负载来说太长了,一个批处理任务霸占核心 3ms,正好卡在你的 SLA 上

这些问题的共性是:它们不是 bug,它们是通用调度器为了「对所有人都不太差」而必须做出的妥协

Linux 的调度器从 2007 年的 CFS 到后来的 EEVDF,本质上都在做同一件事——用一个统一的公平性模型,去覆盖从手机、桌面、游戏机到 128 核服务器的所有场景。这个模型必然是妥协的产物。而在过去,如果你想改这个妥协,路径只有一条:

改 kernel/sched/fair.c → 重新编译内核 → 全量灰度重启 → 出问题回滚(再重启一次)

一个迭代周期以「月」计。而且一旦你的调度逻辑有 bug,代价是内核 panic 或者整机 hang 死。这就是为什么过去二十年,真正在生产环境里改过调度器的公司屈指可数。

sched_ext 就是来终结这条路径的。


二、sched_ext 是什么:一句话定义与三条硬约束

sched_ext(SCHED_CLASS_EXT)是一个「行为由一组 BPF 程序定义」的调度类。它在 Linux 6.12 完成上游合入,由 Meta 和 Google 主推,目前 Meta 已经在做大规模生产部署。

用工程语言翻译一下:内核提供一套完整的调度接口(struct sched_ext_ops),你用 BPF 实现其中的回调,加载进去,整个系统的任务调度就交给你的代码了。不想要了,Ctrl-C,系统在毫秒级退回默认调度器。

2.1 三条硬约束(这才是它敢让你上生产的原因)

约束一:系统完整性由内核兜底,不由你的代码保证。

内核文档写得非常直白:

The system integrity is maintained no matter what the BPF scheduler does. The default scheduling behavior is restored anytime an error is detected, a runnable task stalls, or on invoking the SysRq key sequence SysRq-S.

也就是说,你的调度器可以写得很烂,最坏的后果是某些线程被饿死到看门狗超时,然后内核把你踢掉、退回 fair class。它不会让系统崩。看门狗超时默认也是最大值 30 秒:

/**
 * @timeout_ms: The maximum amount of time, in milliseconds, that a
 * runnable task should be able to wait before being scheduled. The
 * maximum timeout may not exceed the default timeout of 30 seconds.
 */
u32 timeout_ms;

约束二:优先级低于 fair class。

这点很多中文资料写错了。早期 patch 版本里 sched_ext 的位置反复变过,但看当前主线文档的描述:

when the BPF scheduler is loaded and SCX_OPS_SWITCH_PARTIAL is set in ops->flags, only tasks with the SCHED_EXT policy are scheduled by sched_ext, while tasks with SCHED_NORMAL, SCHED_BATCH and SCHED_IDLE policies are scheduled by the fair-class scheduler which has higher sched_class precedence than SCHED_EXT.

所以 class 优先级是:stop > deadline > rt > fair > ext > idle

这个设计的含义是:即使你的 BPF 调度器完全跑飞,普通的 SCHED_NORMAL shell 会话(在 partial 模式下)仍然能抢占它跑起来,你还有机会 SSH 进去救火。这是一个非常克制的工程决策。

不过要注意默认行为:不设 SCX_OPS_SWITCH_PARTIAL 时,所有 SCHED_NORMAL/SCHED_BATCH/SCHED_IDLE/SCHED_EXT 任务都会被切到 sched_ext 上。也就是默认是「全量接管」,partial 才是「只管标记过的任务」。

约束三:ABI 明确不稳定。

内核文档最后一节标题就叫 ABI Instability

The APIs provided by sched_ext to BPF schedulers programs have no stability guarantees... they are subject to change without warning between kernel versions.

这一条非常重要,是本文所有代码示例的免责声明。scx_bpf_dispatch() 已经改名成 scx_bpf_dsq_insert()scx_bpf_consume() 改成了 scx_bpf_dsq_move_to_local()scx_bpf_select_cpu_dfl() 的第四个参数从 bool *is_idle 变成了 bool *direct你抄任何一篇 2024 年的博客代码,大概率编译不过。

正确的姿势是:把 sched-ext/scx 仓库和你的内核版本对齐,改动看 BREAKING_CHANGES.md

2.2 内核配置

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

验证是否可用:

# 有这个文件说明内核支持
cat /sys/kernel/sched_ext/state
# disabled

# 自开机以来是否加载过 BPF 调度器(单调递增计数器)
cat /sys/kernel/sched_ext/enable_seq
# 0

# 当前进程是否在 sched_ext 上
grep ext /proc/self/sched
# ext.enabled : 0

用户态工具链依赖(来自 scx 仓库):

  • clang >= 16(推荐 >= 17)
  • libbpf >= 1.2.2(推荐 >= 1.3)
  • bpftool(一般在 linux-tools-common
  • Rust toolchain >= 1.82

三、核心概念:DSQ——内核与 BPF 之间的阻抗匹配层

如果你只记住 sched_ext 的一个概念,那就记 DSQ(Dispatch Queue)

内核文档原话:To match the impedance between the scheduler core and the BPF scheduler, sched_ext uses DSQs。「阻抗匹配」这个词用得极妙——它说明 DSQ 不是一个调度算法组件,而是一个协议边界

3.1 三种 DSQ

类型ID数量特性
Local DSQSCX_DSQ_LOCAL每 CPU 一个终点站,CPU 只从这里取任务执行
Global DSQSCX_DSQ_GLOBAL全局一个(内部按 NUMA node 拆分)兜底 FIFO
User DSQ任意 < 2^63 的 u64你想建多少建多少支持 FIFO 和优先级队列两种模式

关键定义在 include/linux/sched/ext.h

enum scx_public_consts {
	SCX_SLICE_DFL		= 20 * 1000000,	/* 20ms */
	SCX_SLICE_INF		= U64_MAX,	/* infinite, implies nohz */
};

enum scx_dsq_id_flags {
	SCX_DSQ_INVALID		= SCX_DSQ_FLAG_BUILTIN | 0,
	SCX_DSQ_GLOBAL		= SCX_DSQ_FLAG_BUILTIN | 1,
	SCX_DSQ_LOCAL		= SCX_DSQ_FLAG_BUILTIN | 2,
	SCX_DSQ_LOCAL_ON	= SCX_DSQ_FLAG_BUILTIN | SCX_DSQ_FLAG_LOCAL_ON,
	SCX_DSQ_LOCAL_CPU_MASK	= 0xffffffffLLU,
};

注意 SCX_SLICE_DFL = 20ms。这是一个非常大的默认时间片(CFS 的最小粒度通常在几百微秒到几毫秒量级)。如果你写调度器时不显式设置 slice,交互延迟大概率会难看。这也是为什么很多生产调度器(比如 scx_cosmos)把默认时间片压到 10μs 级别。

3.2 两个动词:insert 与 move

文档的措辞非常精确,值得逐字读:

A CPU always executes a task from its local DSQ. A task is "inserted" into a DSQ. A task in a non-local DSQ is "move"d into the target CPU's local DSQ.

翻译成状态机:

                  scx_bpf_dsq_insert()
   [ 新唤醒任务 ] ────────────────────▶ [ 某个 DSQ ]
                                            │
                                            │ scx_bpf_dsq_move_to_local()
                                            ▼
                                      [ CPU 的 local DSQ ]
                                            │
                                            ▼
                                       [ 上 CPU 执行 ]

对应的两个核心 kfunc:

/* 插入到任意 DSQ 的 FIFO 尾部 */
void scx_bpf_dsq_insert(struct task_struct *p, u64 dsq_id,
                        u64 slice, u64 enq_flags);

/* 插入到 user DSQ 的优先队列(按 vtime 排序)*/
void scx_bpf_dsq_insert_vtime(struct task_struct *p, u64 dsq_id,
                              u64 slice, u64 vtime, u64 enq_flags);

/* 从指定的非 local DSQ 搬一个任务到「正在 dispatch 的这个 CPU」的 local DSQ */
bool scx_bpf_dsq_move_to_local(u64 dsq_id);

这里有个新手必踩的坑:内建 DSQ(SCX_DSQ_LOCAL / SCX_DSQ_GLOBAL不支持优先队列,只能用 scx_bpf_dsq_insert()。想按 vtime 排序,必须自己 scx_bpf_create_dsq() 建一个 user DSQ。

另一个坑:scx_bpf_dsq_insert()延迟执行的——它只是把插入操作排进队列,最多能排 ops.dispatch_max_batch 个。真正的插入在 ops.dispatch() 返回后或者调用 scx_bpf_dsq_move_to_local() 时被 flush。这个语义会影响你在 ops.dispatch() 里做「先插入再读队列长度」这类逻辑的正确性。


四、调度周期全流程拆解

这一节是全文最需要慢读的部分。我按内核文档的 4 步 + 任务生命周期伪代码,逐段拆开讲。

4.1 第一步:ops.select_cpu()——一个「建议」,不是「决定」

任务被唤醒时(wakeup / fork / exec),第一个被调用的是 ops.select_cpu()。它有两个作用:

  1. CPU 选择的优化提示
  2. 顺便把选中的 CPU 从 idle 里唤醒

划重点,文档原话:

The CPU selected by ops.select_cpu() is an optimization hint and not binding. The actual decision is made at the last step of scheduling.

也就是说,你返回的 CPU 号只是个建议。真正跑在哪个核上,取决于最后哪个 CPU 的 local DSQ 里有这个任务。但如果你的建议和最终结果一致,会有一点性能收益(省掉一次迁移)。

ops.select_cpu() 里可以直接调 scx_bpf_dsq_insert()这会导致 ops.enqueue() 被完全跳过。这是快路径优化的关键:如果唤醒时刚好有空闲核,直接塞进那个核的 local DSQ,整条 enqueue 逻辑都不用走。

内建的默认选核策略也暴露成了 kfunc:

s32 scx_bpf_select_cpu_dfl(struct task_struct *p, s32 prev_cpu,
                           u64 wake_flags, bool *direct);

注意第四个参数在当前主线是 bool *direct(旧版叫 is_idle)。它为 true 表示「默认策略认为可以直接派发」。

还有一组更精细的 idle 查询 kfunc:

const struct cpumask *scx_bpf_get_idle_cpumask(void);
const struct cpumask *scx_bpf_get_idle_smtmask(void);       /* 整核空闲的 mask */
const struct cpumask *scx_bpf_get_idle_cpumask_node(int node);
s32 scx_bpf_pick_idle_cpu(const struct cpumask *cpus_allowed, u64 flags);
s32 scx_bpf_pick_idle_cpu_node(const struct cpumask *cpus_allowed, int node, u64 flags);
s32 scx_bpf_select_cpu_and(struct task_struct *p, s32 prev_cpu, u64 wake_flags,
                           const struct cpumask *cpus_allowed, u64 flags);
bool scx_bpf_test_and_clear_cpu_idle(s32 cpu);

smtmask 那个特别有用:在超线程机器上,优先选「整个物理核都空闲」的逻辑核,能显著减少 SMT 争抢导致的 IPC 下降。

4.2 第二步:ops.enqueue()——三选一

如果没在 select_cpu() 里直接派发,接下来调用 ops.enqueue()。你有三个选择:

  1. 直接插入内建 DSQSCX_DSQ_GLOBAL / SCX_DSQ_LOCAL / SCX_DSQ_LOCAL_ON | cpu
  2. 直接插入自定义 DSQ:任意 < 2^63 的 ID
  3. 自己在 BPF 侧排队(比如塞进一个 BPF map / arena 链表)

选 1 意味着「我这次不管了,交给内核」;选 2 和 3 意味着「这个任务归我托管了」。这个区别引出了下一节最容易写错的概念。

4.3 第三步:ops.dispatch()——CPU 饿了才叫你

CPU 准备调度时的顺序是硬编码的:

1. 看自己的 local DSQ,非空 → 取第一个跑
2. 空 → 尝试从 global DSQ 搬一个过来
3. 还是空 → 调用 ops.dispatch()
4. dispatch 返回后再看 local DSQ:
   - 非空 → 跑第一个
   - 空 → 再试 global DSQ
   - 还空 && dispatch 派发过任务 → 回到 3 重试
   - 还空 && 上一个 SCX 任务还 runnable → 继续跑它(见 SCX_OPS_ENQ_LAST)
   - 否则 → 进 idle

这个顺序解释了一个常见困惑:「为什么我的 ops.dispatch() 没被调用?」——因为 local DSQ 和 global DSQ 里还有任务,轮不到你。

如果你只用内建 DSQ,那 ops.dispatch() 根本不需要实现。

4.4 完整生命周期

内核文档给了一段伪代码,我加了注释:

ops.init_task();            /* 新任务创建 */
ops.enable();               /* 为该任务启用 BPF 调度 */

while (task in SCHED_EXT) {
    if (task can migrate)
        ops.select_cpu();   /* 唤醒时调用,仅仅是优化提示 */

    ops.runnable();         /* 任务变为可运行 */

    while (task_is_runnable(task)) {
        if (task is not in a DSQ || task->scx.slice == 0) {
            ops.enqueue();  /* 可以把任务放进某个 DSQ */

            /* 属性变化?亲和性、nice 值等 */
            if (sched_change(task)) {
                ops.dequeue();   /* 离开 BPF 托管 */
                ops.quiescent();
                /* 属性变更回调,如 ops.set_weight() */
                ops.runnable();
                continue;
            }

            ops.dispatch();     /* 任务被搬到某个 local DSQ */
            ops.dequeue();      /* 离开 BPF 托管 */
        }

        ops.running();      /* 任务开始在分配到的 CPU 上跑 */

        while (task_is_runnable(task) && task->scx.slice > 0) {
            ops.tick();     /* 每 1/HZ 秒调一次 */
            if (task->scx.slice == 0)
                ops.dispatch(); /* slice 可以被续上 */
        }

        ops.stopping();     /* 停止运行(时间片耗尽或阻塞)*/
    }

    ops.quiescent();        /* 让出 CPU(进入等待)*/
}

ops.disable();              /* 关闭该任务的 BPF 调度 */
ops.exit_task();            /* 任务销毁 */

几个文档明确列出的「伪代码没覆盖的边界情况」,实战中都会撞上:

  • ops.dispatch() 可能因为任务属性竞态而搬运失败,此时会重试
  • 任务可能从 ops.enqueue() 被直接 direct-dispatch 到 local DSQ,此时 ops.dispatch()ops.dequeue() 都被跳过,直接进 ops.running()
  • 修改一个正在运行的任务的属性,回调序列是 ops.stopping()ops.quiescent() → (属性变更回调)→ ops.runnable()ops.running()
  • SCX 任务可能被更高优先级调度类的任务抢占,此时它会带着非零 slice 退出 tick 循环

最后一条尤其坑:你不能假设「slice 用完」是任务下 CPU 的唯一原因。任何基于「slice 归零 = 用满了时间片」的统计逻辑都会算错。


五、任务托管(custody)与 ops.dequeue():最容易写错的地方

这是当前主线文档新加的一大段,也是我认为整个 sched_ext 编程模型里最反直觉的部分。

5.1 什么叫「在 BPF 调度器的托管之下」

定义:当 BPF 调度器负责管理一个任务的生命周期时,这个任务处于 BPF 调度器的 custody(托管) 之下。

进入托管的两种方式:

  1. 被派发到 user DSQ(自建的 DSQ)
  2. 被存进 BPF 内部数据结构(BPF map、arena 链表等)

而且只能从 ops.enqueue() 进入托管——唯一的例外是从 ops.select_cpu() 派发到 user DSQ,那种情况在语义上等同于从 ops.enqueue() 派发。

5.2 三种情况,三种 ops.dequeue() 行为

enqueue 里的动作是否进入托管ops.dequeue() 会被调用吗
派发到终点 DSQ(SCX_DSQ_LOCAL / SCX_DSQ_LOCAL_ON|cpu / SCX_DSQ_GLOBAL❌ 不进入不会
派发到 user DSQ✅ 进入会,且恰好一次
存进 BPF 数据结构✅ 进入

这张表是无数「任务凭空消失」bug 的根源。典型错误写法:

/* ❌ 错误示范:以为 dequeue 一定会被调用,在里面做引用计数 */
void BPF_STRUCT_OPS(my_enqueue, struct task_struct *p, u64 enq_flags)
{
	__sync_fetch_and_add(&nr_queued, 1);
	scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
	/* 派发到 GLOBAL 是终点站,ops.dequeue() 不会被调用 */
	/* nr_queued 只增不减,几分钟后彻底失真 */
}

void BPF_STRUCT_OPS(my_dequeue, struct task_struct *p, u64 deq_flags)
{
	__sync_fetch_and_sub(&nr_queued, 1);  /* 永远不会执行 */
}

5.3 ops.dequeue() 的三种触发原因

文档用 flags 区分:

  1. 常规派发:任务从 ops.dispatch() 被派发到终点 DSQ,无特殊 flag
  2. core-sched 挑走CONFIG_SCHED_CORE 开启时,core scheduling 在任务还在托管中就把它挑走执行,flag 为 SCX_DEQ_CORE_SCHED_EXEC
  3. 调度属性变更sched_setaffinity() / sched_setscheduler() / 改优先级 / CPU 迁移等,flag 为 SCX_DEQ_SCHED_CHANGE

而且有一条硬规则:

Important: Once a task has left BPF custody, property changes will not trigger ops.dequeue().

还有一个反直觉点:ops.enqueue() 可以连续被调用多次而中间没有 ops.dequeue()。比如用 scx_bpf_dsq_reenq() 把 user DSQ 上的任务重新入队时就会这样,任务全程都在托管中。

5.4 我的建议

如果你是第一次写 sched_ext 调度器:

先只用内建 DSQ,别碰 user DSQ,更别在 BPF 侧自己排队。

内建 DSQ 路线下你完全不用管 custody,代码量少一个数量级,而且性能对大多数场景已经够用。等你确实需要「按 vtime 排序」或者「多层队列」时,再引入 user DSQ,并且把 custody 的三种情况在纸上画一遍状态图。


六、代码实战 1:40 行写出一个能跑的调度器

这是内核文档里 scx_simple 的精简版,一个全局 FIFO 调度器:

/* simple.bpf.c */
#include <scx/common.bpf.h>

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

UEI_DEFINE(uei);

/*
 * 唤醒时决定任务迁移到哪个 CPU。如果默认的 select_cpu 实现找到了空闲核,
 * 直接把任务插入 SCX_DSQ_LOCAL 并跳过 ops.enqueue()。
 */
s32 BPF_STRUCT_OPS(simple_select_cpu, struct task_struct *p,
                   s32 prev_cpu, u64 wake_flags)
{
	s32 cpu;
	/* 必须初始化,否则 BPF verifier 会拒绝这个程序 */
	bool direct = false;

	cpu = scx_bpf_select_cpu_dfl(p, prev_cpu, wake_flags, &direct);

	if (direct)
		scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SCX_SLICE_DFL, 0);

	return cpu;
}

/*
 * 直接插入全局 DSQ。只有在上面 select_cpu 没找到核时才会走到这里。
 */
void BPF_STRUCT_OPS(simple_enqueue, struct task_struct *p, u64 enq_flags)
{
	scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
}

s32 BPF_STRUCT_OPS_SLEEPABLE(simple_init)
{
	return 0;
}

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

SEC(".struct_ops.link")
struct sched_ext_ops simple_ops = {
	.select_cpu	= (void *)simple_select_cpu,
	.enqueue	= (void *)simple_enqueue,
	.init		= (void *)simple_init,
	.exit		= (void *)simple_exit,
	.name		= "simple",
};

有意思的是文档自己吐槽了:这两个回调的行为和默认实现完全一样,你把它们删掉,调度器行为一模一样。也就是说,struct sched_ext_ops唯一必填的字段是 .name

一个空调度器就是一个可用的全局 FIFO 调度器。这个设计对上手极其友好——你可以从零回调开始,一个一个往里加。

6.1 加载与卸载

$ make -j16 -C tools/sched_ext
$ tools/sched_ext/build/bin/scx_simple
local=0 global=3
local=5 global=24
local=9 global=44
^CEXIT: BPF scheduler unregistered

运行时状态检查:

$ cat /sys/kernel/sched_ext/state
enabled
$ cat /sys/kernel/sched_ext/root/ops
simple

卸载有三条路(这是安全网的具体形态):

  1. 杀掉用户态进程(struct_ops link 引用计数归零)
  2. SysRq-S
  3. 看门狗超时自动踢出

SysRq-D 则是触发一次 debug dump,不会卸载调度器,dump 内容只能通过 sched_ext_dump tracepoint 读。


七、代码实战 2:交互优先的双队列 vtime 调度器

现在写一个真正有策略的。目标:区分交互型任务和 CPU 密集型任务,交互型走短时间片高优先队列,CPU 密集型走长时间片低优先队列。

这基本是 scx_bpfland 的核心思路的简化版:以「每秒自愿上下文切换次数(nvcsw)」作为交互性判据。

7.1 设计

                  ┌──────────────────┐
  wakeup ────────▶│ ops.select_cpu() │──── 有空闲整核 ──▶ SCX_DSQ_LOCAL(快路径)
                  └────────┬─────────┘
                           │ 没有空闲核
                           ▼
                  ┌──────────────────┐
                  │  ops.enqueue()   │
                  └────────┬─────────┘
                           │
              nvcsw 高?───┴───┐ nvcsw 低?
                  ▼            ▼
          PRIO_DSQ(vtime)   SHARE_DSQ(vtime)
          slice = 1ms       slice = 20ms
                  │            │
                  └─────┬──────┘
                        ▼
                ops.dispatch():先吃 PRIO,再吃 SHARE

7.2 BPF 侧完整代码

/* interactive.bpf.c */
#include <scx/common.bpf.h>

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

#define PRIO_DSQ	0
#define SHARE_DSQ	1

#define SLICE_PRIO	(1ULL * 1000000)	/* 1ms  */
#define SLICE_SHARE	(20ULL * 1000000)	/* 20ms */

/* 判定为交互型的阈值:每秒自愿上下文切换次数 */
const volatile u64 nvcsw_thresh = 10;

/* 两条队列各自的虚拟时钟 */
static u64 vtime_prio_now;
static u64 vtime_share_now;

UEI_DEFINE(uei);

struct task_ctx {
	u64	nvcsw_ts;	/* 上次采样时间 */
	u64	nvcsw_last;	/* 上次采样的 nvcsw 值 */
	u64	nvcsw_rate;	/* 每秒自愿切换次数 */
	bool	is_interactive;
};

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 struct task_ctx *lookup_task_ctx(struct task_struct *p)
{
	return bpf_task_storage_get(&task_ctxs, p, 0, 0);
}

/* ---------------- select_cpu:优先找整核空闲 ---------------- */

s32 BPF_STRUCT_OPS(intr_select_cpu, struct task_struct *p,
                   s32 prev_cpu, u64 wake_flags)
{
	const struct cpumask *idle_smtmask;
	s32 cpu = prev_cpu;

	/* 优先复用上次的 CPU(cache 热) */
	if (scx_bpf_test_and_clear_cpu_idle(prev_cpu)) {
		scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SLICE_PRIO, 0);
		return prev_cpu;
	}

	/* 其次找一个「整个物理核都空闲」的逻辑核,避免 SMT 争抢 */
	idle_smtmask = scx_bpf_get_idle_smtmask();
	cpu = scx_bpf_pick_idle_cpu(p->cpus_ptr, SCX_PICK_IDLE_CORE);
	scx_bpf_put_idle_cpumask(idle_smtmask);

	if (cpu >= 0) {
		scx_bpf_dsq_insert(p, SCX_DSQ_LOCAL, SLICE_PRIO, 0);
		return cpu;
	}

	/* 都没有,交给 ops.enqueue() 决策 */
	return prev_cpu;
}

/* ---------------- enqueue:按交互性分流 ---------------- */

void BPF_STRUCT_OPS(intr_enqueue, struct task_struct *p, u64 enq_flags)
{
	struct task_ctx *tctx;
	u64 vtime, now_vtime, slice, dsq_id;

	tctx = lookup_task_ctx(p);
	if (!tctx) {
		/* 兜底:拿不到 task storage 就走 global */
		scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL, SLICE_SHARE, enq_flags);
		return;
	}

	if (tctx->is_interactive) {
		dsq_id    = PRIO_DSQ;
		slice     = SLICE_PRIO;
		now_vtime = vtime_prio_now;
	} else {
		dsq_id    = SHARE_DSQ;
		slice     = SLICE_SHARE;
		now_vtime = vtime_share_now;
	}

	vtime = p->scx.dsq_vtime;

	/* 防止长期睡眠的任务积累过多「vtime 债权」后霸占 CPU */
	if (vtime_before(vtime, now_vtime - slice))
		vtime = now_vtime - slice;

	scx_bpf_dsq_insert_vtime(p, dsq_id, slice, vtime, enq_flags);
}

/* ---------------- dispatch:先高优后普通 ---------------- */

void BPF_STRUCT_OPS(intr_dispatch, s32 cpu, struct task_struct *prev)
{
	if (scx_bpf_dsq_move_to_local(PRIO_DSQ))
		return;
	scx_bpf_dsq_move_to_local(SHARE_DSQ);
}

/* ---------------- running / stopping:推进虚拟时钟 + 统计交互性 ---------------- */

void BPF_STRUCT_OPS(intr_running, struct task_struct *p)
{
	struct task_ctx *tctx = lookup_task_ctx(p);
	if (!tctx)
		return;

	if (tctx->is_interactive) {
		if (vtime_before(vtime_prio_now, p->scx.dsq_vtime))
			vtime_prio_now = p->scx.dsq_vtime;
	} else {
		if (vtime_before(vtime_share_now, p->scx.dsq_vtime))
			vtime_share_now = p->scx.dsq_vtime;
	}
}

void BPF_STRUCT_OPS(intr_stopping, struct task_struct *p, bool runnable)
{
	struct task_ctx *tctx = lookup_task_ctx(p);
	u64 now, delta_ns, slice;

	if (!tctx)
		return;

	/* 按权重折算虚拟运行时间:weight 越大,vtime 走得越慢 */
	slice = SLICE_SHARE - p->scx.slice;
	p->scx.dsq_vtime += slice * 100 / p->scx.weight;

	/* 每秒重算一次 nvcsw 速率 */
	now = scx_bpf_now();
	if (now - tctx->nvcsw_ts >= 1000000000ULL) {
		u64 delta_nvcsw = p->nvcsw - tctx->nvcsw_last;

		tctx->nvcsw_rate     = delta_nvcsw;
		tctx->nvcsw_last     = p->nvcsw;
		tctx->nvcsw_ts       = now;
		tctx->is_interactive = (delta_nvcsw >= nvcsw_thresh);
	}
}

/* ---------------- 任务生命周期 ---------------- */

s32 BPF_STRUCT_OPS(intr_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;

	tctx->nvcsw_ts       = scx_bpf_now();
	tctx->nvcsw_last     = p->nvcsw;
	tctx->is_interactive = true;	/* 乐观假设,一秒后自动修正 */

	/* 新任务的 vtime 从当前时钟起步,避免「新任务白嫖」*/
	p->scx.dsq_vtime = vtime_share_now;
	return 0;
}

/* ---------------- init / exit ---------------- */

s32 BPF_STRUCT_OPS_SLEEPABLE(intr_init)
{
	s32 ret;

	ret = scx_bpf_create_dsq(PRIO_DSQ, -1);
	if (ret)
		return ret;
	return scx_bpf_create_dsq(SHARE_DSQ, -1);
}

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

SEC(".struct_ops.link")
struct sched_ext_ops interactive_ops = {
	.select_cpu	= (void *)intr_select_cpu,
	.enqueue	= (void *)intr_enqueue,
	.dispatch	= (void *)intr_dispatch,
	.running	= (void *)intr_running,
	.stopping	= (void *)intr_stopping,
	.init_task	= (void *)intr_init_task,
	.init		= (void *)intr_init,
	.exit		= (void *)intr_exit,
	.flags		= SCX_OPS_ENQ_LAST,
	.timeout_ms	= 5000,
	.name		= "interactive",
};

7.3 几个设计点值得单独说

(1)为什么要做 vtime 钳制?

if (vtime_before(vtime, now_vtime - slice))
	vtime = now_vtime - slice;

一个睡了 10 分钟的任务,它的 dsq_vtime 停留在 10 分钟前。如果不钳制,它一醒来就是全队最小 vtime,会连续霸占 CPU 直到「还清债务」。这就是经典的 sleeper unfairness 问题。CFS 用 place_entity() 解决,这里我们手动钳制到「不超过一个 slice 的历史优势」。

(2)为什么按 weight 折算 vtime?

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

p->scx.weight 是 nice 值映射过来的权重(nice 0 对应 100)。权重越大,同样的实际运行时间折算成的虚拟时间越少,下次排队时排得越前。这就是「加权公平」的最小实现。

(3)SCX_OPS_ENQ_LAST 是干嘛的?

不设这个 flag 时,如果 CPU 上没有别的任务可跑,内核会让当前任务继续跑(对应 SCX_EV_DISPATCH_KEEP_LAST 事件计数器)。设了这个 flag,最后一个任务也会走 ops.enqueue(),你能拿到完整的控制权。代价是多一次回调开销。

注意校验规则:设了 SCX_OPS_ENQ_LAST 但没实现 ops.enqueue() 会直接加载失败

if ((ops->flags & SCX_OPS_ENQ_LAST) && !ops->enqueue) {
	scx_error(sch, "SCX_OPS_ENQ_LAST requires ops.enqueue() to be implemented");
	return -EINVAL;
}

(4)timeout_ms = 5000 的取舍

默认 30 秒的看门狗太宽松了——一个 bug 导致某类任务饿死,你要等半分钟才会被踢出。开发期建议调到 2~5 秒,能快速暴露饥饿问题。生产环境再根据实际负载放宽。


八、架构演进 1:cid——把 cpumask 换成拓扑有序的稠密 ID

这是 2026 年这一版内核里最重要的架构变化之一,也是目前中文互联网上几乎没人讲的部分。

kernel/sched/ext/cid.h 的文件头注释(Copyright 2026 Meta Platforms / Tejun Heo)把动机讲得极其清楚,我原文贴出来:

Raw cpu numbers are clumsy for sharding work and communication across topology units, especially from BPF: the space can be sparse, numerical closeness doesn't imply topological closeness (x86 hyperthreading often puts SMT siblings far apart), and a range of cpu ids doesn't mean anything.

Sub-scheds make this acute - cpu allocation, revocation and other state are constantly communicated across sub-scheds, and passing whole cpumasks scales poorly with cpu count. cpumasks are also awkward in BPF: a variable-length kernel type sized for the maximum NR_CPUS (4k), with verbose helper sequences for every op.

8.1 问题拆解

原始 CPU 编号有三个毛病:

  1. 稀疏:CPU 号空间不连续(热插拔、offline 核)
  2. 数值相邻 ≠ 拓扑相邻:x86 上超线程兄弟核的编号经常隔着半个机器(比如 CPU 0 和 CPU 64 是一对 SMT sibling)
  3. 一段 CPU 号区间没有任何语义[8, 16) 这个区间不代表任何拓扑单元

再加上 BPF 侧的现实困难:cpumask 是变长类型,按 NR_CPUS(最大 4096)分配,每个操作都要一串 helper 调用。在需要频繁跨调度器传递 CPU 集合的场景下,这个开销不可接受。

8.2 cid 的解法

给每个 CPU 一个稠密的、按拓扑排序的 ID。共享同一个 core / LLC / NUMA node 的 CPU 获得连续的 cid 区间

这样一来:

  • 一个拓扑单元 = 一个 (start, length) 切片
  • 跨调度器通信传一个切片,而不是一整个 cpumask
  • BPF 代码可以一次处理一个 u64 字长的 cid(64 个 CPU)

映射表的构建时机和约束也写得很明确:

The mapping is built once at root scheduler enable time by walking the topology of online cpus only. Going by online cpus is out of necessity: depending on the arch, topology info isn't reliably available for offline cpus. The expected usage model is restarting the scheduler on hotplug events so the mapping is rebuilt against the new online set.

cid 空间的布局:

cid 空间总长 = num_possible_cpus()

┌──────────────────────────────────────┬─────────────────────────┐
│  拓扑标注区(enable 时 online 的 CPU)│  无拓扑区(tail)        │
│  按 core/LLC/NUMA 连续排列            │  topo 信息全是 -1 哨兵值 │
└──────────────────────────────────────┴─────────────────────────┘

对应的内核符号:

extern s16 *scx_cid_to_cpu_tbl;
extern s16 *scx_cpu_to_cid_tbl;
extern struct scx_cid_topo *scx_cid_topo;

8.3 cpu-form vs cid-form:两套 struct_ops

内核为此引入了 struct sched_ext_ops_cid,是 struct sched_ext_ops 的「cid 形态」变体:

/*
 * struct sched_ext_ops_cid - cid-form alternative to struct sched_ext_ops
 *
 *   - select_cpu       -> select_cid (returns cid)
 *   - set_cpumask      -> set_cmask (cmask instead of cpumask)
 *   - cpu_online       -> cid_online
 */

两套 ops 共享字段偏移(内核用 BUILD_BUG_ONscx_init() 里校验),通过匿名 union 访问同一块存储:

struct scx_sched {
	union {
		struct sched_ext_ops		ops;
		struct sched_ext_ops_cid	ops_cid;
	};
	bool	is_cid_type;	/* 通过 bpf_sched_ext_ops_cid 注册则为 true */
	...
};

配套的 cmask 操作是一整套普通 C 函数,没有 cpumask 那些 BPF helper 的仪式感:

void scx_cmask_clear(struct scx_cmask *m);
void scx_cmask_fill(struct scx_cmask *m);
void scx_cmask_and(struct scx_cmask *dst, const struct scx_cmask *src);
void scx_cmask_or(struct scx_cmask *dst, const struct scx_cmask *src);
void scx_cmask_andnot(struct scx_cmask *dst, const struct scx_cmask *src);
bool scx_cmask_subset(const struct scx_cmask *sub, const struct scx_cmask *super);
bool scx_cmask_intersects(const struct scx_cmask *a, const struct scx_cmask *b);
bool scx_cmask_empty(const struct scx_cmask *m);
void scx_cpumask_to_cmask(const struct cpumask *src, struct scx_cmask *dst);

新增的 cid 相关 kfunc:

s32  scx_bpf_task_cid(struct task_struct *p);
s32  scx_bpf_this_cid(void);
u32  scx_bpf_nr_cids(void);
u32  scx_bpf_nr_online_cids(void);
void scx_bpf_kick_cid(s32 cid, u64 flags);
struct task_struct *scx_bpf_cid_curr(s32 cid);
u32  scx_bpf_cidperf_cap(s32 cid);
u32  scx_bpf_cidperf_cur(s32 cid);
void scx_bpf_cidperf_set(s32 cid, u32 perf);

注意最后三个 cidperf_*——这是 CPU 频率/性能提示接口(对应 cpu-form 的 scx_bpf_cpuperf_*)。调度器可以直接给 cpufreq governor 下指令,这在 big.LITTLE / P-core+E-core 混合架构上是刚需。

8.4 cid-form 还带来了 BPF arena 强制约束

/*
 * Arena map auto-discovered from member progs at struct_ops attach.
 * cid-form schedulers must use exactly one arena across all member progs.
 * NULL on cpu-form.
 */
struct bpf_map	*arena_map;
struct gen_pool	*arena_pool;
uintptr_t	arena_kern_base;

**cid-form 调度器必须用且只能用一个 BPF arena。**BPF arena 是相对新的特性,它给 BPF 程序提供了一块可以用普通指针访问的、内核和 BPF 共享的稀疏内存区域——你可以在里面搭链表、红黑树这些传统 BPF map 做不了的数据结构。

内核文档里提到的 scx_qmap 就是「用 arena 支撑的双向链表实现五级 FIFO」,scx_sdt 则是「演示用 arena 管理 per-task 数据」。

这条约束的含义是:cid-form 是给「重度定制、需要复杂数据结构」的调度器准备的。如果你只是写个简单策略,cpu-form 依然够用而且门槛低得多。


九、架构演进 2:子调度器——按 cgroup 把调度权再分包

这是我认为 sched_ext 最有想象力的演进方向。

9.1 配置项

config EXT_SUB_SCHED
        def_bool y
        depends on SCHED_CLASS_EXT && CGROUPS

默认开启,只要你开了 SCHED_CLASS_EXTCGROUPS

9.2 怎么挂载

struct sched_ext_ops 里多了一个字段:

/**
 * @cgroup_id: When >1, attach the scheduler as a sub-scheduler on the
 * specified cgroup.
 */
u64 sub_cgroup_id;

也就是说:加载一个 BPF 调度器时,如果指定了 sub_cgroup_id,它不会接管整个系统,而是只接管那个 cgroup 子树里的任务。

父调度器侧有两个新回调做准入控制:

/* argument container for ops.sub_attach() */
struct scx_sub_attach_args {
	struct sched_ext_ops	*ops;
	char			*cgroup_path;
};

/**
 * @sub_attach: Attach a sub-scheduler
 * Return 0 to accept the sub-scheduler. -errno to reject.
 */
s32 (*sub_attach)(struct scx_sub_attach_args *args);

/**
 * @sub_detach: Detach a sub-scheduler
 */
void (*sub_detach)(struct scx_sub_detach_args *args);

**父调度器有一票否决权。**这是整个设计的政治学核心:平台团队写 root 调度器,在 sub_attach() 里检查 cgroup 路径、检查子调度器的 ops 声明,决定是否放行业务团队自带的调度器。

9.3 父调度器怎么把 CPU 时间「转租」给子调度器

新增的 kfunc:

/**
 * scx_bpf_sub_dispatch - Trigger dispatching on a child scheduler
 * @cgroup_id: cgroup ID of the child scheduler to dispatch
 *
 * Allows a parent scheduler to trigger dispatching on one of its direct
 * child schedulers. The child scheduler runs its dispatch operation to
 * move tasks from dispatch queues to the local runqueue.
 *
 * Returns: true on success, false if cgroup_id is invalid, not a direct
 * child, or caller lacks dispatch permission.
 */
__bpf_kfunc bool scx_bpf_sub_dispatch(u64 cgroup_id, const struct bpf_prog_aux *aux);

注意实现里的这段校验:

if (unlikely(scx_parent(child) != parent)) {
	scx_error(parent, "trying to dispatch a distant sub-sched on cgroup %llu",
	          cgroup_id);
	return false;
}

**只能调度自己的直接子节点,不能越级。**这就是一棵严格的调度器树,权限逐层下放。

于是 root 调度器的 ops.dispatch() 变成了这样一种形态:

void BPF_STRUCT_OPS(root_dispatch, s32 cpu, struct task_struct *prev)
{
	u64 cgid;
	int i;

	/* 先看自己直管的任务(系统进程、未被分包的负载) */
	if (scx_bpf_dsq_move_to_local(SYSTEM_DSQ))
		return;

	/* 按某种配额/权重策略,轮流让子调度器出牌 */
	bpf_for(i, 0, nr_children) {
		cgid = pick_next_child_by_quota(cpu, i);
		if (!cgid)
			continue;
		if (scx_bpf_sub_dispatch(cgid))
			return;
	}

	/* 谁都没货,兜底 */
	scx_bpf_dsq_move_to_local(SCX_DSQ_GLOBAL);
}

这个模型的表达力非常强:**root 负责「资源分配 / 配额 / 隔离」,sub 负责「本租户内部的调度策略」。**这正好对应云厂商和大型公司内部平台的组织结构——平台团队和业务团队的边界,第一次可以直接映射到内核调度器的代码边界上。

9.4 层级 bypass:故障隔离怎么做

最精妙的设计在 bypass 逻辑里。看这段注释:

static struct scx_dispatch_q *bypass_enq_target_dsq(struct scx_sched *sch, s32 cpu)
{
#ifdef CONFIG_EXT_SUB_SCHED
	/*
	 * If @sch is a sub-sched which is bypassing, its tasks should go into
	 * the bypass DSQs of the nearest ancestor which is not bypassing. The
	 * not-bypassing ancestor is responsible for scheduling all tasks from
	 * bypassing sub-trees. If all ancestors including root are bypassing,
	 * all tasks should go to the root's bypass DSQs.
	 *
	 * Whenever a sched starts bypassing, all runnable tasks in its subtree
	 * are re-enqueued after scx_bypassing() is turned on, guaranteeing that
	 * all tasks are transferred to the right DSQs.
	 */
	while (scx_parent(sch) && scx_bypassing(sch, cpu))
		sch = scx_parent(sch);
#endif
	return bypass_dsq(sch, cpu);
}

翻译成人话:

某个业务团队的子调度器写崩了 → 它进入 bypass 模式 → 它的任务自动上交给「最近的一个没崩的祖先调度器」接管 → 其他租户完全不受影响。

如果所有祖先包括 root 都崩了,任务才落到 root 的 bypass DSQ。而且切换时有一致性保证:一旦某个调度器开始 bypass,它子树里所有 runnable 任务都会被重新入队,确保全部转移到正确的 DSQ。

这是一个分层的故障域设计。对比一下现状:今天你在 K8s 上跑多租户,一个租户的调度策略出问题,你只能靠 cgroup 限流硬扛。有了 sub-sched,故障半径被限制在单个 cgroup 子树内,而且降级是自动的、有兜底的。

9.5 硬约束:sub-sched 必须是 cid-form

/*
 * Sub-scheduler support is tied to the cid-form struct_ops. A sub-sched
 * attaches through a cid-form-only interface (sub_attach/sub_detach),
 * and a root that accepts sub-scheds must expose cid-form state to
 * them. Reject cpu-form schedulers on either side.
 */
if (!sch->is_cid_type) {
	if (scx_parent(sch)) {
		scx_error(sch, "sub-sched requires cid-form struct_ops");
		return -EINVAL;
	}
	if (ops->sub_attach || ops->sub_detach) {
		scx_error(sch, "sub_attach/sub_detach requires cid-form struct_ops");
		return -EINVAL;
	}
}

现在回过头看第八节就通了:**cid 不是一个独立的优化,它是 sub-sched 的前置基础设施。**因为子调度器之间要频繁交换 CPU 分配/回收状态,传 cpumask 在 128 核以上完全扛不住,所以必须先有稠密拓扑 ID。

还有一条依赖关系:

/*
 * SCX_OPS_TID_TO_TASK is enabled by the root scheduler. A sub-sched
 * may set it to declare a dependency; reject if the root hasn't
 * enabled it.
 */

子调度器可以「声明依赖」某个 flag,但只有 root 有权真正开启。能力向下继承,权限向上收敛。

9.6 我的判断

sub-sched 是 sched_ext 从「可插拔调度器」向「调度基础设施」的质变。

它解决的问题是组织性的,不只是技术性的:在一个几千人的工程组织里,「谁有权改调度策略」一直是个死结——不改,业务的特殊需求满足不了;随便改,平台稳定性没人兜底。sub-sched 给出的答案是:用内核强制执行的层级授权模型,把调度权安全地分包出去。

这在架构上和 cgroup 本身是同构的——cgroup 分包的是资源额度,sub-sched 分包的是资源的分配算法


十、生态全景:scx 仓库里那些能上生产的调度器

写调度器之前先看看别人写了什么。sched-ext/scx 仓库现在是 Rust 用户态 + BPF 内核态的组合(C 示例已经移到内核 tools/sched_ext 和独立的 scx-c-examples 仓库)。

10.1 生产级的几个

调度器核心算法目标场景生产就绪
scx_lavdLAVD(延迟关键性感知的虚拟截止期)游戏、高交互
scx_layered多层 + 用户态策略,可按 cgroup/属性分层大规模服务器混部
scx_rusty多域 + 用户态负载均衡通用服务器
scx_bpflandvruntime + 交互性分类(纯 BPF)游戏、直播、实时音频
scx_flashEDF + 动态延迟权重多媒体、实时音频
scx_cosmos局部性优先,饱和时切 deadline通用(桌面/服务器自适应)
scx_p2dqPick-two 负载均衡 + 多层队列通用
scx_tickless主 CPU 池集中调度,其余 CPU 关 tick云计算、虚拟化、HPC
scx_rustland调度决策全在用户态 Rust实验/教学

10.2 几个值得单独说的设计

scx_lavd:核心思想是「测量任务的延迟关键性,然后用这个信息去调整 deadline 和时间片」。它按 LLC、按核类型(Intel P/E core,ARM big/LITTLE)、按 NUMA 域各建一个调度域。Valve 那边的 Linux 游戏体验优化就是这条线。

scx_layered:Meta 的主力。它的定位是「高度可配置」——你可以定义「所有 user.slice 里的任务算一层,这层保证至少 80% 的 CPU 利用率」。README 里明确提到一个坑:

you may run into an issue with infeasible weights, where a task with a very high weight may cause the scheduler to incorrectly leave cores idle because it thinks they're necessary to accommodate the compute for a single task. This can also happen in CFS.

权重不可行问题——单个任务权重设得极高时,调度器会误以为需要留着核给它,结果核空转。这个坑 CFS 也有。

scx_cosmos:设计很有意思,是一个双模态调度器:

  • 系统未饱和时:优先把任务钉在同一个 CPU 上,用 local DSQ(既保 cache 局部性,又避免共享 DSQ 的锁竞争)
  • 系统饱和后:切到 deadline 策略 + 共享 DSQ(或按 NUMA node 分的 DSQ)

并且它用 timer 批量延迟 CPU 唤醒,把 enqueue 开销摊薄,从而支持默认 10μs 的超短时间片

scx_tickless:给云和 HPC 的。所有调度事件路由到一个主 CPU 池(默认只有 CPU 0),其他 CPU 可以关掉 tick,把 OS noise 降到最低。抢占通过 IPC 从主 CPU 发出。注意前提:内核必须用 nohz_full 启动,而 nohz_full 会引入系统调用开销——所以它明确说了「不适合延迟敏感负载」。

10.3 安装

# Rust 调度器都发在 crates.io
cargo install scx_rusty
cargo install scx_lavd

# 或者从源码构建
git clone https://github.com/sched-ext/scx
cd scx
cargo build --release              # 全部
cargo build --release -p scx_rusty # 单个

各发行版都有打包:Ubuntu、Arch、Gentoo、Fedora、Nix、openSUSE Tumbleweed。


十一、可观测性与性能调优

11.1 events 计数器:每一条都是一类 bug

每个运行中的调度器在 /sys/kernel/sched_ext/<name>/events 暴露诊断计数器:

$ cat /sys/kernel/sched_ext/simple/events
SCX_EV_SELECT_CPU_FALLBACK 0
SCX_EV_DISPATCH_LOCAL_DSQ_OFFLINE 0
SCX_EV_DISPATCH_KEEP_LAST 123
SCX_EV_ENQ_SKIP_EXITING 0
SCX_EV_ENQ_SKIP_MIGRATION_DISABLED 0
SCX_EV_REENQ_IMMED 0
SCX_EV_REENQ_LOCAL_REPEAT 0
SCX_EV_REFILL_SLICE_DFL 456789
SCX_EV_BYPASS_DURATION 0
SCX_EV_BYPASS_DISPATCH 0
SCX_EV_BYPASS_ACTIVATE 0
SCX_EV_INSERT_NOT_OWNED 0
SCX_EV_SUB_BYPASS_DISPATCH 0

我按「这个数字非零意味着什么」重新组织一遍:

计数器非零意味着你该做什么
SCX_EV_SELECT_CPU_FALLBACK你的 select_cpu() 返回了任务不能用的 CPU,内核默默换了一个检查是否忽略了 p->cpus_ptr,这是最常见的性能杀手
SCX_EV_DISPATCH_LOCAL_DSQ_OFFLINE派发目标 CPU 下线,任务被重定向到 global DSQ有 CPU 热插拔,考虑实现 hotplug 回调或重启调度器
SCX_EV_DISPATCH_KEEP_LAST没有别的任务,当前任务继续跑正常。想接管这个决策就设 SCX_OPS_ENQ_LAST
SCX_EV_ENQ_SKIP_EXITING退出中的任务被直接派发,绕过了你的 enqueue()正常。要接管就设 SCX_OPS_ENQ_EXITING
SCX_EV_ENQ_SKIP_MIGRATION_DISABLED禁止迁移的任务被直接派发到 local DSQ正常。要接管就设 SCX_OPS_ENQ_MIGRATION_DISABLED
SCX_EV_REENQ_IMMEDSCX_ENQ_IMMED 派发但目标 CPU 不可立即执行,被重新入队检查 IMMED 的使用是否过于乐观
SCX_EV_REENQ_LOCAL_REPEATlocal DSQ 的 reenqueue 又触发了一次 reenqueue持续增长说明你的 SCX_ENQ_REENQ 处理有 bug
SCX_EV_REFILL_SLICE_DFL你忘了设时间片,内核用 20ms 默认值补上了数值巨大说明你的 slice 逻辑有漏网之鱼
SCX_EV_BYPASS_ACTIVATE / _DURATION / _DISPATCH进入过 bypass 模式(加载/卸载/错误恢复)加载卸载时正常。运行期非零 = 出过错
SCX_EV_INSERT_NOT_OWNED试图把不属于本调度器的任务插入 DSQ,被静默忽略sub-sched 场景下越权,检查任务归属
SCX_EV_SUB_BYPASS_DISPATCH从子调度器 bypass DSQ 派发的任务数说明某个子调度器崩了,被祖先接管

我特别想强调 SCX_EV_REFILL_SLICE_DFL 这一条。很多人写完调度器测出来交互延迟难看,查半天查不出原因,其实就是某条路径漏设了 slice,任务拿了 20ms 的默认片。这个计数器就是照妖镜。

11.2 更细的状态

$ tools/sched_ext/scx_show_state.py
ops           : simple
enabled       : 1
switching_all : 1
switched_all  : 1
enable_state  : enabled (2)
bypass_depth  : 0
nr_rejected   : 0
enable_seq    : 1

nr_rejected 非零说明有任务被拒绝加入你的调度器(通常是 ops.init_task() 返回了错误),值得警惕。

bypass_depth 非零说明当前正在 bypass。

11.3 module 参数(调试用)

# bypass 模式下所有任务的时间片,默认 5000μs,范围 100μs ~ 100ms
cat /sys/module/sched_ext/parameters/slice_bypass_us

# bypass 模式下负载均衡器重分布任务的间隔,默认 500000μs(0.5s)
# 设 0 关闭 bypass 期间的负载均衡,范围 0 ~ 10s
cat /sys/module/sched_ext/parameters/bypass_lb_intv_us

文档明确说了:These knobs are primarily for debugging; there is usually no reason to change them during normal operation. 别在生产上乱动。

11.4 运行时监控

scx 的调度器现在默认不打印统计,需要手动开:

# 每 0.5 秒打印一次
scx_bpfland --monitor 0.5

# 直接从调度实例打统计
scx_rusty --stats 5

scx_rusty --monitor 5 的输出长这样,信息密度很高:

###### load balance @ -265.1ms ######
cpu= 0.00 load= 0.17 mig=0 task_err=0 lb_data_err=0 time_used= 0.0ms
tot= 15 sync_prev_idle= 0.00 wsync= 0.00
prev_idle= 0.00 greedy_idle= 0.00 pin= 0.00
dir= 0.00 dir_greedy= 0.00 dir_greedy_far= 0.00
dsq=100.00 greedy_local= 0.00 greedy_xnuma= 0.00
slice=20000us
  NODE[00] load= 0.17 imbal= +0.00 delta= +0.00
   DOM[00] load= 0.17 imbal= +0.00 delta= +0.00

dsq=100.00 表示 100% 的派发走了 DSQ 路径(没有走 direct dispatch 快路径),在轻载下这是一个可优化信号。

11.5 七条调优经验

**1. 先量 SCX_EV_REFILL_SLICE_DFL,再谈别的。**这个数如果和上下文切换总数一个量级,说明你的时间片管理基本没生效。

**2. 时间片不是越短越好,找拐点。**短时间片降低延迟但增加上下文切换开销。经验区间:交互任务 0.52ms,批处理 1050ms。scx_cosmos 敢用 10μs 是因为它同时做了唤醒批处理。

**3. 优先用 smtmask 而不是 cpumask 选核。**在超线程机器上,两个任务跑在同一物理核的两个逻辑核上,IPC 可能掉 30~40%。优先选整核空闲。

**4. 尊重 p->cpus_ptr。**忽略任务亲和性会让 SCX_EV_SELECT_CPU_FALLBACK 飙升,每次 fallback 都是一次白做的工作。

**5. 跨 LLC 迁移要有阻尼。**贸然把任务迁到另一个 LLC,L3 冷启动的代价可能是几十微秒。scx_rusty 的多域设计和 scx_p2dq 的 pick-two 都是在处理这个问题。

6. 用 ops.dispatch_max_batch 控制批量。scx_bpf_dsq_insert() 是延迟执行的,一次 ops.dispatch() 里可以攒最多 dispatch_max_batch 个待插入任务。批量太小则回调频繁,太大则延迟增加。

**7. 开发期把 timeout_ms 调到 2000~5000。**默认 30 秒的看门狗会让饥饿 bug 藏得太深。


十二、十条踩坑清单

坑 1:抄了 2024 年的博客代码,编译不过。
scx_bpf_dispatch()scx_bpf_dsq_insert()scx_bpf_consume()scx_bpf_dsq_move_to_local()scx_bpf_select_cpu_dfl() 第四参 is_idledirect。ABI 明确不保证稳定,永远以你内核版本的 tools/sched_ext 和 scx 仓库的 BREAKING_CHANGES.md 为准。

坑 2:往内建 DSQ 插入 vtime 任务。
SCX_DSQ_LOCAL / SCX_DSQ_GLOBAL 不支持优先队列,只能 scx_bpf_dsq_insert()。想排序必须自建 user DSQ。

坑 3:以为 ops.dequeue() 总会被调用。
派发到终点 DSQ 的任务从来没进入过托管,ops.dequeue() 不会触发。基于它做引用计数会直接失真。详见第五节。

坑 4:BPF verifier 拒绝,因为变量没初始化。
内核文档在示例代码里专门加了注释:/* Need to initialize or the BPF verifier will reject the program */。所有传给 kfunc 的输出参数都要先初始化。

坑 5:在 ops.select_cpu() 里把任务存进 BPF 数据结构。
文档明确说这是 discouraged 的——它不会阻止 ops.enqueue() 被调用,会导致竞态和状态不一致。要么在 select_cpu() 里直接 dsq_insert(跳过 enqueue),要么什么都别做。

坑 6:scx_bpf_dsq_insert() 在持有 BPF 锁时调用。
当前不支持(文档说在做了)。scx_bpf_dsq_move_to_local() 同样不能在持锁时调用。

坑 7:没设 ops.enqueue() 却设了 SCX_OPS_ENQ_LAST
加载直接失败,报 SCX_OPS_ENQ_LAST requires ops.enqueue() to be implemented

坑 8:SCX_OPS_BUILTIN_IDLE_PER_NODEops.update_idle() 冲突。
如果实现了 ops.update_idle() 但没设 SCX_OPS_KEEP_BUILTIN_IDLE,同时又想用 per-node idle,会报 requires CPU idle selection enabled

坑 9:忘了 sub-sched 必须是 cid-form。
cpu-form 的调度器带 sub_cgroup_id 或者实现 sub_attach/sub_detach,一律拒绝加载。

坑 10:热插拔后 cid 映射失效。
cid 映射只在 root 调度器 enable 时按当时 online 的 CPU 构建一次。预期用法是热插拔事件后重启调度器。想不重启就处理热插拔,需要自己实现 cid 和 shard 映射的 override 接口。另外 ops.hotplug_seq 可以用来检测加载过程中发生的热插拔——不匹配就加载失败,避免用过期拓扑跑起来。


十三、总结与展望

回头看这条技术线,sched_ext 的演进有清晰的三个阶段:

第一阶段(6.12 合入):把调度器变成可插拔的。
核心贡献是 DSQ 这个阻抗匹配层,加上「内核兜底、绝不崩机」的安全网。它让「改调度器」的迭代周期从月降到分钟。

第二阶段(生态成熟):出现了一批真能上生产的调度器。
scx_lavd 让 Linux 游戏体验有了实质改善,scx_layered 在 Meta 大规模部署,scx_cosmos 这种双模态设计开始探索「一个调度器同时服务桌面和服务器」的可能。这个阶段证明了:领域特化的调度策略确实能带来通用调度器给不了的收益。

第三阶段(cid + sub-sched):调度权本身变成可分包的资源。
这才是真正的架构跃迁。当你可以按 cgroup 挂载子调度器、父调度器有准入否决权、子调度器崩了自动上交给祖先接管——调度器就不再是「一个内核组件」,而是「一套多租户的调度基础设施」。

几个我在观察的方向

1. K8s 会不会长出 sched_ext 的抽象层?
现在 K8s 的 CPU 管理停留在 cpuset + cgroup 层面。如果 sub-sched 成熟,完全可以想象一个 CRD:SchedulerPolicy,让不同的 namespace 挂不同的 BPF 调度策略。故障隔离由内核的层级 bypass 保证。这个东西一旦出现,混部的资源利用率天花板会被抬高一大截。

2. 异构算力的调度会被重写。
scx_bpf_cidperf_set() 这类接口意味着调度器可以直接指挥 cpufreq。在 P-core/E-core、big.LITTLE、以及未来更复杂的异构架构上,「把哪个任务放到哪种核上并跑在哪个频点」是一个联合优化问题,只有调度器有全局信息。现在这个决策权终于可以下放到用户态代码里了。

3. 调度策略会不会变成「可学习」的?
用户态调度器(scx_rustland)已经证明了「在用户态做调度决策」是可行的。那么下一步——用在线学习的模型来做时间片和选核决策——技术上没有障碍。挑战在于决策延迟必须压到微秒级,以及可解释性和可回滚性。

最后给个上手路线

如果你想动手:

  1. 找一台能装新内核的机器(Arch / Fedora / Ubuntu 都有 scx 打包),确认 cat /sys/kernel/sched_ext/state 有输出
  2. 先跑现成的:cargo install scx_bpfland,然后 scx_bpfland --monitor 5,一边编译内核一边看你的桌面卡不卡
  3. tools/sched_ext/scx_simple.bpf.c,全文不到 200 行
  4. 照着第七节的双队列 vtime 调度器改一个自己的,先跑虚拟机,别上物理机
  5. 盯着 /sys/kernel/sched_ext/<name>/events 调,特别是 SCX_EV_REFILL_SLICE_DFLSCX_EV_SELECT_CPU_FALLBACK
  6. 觉得自己写得还行了,去 scx 的 Discord(每周二有 office hours)挨顿批评

调度器这个领域被「只有内核大佬能碰」的光环笼罩了二十年。sched_ext 做的事情,本质上就是把这个光环摘掉——它不能让你写出更好的调度器,但它让「写一个更好的调度器」这件事,从一个需要审批的项目,变成了一个下午就能开始的实验。

这个区别,比任何单一的性能数字都重要。


参考与延伸

  • Linux 内核文档:Documentation/scheduler/sched-ext.rst
  • 内核源码:kernel/sched/ext/{ext.c,idle.c,cid.c,arena.c,internal.h}include/linux/sched/ext.h
  • 调度器实现仓库:github.com/sched-ext/scx(Rust 用户态)、github.com/sched-ext/scx-c-examples(C 示例)
  • LWN:The extensible scheduler class(2023-02)
  • Linux Plumbers Conference sched_ext MC(2024 / 2025)

免责声明:本文引用的 API 和字段基于撰稿时的主线内核。sched_ext 的 BPF 接口明确不提供稳定性保证,任何代码在使用前请对照你实际内核版本的头文件核对。

推荐文章

Linux 网站访问日志分析脚本
2024-11-18 19:58:45 +0800 CST
使用xshell上传和下载文件
2024-11-18 12:55:11 +0800 CST
GROMACS:一个美轮美奂的C++库
2024-11-18 19:43:29 +0800 CST
MySQL 1364 错误解决办法
2024-11-19 05:07:59 +0800 CST
Vue3中如何处理异步操作?
2024-11-19 04:06:07 +0800 CST
55个常用的JavaScript代码段
2024-11-18 22:38:45 +0800 CST
JS中 `sleep` 方法的实现
2024-11-19 08:10:32 +0800 CST
程序员茄子在线接单