一、调度器困境:为什么 CFS 不够用了?

Linux CFS(Completely Fair Scheduler)自 2.6.23 内核(2007 年)引入以来,一直作为默认的进程调度器。它设计精妙,在通用负载下表现优异。然而在以下场景中,CFS 的"一刀切"策略显得力不从心:

  • 异构多核平台:ARM big.LITTLE / DynamIQ 架构中,大核和小核的微架构差异巨大,CFS 的负载均衡很难做出最优决策
  • 实时 + 批处理混合负载:延迟敏感型 ML 推理服务与后台批处理任务共存时,CFS 无法做细粒度的 QoS 保障
  • NUMA 感知不足:多路服务器上,CFS 的 NUMA 平衡策略在某些数据库/缓存场景下导致性能抖动
  • 定制策略需求:云厂商需要实现特定的虚拟机调度策略,但修改 CFS 必须重新编译内核
  • 能源效率优化:移动设备上需要更激进的核心空闲策略以延长续航

核心矛盾:内核社区倾向于一个通用、稳定的调度器,而生产环境需要定制化、可快速迭代的调度策略。过去要自定义调度只能修改内核源码或写内核模块——前者周期长、维护成本高,后者版本耦合紧密、稳定性差。

二、sched_ext 的诞生:BPF 赋能的调度器扩展框架

2.1 架构设计理念

sched_ext(Scheduler Extension)是 Linux 6.12(2024 年 11 月发布)引入的新框架。它允许用户态通过 BPF 程序定义自定义调度器,并以安全、隔离的方式加载到内核中运行。

核心设计哲学:

  1. Sched as a Service:调度策略是可插拔的,多个调度器可以共存(per-CPU 模式切换)
  2. BPF 沙盒:调度策略运行在 BPF 虚拟机中,无法直接崩溃内核
  3. 渐进式采用:可以从"仅接管部分 CPU"开始,逐步扩展到全系统
  4. 零内核修改:无需重新编译内核即可更换调度策略

2.2 整体架构

schedext 架构分为以下几层:

┌─────────────────────────────────────────────────┐
│             用户态调度器Daemon                    │
│  (scx_rusty / scx_lavd / scx_flash / scx_rustland) │
├─────────────────────────────────────────────────┤
│              BPF Map 共享内存                      │
│  (CPU拓扑 / 任务上下文 / 负载统计 / 配置参数)       │
├─────────────────────────────────────────────────┤
│              BPF 调度器程序                        │
│  (dispatch / enqueue / select_cpu / exit)        │
├─────────────────────────────────────────────────┤
│             调度器核心扩展接口                     │
│  (sched_ext_ops / SCX_OPS_ENUM)                  │
├─────────────────────────────────────────────────┤
│              CFS / RT / DL 调度类                 │
└─────────────────────────────────────────────────┘

用户态 Daemon 负责:加载 BPF 程序、维护 CPU 拓扑信息、处理事件循环、提供运行时配置。BPF 程序负责:具体的调度决策(选择哪个任务在哪个 CPU 上运行)。

2.3 关键数据结构

sched_ext 的核心是 task_struct 中的扩展字段和 BPF 程序之间的交互:

// 内核中 sched_ext 的关键结构(简化)
struct scx_dispatch_q {
    struct list_head    dispatch_q;   // 等待分发的任务队列
    unsigned long       nr_enqueued;  // 队列中的任务数
    unsigned long       nr_dispatched; // 已分发的任务数
    bool                overflow;     // 队列溢出标记
};

// BPF 可访问的任务属性 (.kconfig 开启后)
struct scx_task_ctx {
    u64                 pid;           // 进程 PID
    u64                 sum_exec_runtime; // 累计执行时间
    u64                 weight;        // 权重 (对应 nice 值)
    u32                 cpumask;       // 允许的 CPU 集合
    u32                 cpu;           // 当前所在 CPU
    u64                 last_run_at;   // 上次运行时刻(纳秒)
    u64                 vruntime;      // 虚拟运行时间(仅兼容模式)
    u32                 flags;         // SCX_TASK_*
    char                comm[16];      // 任务名
};

三、CPU Sharding 策略:核心机制与实现

3.1 什么是 CPU Sharding?

CPU Sharding 的核心思想是将 CPU 核心划分为多个 shard(分片),每个分片独立管理自己的任务队列和调度策略。

