Linux 内核进程调度器 CFS 深度实战:从红黑树到虚拟运行时间的完全公平

引言

进程调度是操作系统的核心职责之一,它决定了哪个进程在何时获得 CPU 时间。Linux 内核的调度器经历了多次重大演进,其中最具里程碑意义的是 2.6.23 内核引入的 CFS(Completely Fair Scheduler,完全公平调度器),它彻底取代了之前的 O(1) 调度器,成为 Linux 调度器的里程碑式设计。

CFS 的核心设计理念简单而优雅:让每个进程获得"完全公平"的 CPU 时间份额。本文将深入剖析 CFS 的实现原理、核心数据结构、调度算法细节,以及生产环境中的调优实践。

一、CFS 的设计哲学

1.1 从 O(1) 到 CFS 的演进

在 CFS 之前,Linux 使用的是 O(1) 调度器(2.6.0 - 2.6.22)。O(1) 调度器使用运行队列数组和过期队列数组来实现 O(1) 时间复杂度的调度决策,但它存在以下问题:

  • 交互式进程识别困难:通过睡眠时间等启发式规则判断进程是否为交互式进程,误判率高
  • 公平性不足:优先级粒度粗,难以保证严格的公平性
  • 扩展性差:活跃/过期数组的设计在大规模场景下表现不佳

CFS 的设计者 Ingo Molnár 提出了一个革命性的想法:不使用传统的时间片概念,而是基于"虚拟运行时间"来实现公平调度。

1.2 核心概念:虚拟运行时间(vruntime)

CFS 的核心创新是引入 vruntime(virtual runtime,虚拟运行时间) 概念:

vruntime += delta_exec * (NICE_0_LOAD / weight)

其中:

  • delta_exec:进程实际执行的物理时间
  • NICE_0_LOAD:nice 值为 0 时的负载权重(常量 1024)
  • weight:进程的权重(由 nice 值决定)

关键洞察:优先级高的进程(nice 值小,权重大),vruntime 增长更慢,因此能更频繁地被调度。当所有进程的 vruntime 相同时,系统就达到了"完全公平"。

二、核心数据结构

2.1 调度实体(sched_entity)

在 CFS 中,每个进程的调度信息通过 sched_entity 结构表示(嵌入在 task_struct 中):

struct sched_entity {
    struct load_weight  load;       // 权重
    struct rb_node      run_node;   // 红黑树节点
    unsigned int        on_rq;      // 是否在运行队列
    
    u64                 exec_start; // 本次执行开始时间
    u64                 sum_exec_runtime; // 总执行时间
    u64                 vruntime;   // 虚拟运行时间
    u64                 prev_sum_exec_runtime; // 上次切换时的总执行时间
};

关键字段说明:

  • load:进程的调度权重,决定 vruntime 的增长速率
  • run_node:红黑树节点,用于将调度实体插入 CFS 运行队列
  • vruntime:虚拟运行时间,CFS 调度的唯一排序依据

2.2 CFS 运行队列(cfs_rq)

每个 CPU 维护一个 CFS 运行队列:

struct cfs_rq {
    struct load_weight  load;           // 队列总权重
    unsigned long       nr_running;     // 可运行进程数
    
    u64                 min_vruntime;   // 队列中最小的 vruntime(单调递增)
    struct rb_root      tasks_timeline; // 红黑树根节点(按 vruntime 排序)
    struct rb_node      *rb_leftmost;   // 最左侧节点(vruntime 最小的进程)
    
    struct sched_entity *curr;          // 当前正在运行的实体
    struct sched_entity *next;          // 下一个要运行的实体
    struct sched_entity *last;          // 上次运行的实体
};

CFS 使用红黑树(Red-Black Tree)来组织所有可运行进程,以 vruntime 作为排序键:

  • 最左侧节点(rb_leftmost)始终指向 vruntime 最小的进程,即最应该被调度的进程
  • 插入和查找操作时间复杂度为 O(log N),非常高效
  • min_vruntime 用于保证新进程不会因 vruntime 过小而长期占用 CPU

