🤖
AI审核中

换调度器不用重编内核了:Linux sched_ext如何用eBPF重构CPU调度

Java 23分钟 103浏览 0评论

线上接口突然变慢时,我们通常会先看 CPU。

但有一种问题非常反直觉:

  • CPU 使用率只有 60%;
  • 服务吞吐量没有明显下降;
  • 平均响应时间基本正常;
  • P99 延迟却从 200 毫秒增长到了 2 秒;
  • 扩容实例后,问题只能暂时缓解。

这类问题不一定是 CPU 算力不足,也可能是关键线程没有及时获得 CPU。

在同一台服务器上,可能同时运行着请求处理、日志压缩、缓存淘汰、垃圾回收、监控采集和批处理任务。Linux 调度器能够看到哪些线程处于可运行状态,却不知道哪些线程正位于用户请求的关键路径,也不知道某个后台任务晚执行几十毫秒几乎没有影响。

传统解决办法通常是调整线程优先级、绑定 CPU、修改 cgroup 参数,或者直接增加服务器。

但当固定参数已经无法表达复杂的业务策略时,过去只有一条更彻底的路:修改 Linux 内核调度器。

这意味着编写内核补丁、重新编译内核、重启服务器、验证稳定性,并承担系统崩溃的风险。

sched_ext 改变了这条路径。

它允许开发者使用 eBPF 编写 CPU 调度策略,通过用户态程序动态加载到 Linux 内核中。更换调度策略不再一定需要修改和重启整个内核,CPU 调度开始从“内核里的固定实现”,变成一种可以快速部署、试验和回退的系统策略。

一、CPU 使用率正常,为什么接口还是会卡

CPU 使用率只能说明 CPU 在一段时间内有多忙,却不能说明某个线程等待了多久。

假设一台服务器拥有 32 个 CPU 核心,运行着两类任务:

任务类型 特点 对延迟的敏感程度
在线请求线程 单次运行时间短,数量多 非常敏感
后台计算线程 单次占用时间长,可以延后 不敏感

从总体 CPU 使用率来看,服务器可能还有空闲能力。

但如果在线请求线程被安排到繁忙的运行队列,或者频繁在不同 CPU 之间迁移,它仍然可能经历较长的调度等待时间。

因此,下列现象完全可能同时出现:

监控现象 真实情况
CPU 使用率没有达到 100% 某些 CPU 的运行队列已经拥堵
所有核心负载比较平均 线程频繁迁移,缓存命中率下降
平均延迟正常 少量关键请求等待时间异常
吞吐量变化不大 P99、P999 长尾明显恶化
增加线程后性能下降 上下文切换和竞争进一步增加

通用调度器需要同时兼顾桌面、数据库、编译、容器、虚拟机和普通服务器等大量场景,因此不能默认理解每一种业务的优先级。

对调度器来说,两个处于可运行状态的线程只是两个任务。

但对业务系统来说,其中一个线程可能正在生成广告推荐结果,另一个线程可能只是整理昨天的日志。

这就是通用公平策略与业务关键路径之间的语义差距。

二、sched_ext 到底改变了什么

sched_ext 是 Linux 内核中的可扩展调度类,它允许一组 BPF 程序定义调度行为。

它已经从 Linux 6.12 开始进入上游内核。调度策略可以动态加载和卸载,出现内部错误、可运行任务长时间停滞或者管理员主动触发回退时,系统会终止当前 BPF 调度器,并恢复到内核默认的 fair-class 调度器。(Linux内核文档)

传统调度器优化流程通常是:

flowchart LR
    A[发现调度问题] --> B[修改内核代码]
    B --> C[重新编译内核]
    C --> D[重启服务器]
    D --> E[压测验证]
    E --> F[逐步发布]

引入 sched_ext 后,流程可以变成:

flowchart LR
    A[发现调度问题] --> B[编写 BPF 策略]
    B --> C[用户态程序加载]
    C --> D[运行时压测]
    D --> E{效果是否符合预期}
    E -->|是| F[扩大灰度]
    E -->|否| G[停止调度器]
    G --> H[恢复默认调度器]

变化的重点并不是“把调度器完全搬到用户态”。