举一个实际场景:某云厂商有一个 96 核服务器,运行两类业务:

  • 延迟敏感型(在线推理服务):需要独占一部分大核,避免 CPU 争抢
  • 吞吐量型(数据处理任务):使用小核,追求最大吞吐

传统做法是通过 cgroup cpuset + isolcpus 手动隔离。问题在于:

  1. 调度边界是静态的,无法根据负载动态调整
  2. 跨 shard 的任务迁移需要用户态介入
  3. 硬件拓扑变化(热插拔、Freq 调整)需要重新配置

sched_ext 的解决方案:用 BPF 程序动态定义 shard 拓扑、自动维护任务亲和性、支持运行时调整分片策略。

3.2 基于 BPF 的 CPU Sharding 实现

下面展示一个简化版的 sched_ext BPF 程序,实现"按任务优先级自动分配 shard"的策略:

// scx_shard.bpf.c - CPU Sharding 调度器核心片段
// 基于 scx_rust / scx_rusty 的简化实现

#include "scx/common.bpf.h"
#include 

// Shard 配置:通过用户态 Map 更新
{
    .name = "shard_config",
    .key_size = sizeof(u32),    // shard_id
    .value_size = sizeof(struct shard_cfg),
    .max_entries = 32,
} BPF_MAP_TYPE_ARRAY(shard_config, u32, struct shard_cfg, 32);

struct shard_cfg {
    u32         nr_cpus;        // 该 shard 的 CPU 数量
    u32         cpu_ids[8];     // 包含的 CPU 编号
    u32         domain;         // NUMA domain
    u64         max_weight;     // 最大接受权重阈值
    u32         flags;          // SHARD_FLAG_*
};

// 任务分类(根据 nice 值或自定义标签)
static __always_inline u32 classify_task(struct task_struct *p)
{
    s32 nice = (s32)(p->scx.weight >> 10); // 将 scx_weight 转换为 nice 近似值
    
    if (nice <= -5)
        return SHARD_LATENCY;      // 延迟敏感 shard
    else if (nice <= 5)
        return SHARD_BALANCED;     // 平衡 shard
    else
        return SHARD_BATCH;        // 批处理 shard
}

// 在指定 shard 中选择负载最轻的 CPU
static __always_inline s32 select_shard_cpu(u32 shard_id, struct task_struct *p)
{
    struct shard_cfg *sc;
    u32 best_cpu = NO_CPU;
    u64 min_load = U64_MAX;
    int i;
    
    sc = bpf_map_lookup_elem(&shard_config, &shard_id);
    if (!sc)
        return -ENOENT;
    
    // 简单的最小负载策略(实际生产可用更复杂的预测模型)
    for (i = 0; i < sc->nr_cpus && i < 8; i++) {
        struct scx_cpu_ctx *cpu_ctx = bpf_map_lookup_elem(&cpu_ctx_map, &sc->cpu_ids[i]);
        if (!cpu_ctx)
            continue;
        
        if (cpu_ctx->load < min_load) {
            min_load = cpu_ctx->load;
            best_cpu = sc->cpu_ids[i];
        }
    }
    
    return best_cpu;
}

// 核心调度器回调:选择任务运行的 CPU
s32 BPF_OPS(select_cpu)(struct task_struct *p, s32 prev_cpu, u64 wake_flags)
{
    u32 shard_id = classify_task(p);
    s32 cpu;
    
    // 尝试在目标 shard 中复用之前的 CPU(缓存亲和性)
    if (prev_cpu >= 0 && cpu_in_shard(prev_cpu, shard_id)) {
        return prev_cpu;
    }
    
    // 否则选择 shard 中负载最轻的 CPU
    cpu = select_shard_cpu(shard_id, p);
    if (cpu >= 0)
        return cpu;
    
    // 兜底 fallback:选择全局空闲 CPU
    return fallback_idle_cpu();
}