2.3 进程权重与 nice 值的映射

Linux 内核通过 sched_prio_to_weight 数组将 nice 值映射到权重:

static const int sched_prio_to_weight[40] = {
 /* -20 */ 88761, 71755, 56483, 46273, 36291,
 /* -15 */ 29154, 23254, 18705, 14949, 11916,
 /* -10 */ 9548,  7620,  6100,  4904,  3906,
 /* -5  */ 3121,  2501,  1991,  1586,  1277,
 /*  0  */ 1024,  820,   655,   526,   423,
 /*  5  */ 335,   272,   215,   172,   137,
 /* 10  */ 110,   87,    70,    56,    45,
 /* 15  */ 36,    29,    23,    18,    15,
};

重要规律:nice 值每降低 1(优先级提高),权重增加约 1.25 倍。这意味着:

  • nice -20 的进程获得的 CPU 时间约为 nice 19 的 88761/15 ≈ 5900 倍
  • nice 值每差 1,CPU 时间差约 25%

三、CFS 调度算法详解

3.1 入队操作(enqueue_entity)

当进程变为可运行状态时,调用 enqueue_entity() 将其加入 CFS 运行队列:

static void enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    bool renorm = !(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_MIGRATED);
    bool curr = cfs_rq->curr == se;
    
    // 1. 如果是被唤醒的进程且未迁移,需要修正 vruntime
    if (renorm && curr)
        se->vruntime += cfs_rq->min_vruntime;
    
    // 2. 更新 sched_entity 的统计信息
    update_curr(cfs_rq);
    
    // 3. 累加 Account 被挂起的时间
    if (renorm && !curr)
        se->vruntime += cfs_rq->min_vruntime;
    
    // 4. 将新节点插入红黑树
    enqueue_entity_load_avg(cfs_rq, se);
    account_entity_enqueue(cfs_rq, se);
    if (flags & ENQUEUE_WAKEUP)
        place_entity(cfs_rq, se, 0);
    
    // 5. 检查是否需要抢占
    if (sched_feat(START_DEBIT) || ...)
        check_preempt_tick(cfs_rq, se);
    
    // 6. 插入红黑树
    __enqueue_entity(cfs_rq, se);
    
    // 7. 更新负载
    update_cfs_group(se);
    add_nr_running(cfs_rq, 1);
}

关键点:place_entity() 函数负责设置新唤醒进程的 vruntime,防止新进程或睡眠后唤醒的进程"作弊"。

3.2 出队操作(dequeue_entity)

当进程阻塞或被抢占时,调用 dequeue_entity() 将其从红黑树中移除:

static void dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    // 1. 更新当前进程的统计信息
    update_curr(cfs_rq);
    
    // 2. 处理 dequeue 操作
    dequeue_entity_load_avg(cfs_rq, se);
    account_entity_dequeue(cfs_rq, se);
    
    // 3. 更新最小 vruntime
    if (!(flags & DEQUEUE_SLEEP))
        se->vruntime -= cfs_rq->min_vruntime;
    
    // 4. 从红黑树中移除
    __dequeue_entity(cfs_rq, se);
    
    // 5. 更新负载
    update_cfs_group(se);
    sub_nr_running(cfs_rq, 1);
}

注意:当进程睡眠时,vruntime 会减去 min_vruntime,这样进程被唤醒时其 vruntime 保持相对较小,能较快获得调度。

3.3 核心调度函数:pick_next_entity

CFS 选择下一个运行进程的逻辑非常简单——总是选择 vruntime 最小的进程:

static struct sched_entity *pick_next_entity(struct cfs_rq *cfs_rq)
{
    // 直接从红黑树最左侧节点获取
    struct sched_entity *se = pick_first_entity(cfs_rq);
    