真正执行调度决策的 BPF 程序仍然运行在内核上下文中,用户态程序主要负责加载程序、提供配置、输出统计信息,以及在部分实现中参与较复杂的策略计算。

可以将整套系统分成三层:

层级 主要职责
Linux 调度核心 管理线程状态、CPU 和调度类
sched_ext BPF 程序 决定线程怎样排队、选择 CPU 和获得时间片
用户态加载器 加载策略、传递配置、输出指标和控制生命周期

因此,sched_ext 不是普通应用层插件,而是一个由 Linux 内核提供安全边界的可编程调度框架。

三、一个线程从唤醒到运行经历了什么

sched_ext 中,一个线程从被唤醒到真正获得 CPU,通常会经过下面这条路径:

flowchart LR
    A[线程被唤醒] --> B[select_cpu 选择 CPU]
    B --> C[enqueue 任务入队]
    C --> D[本地 全局或自定义 DSQ]
    D --> E[dispatch 派发任务]
    E --> F[CPU 本地 DSQ]
    F --> G[线程开始运行]

1. select_cpu:选择候选 CPU

当线程从睡眠状态变成可运行状态时,调度器可以通过 select_cpu 选择一个候选 CPU。

这个结果主要是优化提示,并不是不可更改的最终决定。

如果选择的 CPU 不在该线程允许使用的 CPU 集合中,内核调度核心会忽略无效结果,并选择其他合法 CPU。合理的 CPU 选择可以减少跨核迁移,同时在目标 CPU 空闲时及时将其唤醒。(Linux内核文档)

策略可以考虑:

  • 线程上一次运行在哪个 CPU;
  • 目标 CPU 是否空闲;
  • 两个 CPU 是否共享同一个末级缓存;
  • 当前线程属于哪个业务分组;
  • 目标 NUMA 节点是否拥有本地内存;
  • 是否需要为关键请求保留部分 CPU。

2. enqueue:决定任务排到哪里

如果任务没有在 select_cpu 阶段直接进入本地队列,内核会调用 enqueue

调度器可以选择:

  • 放入某个 CPU 的本地调度队列;
  • 放入全局调度队列;
  • 放入自定义调度队列;
  • 暂时保存在 BPF 数据结构中,等待后续决策。

这一步决定了任务与哪些线程竞争,也决定了任务会以什么顺序被选中。

3. dispatch:给空闲 CPU 找任务

当 CPU 需要选择下一个任务时,会先检查自己的本地调度队列。

如果本地队列为空,再尝试从全局队列获得任务。如果仍然没有可运行任务,内核才会调用 BPF 调度器的 dispatch 回调,让自定义策略向该 CPU 派发任务。(Linux内核文档)

因此,一个完整的调度策略通常需要回答三个问题:

任务应该去哪一个 CPU?

任务应该进入哪一条队列?

当 CPU 空闲时,应该优先运行哪一个任务?

四、DSQ 是 sched_ext 的核心抽象

sched_ext 使用 DSQ,也就是 Dispatch Queue,连接 BPF 调度策略和 Linux 调度核心。

DSQ 可以按照 FIFO 方式工作,也可以作为优先队列使用。系统默认提供一个全局 DSQ,并为每个 CPU 提供一个本地 DSQ;BPF 调度器还可以创建任意数量的自定义 DSQ。CPU 最终始终从自己的本地 DSQ 取出任务执行。(Linux内核文档)

可以把它理解成高速公路的车道系统。

DSQ 类型 类比 适用场景
本地 DSQ 某个收费口的专用车道 直接交给指定 CPU
全局 DSQ 所有收费口共享的等待区 简单、公平的全局兜底
自定义 DSQ 按车辆类型划分的车道 业务优先级、NUMA、租户隔离
BPF 内部队列 调度中心等待区 实现更复杂的决策逻辑

例如,在线服务可以创建三类队列:

  • 关键请求队列;
  • 普通请求队列;
  • 后台任务队列。

当 CPU 空闲时,调度器优先从关键请求队列取任务;只有关键队列没有任务时,才处理普通请求和后台任务。

这并不只是简单提高线程优先级。

更复杂的调度器还可以动态调整每类任务能够使用的 CPU 数量:

  • 高峰期为关键请求增加 CPU;
  • 低峰期把空闲 CPU 让给批处理任务;
  • 避免后台任务迁移到关键请求使用的缓存域;
  • 防止某类任务长期独占全部 CPU;
  • 根据服务延迟实时调整时间片。

