Linux Kernel Scheduler:CFS vruntime 红黑树到 EEVDL deadline 算法的生产级实战


引言:为什么调度器是操作系统的"操作系统"

在数据中心场景中,一个物理机上可能运行上百个容器、数千个线程。从 nginx worker 的毫级延迟敏感任务,到日志压缩的后台批处理,从数据库的长事务事务链,到 GPU kernel 的异步调度——这些线程都在争夺同一组 CPU 核心。

Linux 内核调度器的设计目标只有一句话:在公平性与吞吐量之间找到最优平衡。但这个目标的工程实现,从 2.6.22 的 CFS 到 6.6 引入的 EEVDF,已经演进了近二十年。

本文将深入调度器的核心数据结构,从 vruntime 的红黑树实现,到 EEVDF 的 deadline 机制,结合生产级的 perf、schedstat、ftrace 实战,帮你建立完整的调度器调优认知。


一、调度器基础:Linux 如何看"时间片"

1.1 调度类与优先级层级

Linux 内核的调度由调度类(sched_class)的多级链表组织:

stop_sched_class (最高) → dl_sched_class → rt_sched_class → fair_sched_class → idle_sched_class (最低)
  • stop_sched_class:CPU 热插拔、IPI 中断处理,抢占一切
  • dl_sched_class:SCHED_DEADLINE,基于全局 EDF 算法,满足硬实时需求
  • rt_sched_class:SCHED_FIFO / SCHED_RR,POSIX 实时调度
  • fair_sched_class:SCHED_NORMAL / SCHED_BATCH / SCHED_IDLE,承载绝大多数用户态进程(即 CFS)
  • idle_sched_class:无任务时的空转调度类

每个 CPU 的运行队列 struct rq 持有所有调度类的子队列,pick_next_task 沿优先级链逐个查找,找到即返回。

1.2 CFS 的核心目标:完美的多任务处理器幻觉

CFS(Completely Fair Scheduler)的设计哲学是:如果有 N 个权重相同的任务,每个任务应得到 1/N 的 CPU 时间。为实现这一点,CFS 不维护传统的时间片,而是维护一个关键变量:vruntime(virtual runtime)。

二、CFS 内部机制深度解析

2.1 vruntime:公平性的计量单位

vruntime 是任务在虚拟时钟上流逝的时间。当任务实际运行时间为 delta_exec 时:

// kernel/sched/fair.c
static void update_curr(struct cfs_rq *cfs_rq)
{
    struct sched_entity *curr = cfs_rq->curr;
    u64 now = rq_clock_task(rq_of(cfs_rq));
    u64 delta_exec;

    delta_exec = now - curr->exec_start;  // 实际运行时间
    curr->exec_start = now;
    curr->sum_exec_runtime += delta_exec;

    // 关键:加权 vruntime
    curr->vruntime += calc_delta_fair(delta_exec, curr);
    update_min_vruntime(cfs_rq);
}

核心权重公式将 NICE_0_LOAD 作为基准权重(1024),权重越低 vruntime 增长越快:

vruntime += delta_exec * NICE_0_LOAD / se->weight

这意味着:nice -20 的进程(weight ≈ 88761)vruntime 增长极慢,获得更多 CPU;nice 19(weight ≈ 15)vruntime 暴涨,几乎被饿死。

2.2 红黑树:O(log n) 选出最"亏欠"的任务

CFS 以 vruntime 为 key,将所有可运行任务组织在一棵红黑树(cfs_rq->tasks_timeline)中。每次 pick_next_task 只需取出最左侧节点:

static struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
    struct rb_node *left = cfs_rq->tasks_timeline.rb_node;
    if (!left)
        return NULL;
    return rb_entry(left, struct sched_entity, run_node);
}

Linux 内核使用 rb_leftmost 缓存该节点指针,确保 O(1) 取最左;插入新任务或唤醒时为 O(log n)。对于 nginx 这种几千 worker 的场景,红黑树在 cache line 友好性上远超链表。