    // 使用缓存的左侧指针优化
    struct sched_entity *left = cfs_rq->rb_leftmost;
    
    // 为什么使用 left 而不是 se?考虑以下场景:
    // 如果 left 为 NULL,说明没有可运行进程
    // 如果有多个相同 vruntime 的情况,需要特殊处理
    if (left) {
        se = __pick_first_entity(&cfs_rq->tasks_timeline);
        cfs_rq->rb_leftmost = NULL;
    }
    
    return se;
}

红黑树的最左侧节点总是 vruntime 最小的节点,因此 pick_next_entity() 的时间复杂度为 O(1)(使用缓存的 rb_leftmost 指针)。

3.4 检查抢占:check_preempt_tick

CFS 不是严格意义上的"时间片"调度器,但它仍然有一个最小调度粒度(sysctl_sched_min_granularity,默认 0.75ms)和目标延迟(sysctl_sched_latency,默认 6ms):

static void check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
    unsigned long ideal_runtime, delta_exec;
    struct sched_entity *se;
    s64 delta;
    
    // 1. 计算当前进程应获得的理想运行时间
    ideal_runtime = sched_slice(cfs_rq, curr);
    
    // 2. 获取实际运行时间
    delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
    
    // 3. 如果实际运行时间超过理想运行时间,标记为需要重新调度
    if (delta_exec > ideal_runtime) {
        resched_curr(rq_of(cfs_rq));
        return;
    }
    
    // 4. 检查是否低于最小粒度
    if (delta_exec < sysctl_sched_min_granularity)
        return;
    
    // 5. 比较当前进程和最优进程的 vruntime
    se = pick_next_entity(cfs_rq);
    delta = curr->vruntime - se->vruntime;
    
    // 6. 如果当前进程的 vruntime 不是最小的,标记抢占
    if (delta > 0)
        resched_curr(rq_of(cfs_rq));
}

四、CFS 中的组调度(CGroup 支持)

CFS 原生支持 组调度(Group Scheduling),这是容器化和资源隔离的基础。

4.1 两层调度器架构

在组调度场景下,CFS 运行在两个层级上:

                CPU 调度器 (CFS)
                      |
            +---------+---------+
            |                   |
        CGroup A            CGroup B
     +----+----+         +----+----+
     |         |         |         |
   proc1    proc2     proc3    proc4

第一层:CFS 在 CGroup 之间公平分配 CPU 时间(基于 cpu.shares)
第二层:每个 CGroup 内部,CFS 继续使用常规机制分配

4.2 CFS Bandwidth Control

Linux 提供了 cpu.cfs_quota_us 和 cpu.cfs_period_us 来实现带宽控制:

# Docker/K8s 资源限制示例
echo 50000 > /sys/fs/cgroup/cpu/docker/xxx/cpu.cfs_quota_us  # 0.5 个 CPU
echo 100000 > /sys/fs/cgroup/cpu/docker/xxx/cpu.cfs_period_us # 100ms 周期

这意味着该容器每 100ms 周期内最多使用 50ms 的 CPU 时间,即限制为 0.5 核。

五、生产环境调优实践

5.1 关键 sysctl 参数

参数默认值说明
sched_min_granularity_ns750000 ns (0.75ms)最小调度粒度,影响上下文切换频率
sched_latency_ns6000000 ns (6ms)目标延迟,所有可运行进程在此时间内至少运行一次
sched_wakeup_granularity_ns10000000 ns (10ms)唤醒抢占粒度,控制新唤醒进程的抢占倾向
sched_migration_cost_ns500000 ns (0.5ms)迁移开销估计,影响负载均衡
sched_nr_migrate32负载均衡时每次迁移的进程数

5.2 交互式工作负载优化

桌面或交互式系统可以减小 sched_wakeup_granularity_ns 来提高响应速度:

# 桌面/交互式场景优化
echo 4000000 > /proc/sys/kernel/sched_wakeup_granularity_ns
echo 1500000 > /proc/sys/kernel/sched_min_granularity_ns

5.3 服务器/批处理场景优化

服务器场景可以增大批处理粒度来降低上下文切换开销:

# 高吞吐服务器优化
echo 10000000 > /proc/sys/kernel/sched_min_granularity_ns
echo 8000000 > /proc/sys/kernel/sched_latency_ns

5.4 使用 taskset 进行 CPU 亲和性设置

对于延迟敏感的应用,可以通过 CPU 亲和性绑定来减少缓存失效:

# 将进程绑定到 CPU 2-3
taskset -c 2,3 ./my_latency_sensitive_app

# 查看进程的 CPU 亲和性
taskset -p <pid>

5.5 使用 chrt 调整实时优先级

对于延迟要求极高的应用(如音视频处理),可以使用实时调度策略:

# 设置为 SCHED_FIFO,优先级 90(最高 99)
chrt -f 90 ./realtime_app

# 使用 SCHED_RR(时间片轮转)
chrt -r 50 ./round_robin_app

# 注意:实时优先级高于所有普通 CFS 调度进程

六、CFS 新发展:EEVDF

值得注意的是,Linux 内核社区正在推进 EEVDF(Earliest Eligible Virtual Deadline First) 调度器来取代 CFS。EEVDF 由 kernel 的主要调度器维护者 Ingo Molnár 在 2023 年提出,预计将在 Linux 6.6+ 中逐步取代 CFS。

EEVDF 的核心改进:

  • 使用严格的截止时间(deadline)替代 CFS 的"近似公平"模型
  • 更精确的延迟控制,适用于实时和低延迟场景
  • 算法复杂度仍为 O(log N),性能与 CFS 相当
  • 保留了 CFS 的"完全公平"特性,同时解决了某些边缘问题(如新进程初始 vruntime 的选择)

七、监控与调试

7.1 查看调度器统计信息

# 查看进程的 vruntime
cat /proc/<pid>/sched | grep vruntime

# 查看 CFS 运行队列信息
cat /proc/sched_debug | grep -A 10 "cfs_rq"

# 查看调度器统计
cat /proc/<pid>/schedstat

7.2 使用 perf 分析调度延迟

# 记录调度事件
perf record -e sched:sched_switch -a sleep 5

# 分析上下文切换
perf report --sort=comm

7.3 使用 bpftool 监控 CFS

# 查看 CFS 相关的 BPF 程序
bpftool prog list | grep cfs

# 跟踪 enqueue/dequeue 操作
bpftool trace

八、总结

CFS 是 Linux 内核调度器的杰出设计,它通过以下创新实现了优雅而高效的进程调度:

  1. 红黑树组织调度实体:O(log N) 插入删除,O(1) 选择最优进程
  2. 虚拟运行时间(vruntime):统一的调度度量,实现严格的公平性
  3. 权重与 nice 值映射:精细的优先级控制
  4. 组调度支持:为容器化和资源隔离奠定基础
  5. 最小粒度与目标延迟:平衡公平性与响应速度

虽然 CFS 即将被 EEVDF 取代,但其核心设计思想——基于虚拟运行时间的公平调度、红黑树数据结构、权重映射机制——将继续影响下一代调度器的设计。

深入理解 CFS,不仅有助于我们更好地配置和调优 Linux 系统性能,更能让我们领略操作系统设计中的工程美学:用简单的数据结构和优雅的算法,解决复杂的资源分配问题。

参考资料

  • Linux 内核源码:kernel/sched/fair.c
  • Understanding the Linux Kernel, 3rd Edition - Chapter 7: Process Scheduling
  • Linux 内核文档:Documentation/scheduler/
  • Ingo Molnär, "CFS: Completely Fair Scheduler", Linux Kernel Mailing List, 2007
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部