调度策略由此从一组固定参数,升级为可以表达状态和反馈逻辑的程序。

五、为什么使用 eBPF,而不是直接加载内核模块

CPU 调度器位于操作系统的核心路径。

一旦调度器出现死循环、丢失可运行任务或者破坏内核状态,整台服务器都有可能失去响应。

sched_ext 选择 eBPF,重要原因之一就是利用 BPF 的验证、限制和退出机制,为调度器试验建立安全边界。

保护机制 作用 不能解决的问题
BPF Verifier 加载前检查程序和内存访问 不能保证业务策略一定合理
动态加载和卸载 不重启内核即可切换策略 切换仍需经过充分测试
可运行任务停滞检测 防止线程被永久遗忘 触发前仍可能出现延迟
自动回退 出错后恢复默认调度器 无法恢复已经超时的请求
调试转储 保存失败时的调度状态 仍需要完善的分析工具

Linux 内核文档明确说明,当检测到内部错误、可运行任务停滞,或者触发 SysRq-S 时,当前 BPF 调度器会被终止,任务会回到默认的 fair-class 调度器。BPF 调度器发生错误时,系统还会生成调试信息;SysRq-D 可以主动触发调试转储,而不立即终止调度器。(Linux内核文档)

此外,BPF Verifier 会在程序加载阶段检查大量不安全行为,降低调度器破坏内核内存的风险。官方设计说明也强调,sched_ext 会通过内核侧机制防止错误调度器无限期饿死任务。(GitHub)

不过,“不会轻易把内核弄崩”并不等于“调度策略一定安全”。

一个逻辑错误的调度器仍然可能:

  • 让某类任务等待过久;
  • 造成大量无效 CPU 迁移;
  • 降低缓存命中率;
  • 破坏租户之间的公平性;
  • 让吞吐量和长尾延迟同时恶化;
  • 在看门狗回退前制造业务故障。

因此,sched_ext 降低的是内核试验门槛,而不是取消性能工程和发布治理。

六、Meta 如何用 sched_ext 优化广告服务

通用调度器的局限,在大型在线服务中表现得尤其明显。

2026 年 7 月,Meta 披露了其广告服务使用 sched_ext 的生产实践。

Meta 的广告工作负载中既有位于延迟关键路径的线程,也有对延迟不太敏感的工作。其自定义策略将 CPU 软划分为两个池:

  • 一个处理延迟敏感任务;
  • 一个处理非关键任务。

两个 CPU 池的规模会根据负载动态调整。策略还会尽量让相关线程持续运行在相近的 CPU 上,以提高末级缓存局部性,减少访问远端内存的成本。(Engineering at Meta)

Meta 披露,其初始上线在最大的广告服务服务器类型上取得了:

  • 广告检索阶段 P99 延迟降低 28%;
  • 加权广告排名数量提升 1.1%;
  • 整个机群节省约 3.28 兆瓦功耗。

随后两次仅修改用户态调度策略的更新,又带来了额外的 P99 延迟下降和关键路径超时减少。Meta 特别强调,后续调度策略可以按天级节奏迭代,而不再完全依赖内核发布周期。(Engineering at Meta)

这些数字是 Meta 在特定硬件、内核版本和广告工作负载上披露的结果,不能直接推导出其他系统也能获得相同收益。

但它证明了一件重要的事情:

当业务已经达到足够规模时,调度器不再只是操作系统内部实现,也可能直接影响业务指标和基础设施成本。

另一个案例来自 Meta 的 GPU 训练机群。

在多 CPU 插槽和大量 GPU 共同工作的训练节点中,数据加载、预处理、检查点保存和通信线程都运行在 CPU 上。微小的 CPU 调度延迟可能导致 GPU 等待数据。Meta 在 Linux Plumbers Conference 2025 上披露,相关策略已经部署到拥有数万块 GPU 的机群中,并使部分模型的 GPU 计算单元利用率提高约 9%。(Indico)

这说明所谓“GPU 利用率不足”,根因不一定在 GPU,也可能是负责供给数据和触发任务的 CPU 线程没有及时运行。

