2. CFS调度器基础:完全公平调度算法原理、红黑树数据结构、虚拟运行时间(vruntime)计算

2.1 完全公平调度算法原理

完全公平调度(Completely Fair Scheduler, CFS)是 Linux 内核自 2.6.23 版本引入的默认调度器,其设计哲学颠覆了传统基于时间片轮转的调度模型。CFS 的核心思想是:为每个可运行进程分配 CPU 时间的“公平份额”,而非固定时间片。它通过一个理想化的“精确多任务 CPU”模型,模拟所有进程同时以无限小的时间粒度并行运行。

核心设计理念: CFS 不直接分配时间片,而是维护一个“等待 CPU 时间”的排序树。调度器总是选择“欠 CPU 时间最多”的进程来运行,从而在宏观上实现所有进程的 CPU 时间分配比例与它们的权重(nice 值)成正比。

CFS 调度周期(也称为“调度延迟”)是一个关键参数,它决定了所有可运行进程至少被调度一次的时间间隔。默认值为 6ms,但会根据可运行进程数量动态调整,以确保调度延迟不会过长。

传统调度器(O(1)) CFS 调度器
基于固定时间片 基于虚拟运行时间(vruntime)
优先级通过时间片长度体现 优先级通过 vruntime 增长速率体现
使用多级反馈队列 使用红黑树(rbtree)
调度复杂度 O(1) 调度复杂度 O(log N)
需要处理时间片耗尽、重新计算 无需时间片概念,持续运行直到被抢占
CFS 调度决策流程 进程就绪队列 红黑树(按 vruntime 排序) 选择最左节点(最小 vruntime) 进程获得 CPU 运行

2.2 红黑树数据结构

CFS 使用红黑树(Red-Black Tree)作为可运行进程队列的核心数据结构。红黑树是一种自平衡二叉查找树,其关键特性保证了 CFS 的调度效率:

  • 自平衡性: 插入和删除操作后,树的高度始终保持在 O(log N),确保调度延迟可控。
  • 有序性: 所有节点按照 vruntime 值排序,最左侧节点始终是 vruntime 最小的进程。
  • O(log N) 复杂度: 每次调度决策(选择下一个运行进程)只需取最左节点,复杂度为 O(1);插入和删除操作复杂度为 O(log N)。
为什么选择红黑树而不是堆? 虽然最小堆也能快速获取最小值,但 CFS 需要频繁地更新进程的 vruntime(每次时钟中断),并重新调整其在树中的位置。红黑树在动态插入、删除和节点更新方面具有更均衡的性能,且支持高效的“按 key 查找”操作,这在负载均衡场景中至关重要。

红黑树在 CFS 中的具体实现位于 kernel/sched/fair.c 文件中,核心数据结构为 struct cfs_rq,其中包含红黑树的根节点 rb_root 和缓存的最左节点指针 rb_leftmost。

/* kernel/sched/fair.c - CFS 运行队列结构 */
struct cfs_rq {
    struct load_weight  load;           /* 运行队列的总负载 */
    unsigned int        nr_running;     /* 可运行进程数量 */
    unsigned int        h_nr_running;   /* 包括子 cgroup 的进程数 */

    u64                 min_vruntime;   /* 队列最小虚拟运行时间 */
    struct rb_root_cached   tasks_timeline; /* 红黑树根节点 + 最左节点缓存 */

    struct sched_entity *curr;          /* 当前正在运行的调度实体 */
    struct sched_entity *next;          /* 下一个要运行的调度实体 */
    struct sched_entity *last;          /* 上一个运行的调度实体 */
    /* ... 其他字段 */
};
CFS 红黑树结构示意图 vruntime: 100 vruntime: 50 vruntime: 150 vruntime: 20 vruntime: 80 vruntime: 120 vruntime: 200 ← 最左节点 调度器每次选择最左节点 (vruntime 最小的进程)运行

2.3 虚拟运行时间(vruntime)计算

虚拟运行时间(vruntime)是 CFS 调度器的核心度量指标。它不是一个真实的物理时间,而是经过进程优先级(nice 值)加权后的“标准化运行时间”。CFS 通过 vruntime 实现了“权重感知”的公平调度。

2.3.1 vruntime 计算公式