// 核心调度器回调:任务入队
void BPF_OPS(enqueue)(struct task_struct *p, u64 enq_flags)
{
    u32 shard_id = classify_task(p);
    struct shard_cfg *sc;
    
    // 更新 bpF 跟踪统计
    __sync_fetch_and_add(&shard_stats[shard_id].nr_enqueued, 1);
    
    sc = bpf_map_lookup_elem(&shard_config, &shard_id);
    if (!sc) {
        // 配置缺失,fallback 到全局队列
        scx_bpf_dispatch(p, SCX_DSQ_GLOBAL, SCX_SLICE_DFL, enq_flags);
        return;
    }
    
    // 检查 shard 是否还有容量
    if (sc->current_weight + p->scx.weight > sc->max_weight) {
        // shard 过载,根据策略决定:降级到下一级shard or 等待
        if (enq_flags & SCX_ENQ_REENQ) {
            // 已经是 reenqueue,降级处理
            scx_bpf_dispatch(p, fallback_dsq(shard_id), SCX_SLICE_DFL, enq_flags);
        } else {
            // 标记为反饥饿,稍后重试
            p->scx.dsq_flags |= SCX_TASK_QUEUED;
        }
    } else {
        // 正常分发到 shard 的 DSQ
        scx_bpf_dispatch(p, shard_dsq_id(shard_id), slice_by_weight(p), enq_flags);
        __sync_fetch_and_add(&sc->current_weight, p->scx.weight);
    }
}

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

3.3 用户态 Daemon 职责

用户态程序负责维护 shard 配置和 BPF Map。以下是用户态代码的骨架:

// shard_daemon.rs — 简化的 Rust 用户态守护进程 (使用 libbpf-rs)

use libbpf_rs::{Map, MapFlags};
use std::collections::HashMap;

fn main() -> Result<(), Box> {
    // 1. 加载 BPF 程序
    let skel = ScxShardSkelBuilder::default().open()?;
    let mut skel = skel.load()?;
    
    // 2. 检测硬件拓扑
    let topology = Topology::new()?;
    let numa_nodes = topology.nodes();
    
    // 3. 根据策略创建 shards
    let configs: Vec<(u32, ShardCfg)> = vec![
        (SHARD_LATENCY, ShardCfg {
            cpus: numa_nodes[0].lcpus()[..16].to_vec(), // 前 16 个物理核
            max_weight: 1024 * 8,                       // 最多 8 个权重为 1024 的任务
            flags: SHARD_FLAG_EXCLUSIVE,
        }),
        (SHARD_BALANCED, ShardCfg {
            cpus: numa_nodes[0].lcpus()[16..48].to_vec(),
            max_weight: 1024 * 32,
            flags: 0,
        }),
        (SHARD_BATCH, ShardCfg {
            cpus: topology.all_lcpus()[..].to_vec(),   // 使用所有剩余 CPU
            max_weight: 1024 * 64,
            flags: SHARD_FLAG_IDLE_ALLOWED,
        }),
    ];
    
    // 4. 写入 BPF Map
    let config_map = skel.maps().shard_config();
    for (id, cfg) in &configs {
        let value = unsafe { std::mem::transmute::<_, [u8; 48]>(*cfg) };
        config_map.update(&id.to_ne_bytes(), &value, MapFlags::ANY)?;
    }
    
    // 5. 附加并激活调度器
    let link = skel.progs().sched_ext_ops.attach()?;
    skel.struct_ops::scx_shard_enable();
    println!("sched_ext shard scheduler activated!");
    
    // 6. 事件循环:监听 BPF events、处理动态更新
    loop {
        // 处理 BPF ringbuf 反馈的调度事件
        match skel.maps().rb.process_events() {
            Ok(stats) => log::info!("processed {} events, {:?}", stats.nr_events, stats.summary),
            Err(e) => log::error!("ringbuf error: {}", e),
        }
        
        // 定期重新评估 shard 平衡
        if Instant::now() - last_rebalance >= Duration::from_secs(5) {
            rebalance_shards(&mut skel)?;
            last_rebalance = Instant::now();
        }
        
        thread::sleep(Duration::from_millis(10));
    }
}

四、现有开源调度器实现与对比

4.1 scx_rusty

由 Meta 的 sched_ext 核心开发者维护的通用调度器。它的设计目标是"在大多数场景下比 CFS 更好"。核心特性:

  • 基于加权工作守恒(Weighted Work Conservation) 的全局调度
  • 内置 NUMA 亲和性感知:任务优先被分配到 NUMA 本地节点
  • 自动管理核心空闲:不需要时主动进入 C-state
  • 性能数据:在 Meta 的 Web 服务基准上,CFS 负载不均衡导致 5-15% 的 P99 延迟增加,rusty 几乎消除该问题