七、sched_ext 不能替代 cgroup

在容器和 Kubernetes 环境中,最容易产生的误解是:

既然 sched_ext 可以控制 CPU 调度,那是不是不再需要 cgroup?

答案是否定的。

两者解决的问题不同。

机制 主要职责
cgroup 定义进程组的资源边界、权重和统计范围
CPU affinity 限定任务允许在哪些 CPU 上运行
Kubernetes CPU Manager 为部分容器分配或绑定 CPU
sched_ext 决定任务怎样排队、选择 CPU 和获得执行机会

Kubernetes 的 kubelet 和容器运行时依赖 Linux cgroup 对 Pod、容器的 CPU 和内存请求及限制进行资源管理。(Kubernetes)

但加载自定义 BPF 调度器后,不能默认认为原有 CPU 控制行为会完全保持不变。

当前 Linux cgroup v2 文档明确指出:

  • cpu.weight 是否影响 BPF 调度器,取决于调度器是否实现相应的 cgroup_set_weight 回调;
  • cpu.max 在文档中被标注为作用于 fair-class 调度器;
  • 部分 CPU 统计项同样区分 fair-class 任务和 BPF 调度器任务。(Linux内核文档)

这意味着在 Kubernetes 节点上测试 sched_ext 时,至少要验证:

检查项 需要回答的问题
CPU Request 不同 Pod 的相对权重是否仍符合预期
CPU Limit 超过限额后是否按照预期限制 CPU 时间
Guaranteed Pod 独占 CPU 是否仍然保持隔离
CPU Manager 自定义策略是否尊重 cpuset 范围
Topology Manager CPU 选择是否破坏 NUMA 和设备亲和性
系统进程 kubelet、容器运行时和内核线程是否会被饿死
监控统计 原有 CPU 使用率和限流指标是否仍然可信

sched_ext 核心会拒绝超出任务合法 CPU 掩码的选择,因此 BPF 调度器不能随意把任务放到不允许使用的 CPU 上。(Linux内核文档)

但“没有越过 cpuset”不等于“容器调度行为完全正确”。

生产调度器仍然需要理解 cgroup 层级、任务权重和不同 QoS 类型,否则它可能在内核层合法运行,却在业务层破坏资源隔离。

八、如何进行一次最小化实验

不建议直接在唯一一台生产服务器上运行陌生的 sched_ext 调度器。

默认情况下,BPF 调度器加载后可能接管系统中的 SCHED_NORMALSCHED_BATCHSCHED_IDLESCHED_EXT 任务,而不只是运行压测程序的单个进程。只有启用部分切换模式时,才会仅接管显式设置为 SCHED_EXT 的任务。(Linux内核文档)

因此,试验环境最好使用:

  • 独立测试服务器;
  • 可以随时重建的测试节点;
  • 从 Kubernetes 集群中隔离的节点;
  • 有远程控制能力的物理机;
  • 没有关键数据的虚拟机。

1. 检查内核版本和配置

uname -r

grep -E 'CONFIG_(BPF|BPF_SYSCALL|BPF_JIT|DEBUG_INFO_BTF|SCHED_CLASS_EXT)=' \
  /boot/config-$(uname -r)

部分系统会把内核配置放在 /proc/config.gz

zgrep -E 'CONFIG_(BPF|BPF_SYSCALL|BPF_JIT|DEBUG_INFO_BTF|SCHED_CLASS_EXT)=' \
  /proc/config.gz

上游文档列出的关键配置包括:

CONFIG_BPF=y
CONFIG_SCHED_CLASS_EXT=y
CONFIG_BPF_SYSCALL=y
CONFIG_BPF_JIT=y
CONFIG_DEBUG_INFO_BTF=y

2. 编译上游示例

在对应的 Linux 内核源码根目录中执行:

make -j"$(nproc)" -C tools/sched_ext

随后启动最基础的示例调度器:

sudo tools/sched_ext/build/bin/scx_simple

scx_simple 是一个用于理解机制的简单调度器,不应该因为它能够运行,就直接把它当作生产优化方案。官方示例仓库同样强调,示例调度器主要用于演示和原型开发。(GitHub)

3. 检查运行状态