2.3 min_vruntime:追赶基准与放置策略

每个 cfs_rq 维护 min_vruntime,代表该队列上最小的 vruntime。新唤醒的任务的 vruntime 被 clamp 在 [min_vruntime, min_vruntime + sysctl_sched_latency] 区间:

static void place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int initial)
{
    u64 vruntime = cfs_rq->min_vruntime;
    if (initial)
        vruntime += sched_vslice(cfs_rq, se);  // 新任务延迟放置
    se->vruntime = max_vruntime(se->vruntime, vruntime);
}

新任务惩罚(initial=1):新 fork 的任务会在 min_vruntime 基础上加上一个 vslice,防止新进程"瞬启瞬占"屠杀老任务。这个惩罚可通过 sysctl_sched_child_runs_first 调整。

2.4 调度粒度与延迟控制

六个 /proc/sys/kernel 参数控制调度手感:

参数 默认值 作用
sched_latency_ns 24ms 一轮完整调度的目标时长
sched_min_granularity_ns 3ms 任务单次运行最小时间
sched_wakeup_granularity_ns 4ms 唤醒抢占阈值倍数
sched_migration_cost_ns 0.5ms 任务热迁移的 lat 容忍
sched_nr_migrate 32 负载均衡单次迁移数
sched_autogroup_enabled 1 终端 session 公平分组

关键公式:每个任务的理想时间 = sched_latency_ns / nr_running。当任务数少于 sched_latency_ns / sched_min_granularity_ns(约 8 个)时,实际时间由 min_granularity 兜底,避免任务过多时时间片太细导致高频上下文切换。

生产调优经验:对于 Redis、Nginx 等延迟敏感的 OLTP 工作负载,通常设置 sched_min_granularity_ns=10000000(10ms)和 sched_wakeup_granularity_ns=15000000(15ms),降低上下文切换代价换取缓存局部性。


三、CFS 的痛点与 EEVDF 的诞生

3.1 CFS 的交互性应对:sleeper fairness 与 wakeup preemption

CFS 对"醒来后需要立即响应"的交互任务有一套补偿机制——sleeper fairness。沉睡任务的 vruntime 在休眠期间不增长,醒来时能得到红黑树左侧的位置。但问题在于:如果 high-vruntime 的老任务醒来,它会直接抢占 CPU,因为它的 vruntime 低于 min_vruntime 后已经被 clamp。

另一个问题是 wakeup 抢占的"过冲":一个任务正要抢占,但抢占者在运行队列上的时间已经很长,过早抢占反而拉低吞吐量。CFS 通过 wakeup_granularity_ns 控制这个阈值。

核心问题:CFS 的公平基于"已经消耗的 CPU",而非"何时需要 CPU"。对于延迟敏感型任务(如游戏循环、VR 渲染),即使 vruntime 很大,如果在 deadline 前拿不到 CPU,帧率就会塌方。

3.2 EEVDF:带有 deadline 的公平调度

EEVDF(Earliest Eligible Virtual Deadline First)是 Ingo Molnár 在 2023 年提出的调度器改进,被合入 Linux 6.6 主线,默认用于 SCHED_NORMAL 策略的工作负载(仍为 CFS 内核态实现,但引入了 deadline 概念)。

EEVDF 的核心创新是为每个任务维护三个变量:

runtime     :本次调度已运行的虚拟时间(每次出队时清零)
 eligibility :当 runtime 消耗完毕时,成为 ineligible 的临界点
deadline    = eligibility + (lag_slice / weight) :虚拟截止时间

其中 lag 是关键:它衡量一个任务与理想公平分配的"偏差"。

lag = (ideal_time - actual_slice)  // 可为正或负
  • 正值:任务"被亏欠",应优先调度
  • 负值:任务"吃多了",出队等待

EEVDF pick 规则为:eligible 中 deadline 最小的先跑。

3.3 EEVDF vs CFS 的本质区别