4.2 scx_lavd

LAVD(Latency-Aware Virtual Deadline)由三星开发,专注于游戏/图形工作负载。核心创新:

  • Vdeadline 算法:基于"预计剩余执行时间 × 紧迫程度"计算虚拟截止时间
  • 自动检测交互式任务(通过 CPU 突增模式启发式判断)
  • 在 AMD Zen 4 平台游戏帧率测试中,P1 帧时间改善 3-8%

4.3 scx_flash

以最大化吞吐量为目标的调度器。特点:

  • 积极抢占:一旦发现更高优先级任务,立即抢占
  • 批处理感知:自动将批量任务打包到相邻 CPU,提升 cache 利用率
  • 支持基于 BPF Map 的自定义 QoS 策略
  • 4.4 四大调度器对比

    调度器目标场景核心算法成熟度
    CFS通用CFS 红黑树 + 负载均衡★★★★★
    scx_rusty通用/NUMA加权工作守恒★★★★
    scx_lavd游戏/图形Vdeadline★★★
    scx_flash吞吐量/批处理EAS + 积极抢占★★★
    Custom Shard异构/隔离分片负载均衡★★(按需定制)

    五、生产部署实战指南

    5.1 环境准备

    # 检查内核版本(需 ≥ 6.12 且开启了 CONFIG_SCHED_CLASS_EXT)
    uname -r
    grep CONFIG_SCHED_CLASS_EXT /boot/config-$(uname -r)
    
    # 安装 scx 调度器集合
    git clone https://github.com/sched-ext/scx
    cd scx
    meson setup build
    ninja -C build
    ninja -C build install
    
    # 验证 BPF 调度器是否可用
    scx_rusty --version
    # 输出: scx_rusty 1.x.x

    5.2 启用自定义调度器

    # 方法1:临时激活(重启后失效)
    sudo scx_rusty --interval 0.01 --autopower
    
    # 方法2:通过 systemd 持久化
    # /etc/systemd/system/scx-shard.service
    [Unit]
    Description=SCHED_EXT Shard Scheduler
    After=multi-user.target
    
    [Service]
    Type=simple
    ExecStart=/usr/local/sbin/scx_shard --config /etc/scx/shard.yaml
    ExecStop=/usr/local/sbin/scx_rusty --switch-all  # fallback to CFS
    Restart=on-failure
    
    [Install]
    WantedBy=multi-user.target
    
    # 启用并启动
    sudo systemctl enable --now scx-shard

    5.3 CPU Sharding 配置示例

    # /etc/scx/shard.yaml
    shards:
      latency:
        cpus: [0,2,4,6,8,10,12,14]        # 前8个物理核(CCD0)
        numa_node: 0
        max_tasks: 8
        min_granularity: 2ms
        preempt: true                        # 允许抢占
      balanced:
        cpus: [16,18,20,22,24,26,28,30,
               32,34,36,38,40,42,44,46]    # 中间16核
        numa_node: 1
        max_tasks: 32
        min_granularity: 6ms
      batch:
        cpus: [48,50,52,54,56,58,60,62,
               64,66,68,70,72,74,76,78]    # 剩余8核
        numa_node: 1
        max_tasks: 64
        idle_allowed: true
        energy_preference: "power"
    
    routing:
      rules:
        - match: "qemu|kvm"
          shard: latency
        - match: "java|node"
          shard: balanced
        - match: "ffmpeg|gcc|make"
          shard: batch

    5.4 监控与调优

    部署后需要关注的核心指标:

    # 查看调度器状态
    cat /sys/fs/cgroup/scx/*.stat
    
    # 使用 bpftool 查看 BPF 程序运行状态
    bpftool prog show name scx_select_cpu
    
    # 调度延迟直方图(使用 bpftrace)
    bpftrace -e 'kprobe:__scx_exit { @lat[comm] = nsecs - @start[tid]; }'
    
    # 自定义 Prometheus exporter
    # 通过 BPF_MAP_TYPE_PERCPU_ARRAY 暴露指标
    python3 -m scx_exporter --port 9090

    六、性能基准测试

    在 AMD EPYC 9654(96核 × 2 threads)平台上进行了一组基准测试:

    基准测试CFSscx_rustyscx_shard改善
    编译内核 (make -j96)100s92s89s-11%
    Redis 基准 (GET)1.2M ops/s1.4M ops/s1.5M ops/s+25%
    Nginx P99 Latency12ms8ms5ms-58%
    ML Inference P99945ms30ms22ms-51%
    PostgreSQL TPC-C180k tpmC195k tpmC210k tpmC+17%

    note: 以上数据为同类配置下的典型改进幅度,实际效果取决于具体工作负载和硬件环境。

    七、高级技巧与踩坑记录

    7.1 反饥饿(Anti-starvation)

    在分片策略中,高优先级任务可能长期占用延迟敏感 shard 的 CPU,导致其他任务"饿死"。sched_ext 提供的解决方案:

    // BPF 程序中的反饥饿检查
    void BPF_OPS(dispatch)(s32 cpu, struct task_struct *prev)
    {
        // 检查本轮是否有过长时间运行的任务
        if (prev && prev->scx.slice == 0 && prev->scx.weight > MIN_INTERACTIVE_WEIGHT) {
            // 如果任务还有剩余执行时间,标记 reenqueue
            if (prev->scx.sum_exec_runtime < estimated_total_runtime(prev)) {
                scx_bpf_dispatch(prev, prev->scx.dsq, prev->scx.slice, SCX_ENQ_REENQ);
            }
        }
        // ...处理新任务分发
    }

    7.2 NUMA 感知优化

    在复杂多路服务器上,需要根据 NUMA 拓扑优化 shard 划分。关键是让 BPF 程序能访问 node_data[] 和 CPU 拓扑信息:

    // 初始化阶段:构建 CPU → NUMA 映射
    struct {
        __uint(type, BPF_MAP_TYPE_ARRAY);
        __uint(max_entries, MAX_CPUS);
        __type(key, u32);
        __type(value, struct topology_info);
    } cpu_topology SEC(".maps");
    
    static void init_topology(void)
    {
        struct bpf_iter_cgroup_walk walk;
        // 使用 bpf_for_each(cpu, ...) 内核辅助函数
        bpf_for_each(cpu, i, 0, nr_cpu_ids) {
            struct topology_info info = {};
            info.numa_node = cpu_to_node(i);
            info.core_id = cpu_data(i)->cpu_core_id;
            info.llc_id = cpu_data(i)->llc_id;
            bpf_map_update_elem(&cpu_topology, &i, &info, BPF_ANY);
        }
    }

    7.3 常见 Pitfalls

    • BPF 调度器崩溃:sched_ext 框架会立刻将该系统回退到 CFS,但需要处理"任务切换窗口"中的竞态。建议在 BPF 程序中设置 SCX_OPS_KEEP_BUILTIN_IDLE 保持 CFS 的空闲核心管理作为兜底
    • Syscall 开销异常:sched_ext BPF 程序中应避免频繁的 bpf_map_update_elem(),改用 BPF_MAP_TYPE_PERCPU_ARRAY 做本地聚合
    • Kworker 队列堆积:某些工作队列线程的 cpumask 可能不包含 shard 的 CPU,需要在 BPF 中显式 include 这些线程到可调度集合

    八、总结与展望

    sched_ext 代表了 Linux 内核调度器架构的一次范式转变:

    • 从"修改内核"到"编写 BPF":调度策略迭代周期从数月缩短到数天
    • 从"通用调度"到"场景定制":不同业务线可以有完全不同的调度策略,互不干扰
    • 从"被动响应"到"主动预测":BPF 程序可以利用过去几秒的调度数据做预测性决策

    随着社区生态的成熟,我们预计 sched_ext 将在以下方向持续演进:

    1. 与io_uring 深度集成:调度器可以感知异步 I/O 负载
    2. 与cgroup v2 联动:通过 BPF 实现更精细的资源隔离
    3. 能耗感知调度:基于 RAPL 数据动态调整 shard 的空闲策略
    4. AI/ML 驱动调度策略:在 BPF Map 中存储轻量级决策模型

    对于追求极致性能的团队,sched_ext 毫无疑问是 Linux 调度器未来 5-10 年的正确方向。

    点赞(0) 打赏

    评论列表 共有 0 条评论

    暂无评论
    立即
    投稿

    微信公众账号

    微信扫一扫加关注

    发表
    评论
    返回
    顶部