cat /sys/kernel/sched_ext/state
cat /sys/kernel/sched_ext/root/ops
cat /sys/kernel/sched_ext/enable_seq

正常情况下可以看到类似结果:

enabled
simple
1

其中:

  • state 表示当前是否启用了 BPF 调度器;
  • root/ops 表示当前调度器名称;
  • enable_seq 表示系统启动后成功加载调度器的次数。

还可以查看当前调度器暴露的事件计数:

cat /sys/kernel/sched_ext/simple/events

这里可以发现:

  • CPU 选择是否频繁回退;
  • 本地 DSQ 目标 CPU 是否离线;
  • 调度器是否进入旁路模式;
  • 是否发生了不合理的重复入队;
  • 默认时间片是否被频繁补充。

这些状态和事件接口已经由上游内核文档提供。(Linux内核文档)

4. 主动回退

对于简单示例,可以在运行调度器的终端按下 Ctrl+C

用户态进程退出后,BPF 调度器会被卸载,系统恢复默认调度器。

需要提前验证的不是“理论上能够回退”,而是:

  • 回退需要多久;
  • 回退期间是否产生延迟抖动;
  • 进程异常退出后能否自动恢复;
  • 调度器卡住时看门狗是否有效;
  • 系统启动时加载失败是否影响业务;
  • 配置更新失败后能否保留旧版本。

九、调度器压测不能只看 CPU 使用率

如果只比较 CPU 使用率,很容易得出错误结论。

调度器优化应该同时覆盖业务、操作系统和硬件三个层次。

业务指标

指标 观察目的
吞吐量 是否处理了更多任务
P50 延迟 普通请求是否改善
P99 和 P999 长尾延迟是否降低
超时率 关键请求是否更稳定
错误率 是否因调度变化出现异常
单请求 CPU 成本 是否只是用更多 CPU 换取延迟

调度指标

指标 观察目的
Run Queue 等待时间 线程获得 CPU 前等待了多久
上下文切换次数 是否产生额外切换成本
CPU Migration 线程是否频繁跨核迁移
任务饥饿时间 某类任务是否长期得不到运行
调度器回退次数 BPF 策略是否不稳定
DSQ 队列长度 哪类任务出现了积压

硬件指标

指标 观察目的
LLC Cache Miss CPU 迁移是否破坏缓存局部性
NUMA Remote Access 是否频繁访问远端内存
IPC 每个 CPU 周期完成的指令数量
内存带宽 后台任务是否挤压关键任务
CPU 频率 调度策略是否影响能耗策略
整机功耗 性能提升是否带来更高能源成本

测试至少要覆盖四种负载:

  1. 只有关键请求;
  2. 只有后台任务;
  3. 两类任务混合运行;
  4. 超过系统容量的过载场景。

只有关键请求时表现良好并不够。

真正困难的是:当批处理、日志、监控和在线流量同时抢占 CPU 时,调度器能否保护关键路径,同时避免后台任务永久饥饿。

十、推荐的灰度发布路径

调度器位于整台节点的基础设施层,因此灰度单位应该是节点,而不是普通应用实例。

flowchart LR
    A[默认调度器基线] --> B[单台测试节点]
    B --> C[故障与回退测试]
    C --> D[少量灰度节点]
    D --> E[同规格节点对照]
    E --> F{业务和系统指标正常}
    F -->|否| G[卸载策略并回退]
    F -->|是| H[按节点分批扩大]

建议按照以下顺序推进。

第一阶段:离线验证

验证程序能够加载、卸载,并确保:

  • BPF Verifier 可以通过;
  • 配置错误不会阻塞启动;
  • 进程退出后能够恢复;
  • 看门狗可以处理任务停滞;
  • 调试转储能够被采集;
  • 不会影响 SSH 和系统管理进程。

第二阶段:固定压测

在同一台机器、同一个内核、同一套业务版本上,分别运行:

  • 不加载 sched_ext 的默认调度器;
  • 加载目标 BPF 调度器。

这样可以最大限度排除硬件、内核和业务版本差异,只比较调度策略本身。

第三阶段:影子节点

复制真实流量特征,但不承载关键业务结果。

重点观察:

  • 长尾延迟;
  • CPU 迁移;
  • 缓存未命中;
  • cgroup 权重;
  • CPU 限额;
  • 系统进程响应能力。