vruntime 的更新发生在每次时钟中断(tick)和进程状态切换时。其核心计算公式如下:

/* vruntime 增量计算 */
delta_exec = 当前进程实际运行的物理时间(ns)
vruntime += delta_exec * (NICE_0_LOAD / se->load.weight)

其中:
- NICE_0_LOAD = 1024(nice 值为 0 时的权重)
- se->load.weight = 进程的实际权重(由 nice 值映射而来)

从公式可以看出:

  • 高优先级进程(nice 值小,权重大): 分母大,vruntime 增长慢,因此更容易被调度器选中。
  • 低优先级进程(nice 值大,权重小): 分母小,vruntime 增长快,因此运行时间更短。
nice 值 权重(weight) vruntime 增长速率(相对 nice=0) 典型应用场景
-20 88761 0.0115x(极慢) 实时音频处理
-10 11051 0.0927x(慢) 交互式应用
0 1024 1.0x(基准) 普通进程
10 304 3.368x(快) 后台批处理
19 15 68.267x(极快) 空闲任务

2.3.2 min_vruntime 与虚拟时间归一化

CFS 运行队列维护一个 min_vruntime 字段,它表示队列中所有进程 vruntime 的最小值。当新进程加入或进程被唤醒时,其初始 vruntime 会被设置为 min_vruntime,以防止新进程获得不公平的 CPU 时间优势。

关键注意事项: 当进程睡眠时间较长时,其 vruntime 会远小于 min_vruntime。如果直接恢复运行,该进程会“饿死”其他进程。CFS 通过 place_entity() 函数对唤醒进程的 vruntime 进行补偿,将其设置为 min_vruntime - 调度延迟/2 左右,既保证其获得一定补偿,又不至于过度抢占。

2.3.3 vruntime 更新路径

vruntime 的更新发生在以下关键路径中:

  1. 周期性调度(scheduler_tick): 每次时钟中断(通常 1ms 或 4ms)触发,更新当前进程的 vruntime。
  2. 进程唤醒(try_to_wake_up): 进程从睡眠状态被唤醒时,重新计算其 vruntime。
  3. 进程创建(wake_up_new_task): 新创建的进程首次加入运行队列时。
  4. 上下文切换(context_switch): 进程切换时,更新被切换出去进程的 vruntime。
/* kernel/sched/fair.c - vruntime 更新核心函数 */
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;

    if (unlikely(!curr))
        return;

    /* 计算自上次更新以来的实际运行时间 */
    delta_exec = now - curr->exec_start;
    if (unlikely((s64)delta_exec <= 0))
        return;

    curr->exec_start = now;

    /* 核心:根据权重缩放 vruntime */
    curr->vruntime += calc_delta_fair(delta_exec, curr);

    /* 更新 min_vruntime */
    update_min_vruntime(cfs_rq);
}

static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
    if (unlikely(se->load.weight != NICE_0_LOAD))
        /* 权重归一化计算 */
        delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
    return delta;
}
vruntime 更新与调度决策流程 时钟中断触发 update_curr() 计算 delta_exec calc_delta_fair() 权重缩放 更新 vruntime 检查是否需要抢占 调度决策(schedule())

2.4 调度延迟与 vruntime 的交互

调度延迟(sched_latency)是 CFS 中一个关键的系统参数,它直接影响了 vruntime 的更新和进程的抢占行为。CFS 通过以下机制将调度延迟与 vruntime 关联起来:

  • 目标延迟(targeted_latency): 默认值为 6ms,表示所有可运行进程在一个调度周期内至少被调度一次的时间目标。
  • 最小粒度(min_granularity): 默认值为 0.75ms,防止进程切换过于频繁导致上下文切换开销过大。
  • 抢占阈值: 当当前进程的 vruntime 与红黑树最左节点的 vruntime 差值超过 thresh(通常为调度延迟/2)时,触发抢占。
性能优化要点: 在 PELT 负载跟踪与 CFS 联合优化中,调度延迟的调整直接影响负载均衡的精度。过短的调度延迟会增加上下文切换开销,降低吞吐量;过长的调度延迟会导致交互式进程响应变慢。通常建议在延迟敏感场景(如音视频处理)中适当减小调度延迟,在批处理场景中增大调度延迟。