CFS:
  - 维护 vruntime 单调递增 calendar
  - 最左侧 vruntime 最小者调度
  - 公平性体现在长期统计

EEVDF:
  - 维护 eligibility + deadline 双属性
  - 偏差(lag)产生即时调度优先级
  - 体现"谁更急"而非"谁更穷"

对于线程数众多、交互性强的场景(如桌面 Linux、游戏服务器),EEVDF 带来的稳定性远超 CFS——低延迟任务能更快响应而不必等 vruntime 收敛。


四、生产级调优与观测实战

4.1 调度策略选择代码示例

#define _GNU_SOURCE
#include <sched.h>
#include <stdio.h>
#include <unistd.h>

// 策略1:SCHED_DEADLINE — 实时硬保证
void enable_deadline_policy(void) {
    struct sched_attr attr = {
        .size = sizeof(attr),
        .sched_policy = SCHED_DEADLINE,
        .sched_runtime  =  10 * 1000 * 1000,  // 10ms 每周期需要运行
        .sched_deadline =  20 * 1000 * 1000,  // 20ms 截止期
        .sched_period   =  20 * 1000 * 1000,  // 20ms 周期
    };
    if (sched_setattr(0, &attr, 0) == -1)
        perror("sched_setattr");
}

// 策略2:SCHED_BATCH — 后台批处理,vruntime 惩罚
void enable_batch_policy(void) {
    struct sched_param param = { .sched_priority = 0 };
    sched_setscheduler(0, SCHED_BATCH, &param);
}

// 策略3:cgroup v2 cpu.weight — 容器级权重
// echo "10000" > /sys/fs/cgroup/redis/cpu.weight    (默认 100)
// echo "100"   > /sys/fs/cgroup/backup/cpu.weight

注意 SCHED_DEADLINE 的 runtime ≤ deadline ≤ period 是内核强制校验条件。超过 runtime 的部分会被该策略的 throttle 机制硬截断,适合音视频、控制循环等硬实时场景。

4.2 ftrace 追踪调度事件

# 追踪上下文切换事件
cd /sys/kernel/debug/tracing
echo 0 > tracing_on
echo sched_switch > set_event
echo sched_wakeup >> set_event
echo 1 > tracing_on

# 运行目标进程后停追踪
sleep 5 && echo 0 > tracing_on

# 查看结果
cat trace | head -100

输出示例:

sched_switch: prev_comm=nginx pid=1234 prev_state=S ==> next_comm=redis pid=5678
sched_wakeup: comm=nginx pid=1234 prio=120 target_cpu=3

4.3 perf sched 延迟直方图

# 记录30秒调度数据
perf sched record -- sleep 30

# 生成延迟分布
perf sched latency --sort max

输出示例:

 Task                  |   Runtime ms | Average delay ms | Maximum delay ms |
------------------------------------------------------------------------
 nginx:(12)            |   8234.5678  |      0.032       |     12.345       |
 redis-server:(4)      |   1234.5678  |      0.011       |      3.210       |
 kworker/u64:0:(8)     |    234.5678  |      0.876       |     45.678       |

关键关注 Maximum delay。对于 Redis 来说,如果最大延迟超过 1ms(默认配置 very_redis 相关),意味着存在调度器延迟尖峰,需要检查: - CPU 是否被 SCHED_FIFO 抢占 - 是否因 NUMA 远程访问导致唤醒延迟 - 是否 L2/cache 被别的线程冲刷

4.4 BPF 工具:runqlat 与 runqlen

BCC 工具 runqlat 给出等待时间的直方图:

# 每5秒输出一次
/usr/share/bcc/tools/runqlat 5 3

# 输出
     usecs               : count     distribution
         0 -> 1          : 0        |                                        |
         2 -> 3          : 0        |                                        |
         4 -> 7          : 1        |                                        |
         8 -> 15         : 3        |*                                       |
        16 -> 31         : 12       |****                                    |
        32 -> 63         : 45       |***************                         |
        64 -> 127        : 128      |****************************************|
       128 -> 255        : 87       |***************************             |
       256 -> 511        : 32        |***********                             |