第四阶段:小规模生产灰度

选择少量同规格服务器,建立明确的对照组。

不要只比较灰度前后的平均值,还要排除流量类型、机型、NUMA 拓扑和请求复杂度差异。

第五阶段:自动回退

为调度器设置独立健康判断。

例如,在下列情况发生时自动停止用户态调度器进程:

  • P99 延迟持续恶化;
  • SSH 或节点心跳异常;
  • 任务停滞事件增加;
  • 调度器进入旁路模式;
  • CPU 迁移数量异常增长;
  • 系统进程运行延迟超过阈值;
  • Kubernetes 节点变为 NotReady。

回退条件必须依赖节点级指标,不能只看某一个业务接口。

十一、哪些场景值得使用 sched_ext

sched_ext 最适合那些已经确认存在调度瓶颈,并且通用参数无法解决问题的系统。

适合考虑的场景

在线服务与批处理混部

需要优先保护用户请求,同时利用空闲 CPU 执行后台任务。

长尾延迟高度敏感

平均延迟已经很好,但 P99、P999 仍然影响业务结果。

复杂 NUMA 和缓存拓扑

线程迁移、远端内存和缓存失效已经成为主要性能成本。

GPU、加速卡和 CPU 协同

GPU 经常因为 CPU 数据准备、通信或调度延迟而等待。

超大规模基础设施

单台服务器只有 1% 的优化收益,但整个机群可以节省大量成本。

调度算法研究与验证

需要快速测试新的公平性、优先级、节能或者任务分组策略。

不适合贸然使用的场景

尚未证明问题出在调度器

如果真正瓶颈是数据库、锁、网络或者磁盘,更换 CPU 调度策略不会解决问题。

普通低流量业务

维护自定义调度器的成本可能远高于增加少量服务器。

缺少节点级可观测性

无法观察运行队列、迁移、缓存和 NUMA 指标时,很难判断策略是否真正有效。

依赖严格 cgroup CPU 限制

在没有完整验证权重和限额行为前,不应直接接管容器生产节点。

要求硬实时保证

sched_ext 不应该被简单视为 SCHED_DEADLINE、实时调度类或者 PREEMPT_RT 的替代品。

没有自动回退机制

人工登录服务器停止调度器,不是可靠的生产回滚方案。

判断是否值得深入这一层,可以先问三个问题:

请求慢的时候,线程是否真的在等待 CPU?

调度等待、CPU 迁移或者缓存失效是否已经成为主要成本?

这种成本能否通过线程池、CPU Manager、cgroup 和绑核等简单手段解决?

只有前两个问题答案为“是”,第三个问题答案为“否”,才值得进一步研究自定义调度器。

十二、sched_ext 真正带来的改变

过去,CPU 调度器属于少数内核开发者才能深入修改的区域。

一个新的调度策略从开发到生产,往往需要经历:

  • 修改内核;
  • 重新编译;
  • 重启机器;
  • 长时间稳定性测试;
  • 等待内核版本升级;
  • 在大规模机群中缓慢部署。

sched_ext 没有让 CPU 调度变得简单,也没有让所有开发者都必须编写调度器。

它真正改变的是调度策略的交付方式。

调度器仍然运行在操作系统最关键的路径中,但策略可以通过经过验证的 BPF 程序动态加载;实验失败时,可以退出程序并恢复默认调度器;业务团队也可以把延迟等级、任务类别、缓存拓扑和负载状态编码进调度策略。

这使 CPU 调度从一种固定的内核能力,逐渐变成一层可部署、可观察、可灰度和可回退的基础设施策略。

未来的性能优化可能不再只是:

  • 增加服务器;
  • 扩大线程池;
  • 修改 JVM 参数;
  • 优化 SQL;
  • 增加缓存;
  • 调整容器 CPU Limit。

当系统规模足够大、长尾延迟足够重要时,团队还可以继续向下追问:

哪些线程应该先获得 CPU?

哪些任务应该共享缓存?

哪些后台工作可以主动让路?

CPU 调度是否应该理解业务关键路径?

sched_ext 提供了一种不必永久维护私有内核分支,也能开始回答这些问题的新方式。

0 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  0 条评论