如果大量任务等待时间 > 100us,说明 CPU 争用严重,需要扩容或隔离(isolcpus、cgroup cpuset)。

对于 cgroup v2 的可运行任务数监控:

# /sys/fs/cgroup/<group>/cpu.stat
cat /sys/fs/cgroup/system.slice/cpu.stat
# nr_periods 123456
# nr_throttled 789              ← CPU 配额耗尽被限流次数
# throttled_usec 1234567        ← 总计限流时间(us)
# nr_burst 0
# burst_usec 0

当 nr_throttled 持续上升,说明该 cgroup 的 cpu.max 不够用,配额不足。

4.5 生产调优清单

场景 配置项 推荐值 原因
Redis / 低延迟服务 sched_min_granularity_ns 10ms 减少切换振突
高并发 nginx sched_wakeup_granularity_ns 15ms 降低抢占损耗
编译调度 SCHED_BATCH - 避免拖慢交互
实时音视频 SCHED_DEADLINE runtime=period×0.8 硬保证
HPC 负载均衡 kernel.sched_energy_aware=0 关闭 EAS 避免核间迁移开销
Container 间干扰 cpu.weight 隔离 核心服务 10000,批处理 100 权重隔离

五、调度器相关故障排查案例

案例一:Nginx p99 延迟尖刺

某外卖网关 nginx worker 在晚高峰出现 p99 延迟从 5ms 跳到 50ms。

通过 perf sched latency 观察到: - 最大调度延迟 42ms - 主要集中在 CPU 3 上

进一步 ftrace 发现 CPU 3 上有 ksoftirqd 吃掉 30% CPU,且多个 worker 被 sched_autogroup 放入同一个任务组,导致单组权重过低。

修复:禁用 sched_autogroup,关闭超线程的其中一个逻辑核,p99 回降到 6ms。

案例二:cgroup cpu.max 限流下的行为

某 Kubernetes 节点上设置了 Pod cpu limit=4,但 cgroup v2 的 cpu.max 设置为 400000 100000(即 4 核 / 100ms 周期)。实际监控发现 nr_throttled 每秒增加 8 次。

这意味着该 Pod 的 CPU 需求 > 4vCPU,但 runtime ≤ deadline ≤ period 的限流机制迫使它每 100ms 只能连续使用 40ms。

修复:扩容到 cpu limit=8,nr_throttled 归零,Pod 延迟恢复正常。


六、小结与未来展望

Linux 调度器已经从 CFS 的"长期公平"进化到 EEVDF 的"lag 即时补偿",但核心编排思想没变:在 keep busy 之间选最紧迫的那个。

对于生产工程师,关键记住这几条:

  1. 延迟敏感 → SCHED_DEADLINE,配额走 runtime/deadline
  2. 后台批流 → SCHED_BATCH,让出 vruntime 抢占权
  3. 权重隔离 → cgroup v2 cpu.weight,比 renice 更准确
  4. 监测先行 → runqlat + perf sched,先看分布再改参
  5. 切换有价 → 增大 min_granularity,缓存比上下文更贵

未来,随着 Intel Thread Director 和 ARM MPAM 硬件分区能力的成熟,调度器将进一步从"纯软件公平"走向"软件意图 + 硬件隔离"协作的混合模式。理解 vruntime 和 deadline 的权衡,仍然是你做调度决策的第一块积木。


延伸阅读: - man 7 sched — Linux 调度策略完整手册 - man 2 sched_setattr — SCHED_DEADLINE 系统调用 - kernel/sched/fair.c — CFS 源码 - kernel/sched/deadline.c — EEVDF / DEADLINE 实现 - tools/sched/perf-sched — perf scheduler 扩展

文章写于 2026年9月,基于 Linux 6.x 主线内核代码分析。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.369290s