3. CFS设计哲学:完全公平调度的核心思想

完全公平调度器(CFS,Completely Fair Scheduler)是Linux内核自2.6.23版本引入的默认调度类,其设计哲学彻底颠覆了传统基于时间片和优先级队列的调度模型。CFS的核心目标是在真实硬件(有限CPU核心)上,为每个可运行进程模拟出“理想、精确的多任务处理器”——即每个进程都能获得完全相等的CPU时间份额。

核心思想:CFS不直接分配固定时间片,而是通过维护每个进程的虚拟运行时间(vruntime),并始终选择vruntime最小的进程来运行,从而在宏观统计上实现CPU时间的完全公平分配。

3.1 从“时间片”到“权重比例”的范式转变

传统O(1)调度器为每个优先级分配固定时间片,进程在时间片耗尽后被迫让出CPU。CFS摒弃了这种刚性分配,转而采用基于权重(weight)的比例分配模型。每个进程的优先级(nice值)被映射为一个权重值,权重越高,进程在单位时间内获得的CPU运行比例越大。

对比维度 传统O(1)调度器 CFS完全公平调度器
分配单位 固定时间片(如100ms) CPU时间比例(基于权重)
公平性度量 时间片是否耗尽 虚拟运行时间(vruntime)是否相等
优先级影响 决定时间片长度 决定vruntime的增长速率
调度决策依据 优先级队列+过期位图 红黑树中查找最小vruntime节点
空闲CPU处理 时间片可能被浪费 空闲进程自动获得CPU时间
关键理解:在CFS中,优先级(nice值)不再决定“能运行多久”,而是决定“运行同样真实时间后,vruntime增长多少”。高优先级(低nice值)进程的vruntime增长更慢,因此更容易被调度器选中。

3.2 虚拟运行时间(vruntime)概念

vruntime是CFS调度器的核心度量指标,它记录了进程在理想多任务处理器上应该获得的运行时间。每次时钟中断或进程状态切换时,内核会更新当前运行进程的vruntime。vruntime的计算公式如下:

/* 简化后的vruntime更新逻辑 */
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 = now - curr->exec_start;

    /* 根据当前进程的权重,将实际执行时间转换为虚拟运行时间 */
    curr->vruntime += calc_delta_fair(delta_exec, curr);
    curr->exec_start = now;
}

/* 虚拟运行时间增量计算 */
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se) {
    /* NICE_0_LOAD 是nice=0时的权重(1024) */
    if (unlikely(se->load.weight != NICE_0_LOAD))
        /* vruntime增量 = 实际时间 * (NICE_0_LOAD / se->load.weight) */
        delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
    return delta;
}

从公式可以看出:

  • nice=0的进程:权重为1024(NICE_0_LOAD),vruntime增量等于实际运行时间。
  • 高优先级进程(nice=-20):权重高达88761,vruntime增量仅为实际时间的约1.15%(1024/88761),增长极慢。
  • 低优先级进程(nice=19):权重仅为15,vruntime增量是实际时间的约68倍(1024/15),增长极快。
避坑提示:vruntime不是真实时间,而是经过权重归一化后的“虚拟时间”。调度器总是选择vruntime最小的进程运行,这意味着高权重(高优先级)进程虽然实际运行时间长,但vruntime增长慢,因此能持续保持最小vruntime,从而获得更多CPU时间。

3.3 nice值与权重映射表

Linux内核维护了一个全局的权重数组,将每个nice值(-20到19)映射到一个具体的权重值。这个映射关系是CFS实现优先级差异的基础。

nice值 优先级(prio) 权重(weight) 相对nice=0的CPU占比
-2010088761约86.7倍
-101109548约9.3倍
-51153355约3.3倍
012010241.0倍(基准)
5125335约0.33倍
10130110约0.11倍
1913915约0.015倍

内核源码中对应的权重数组定义如下:

/* kernel/sched/core.c */
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.25倍。这意味着每降低一个nice值(提高优先级),进程获得的CPU时间大约增加25%。这种设计保证了nice值在整个范围内具有一致的“性价比”。

3.4 红黑树数据结构:高效查找最小vruntime

CFS使用红黑树(Red-Black Tree)来组织同一调度实体组(cfs_rq)中的所有可运行进程。红黑树是一种自平衡二叉查找树,其核心特性保证了调度操作的高效性:

  • 查找最小节点:红黑树的最左叶子节点即为vruntime最小的进程,时间复杂度为O(log N)。
  • 插入新进程:进程被唤醒或创建时,按vruntime值插入红黑树,时间复杂度O(log N)。
  • 删除进程:进程进入睡眠或退出时,从红黑树中删除,时间复杂度O(log N)。
  • 自平衡:红黑树通过旋转和着色操作保持近似平衡,避免退化为链表。
CFS红黑树结构:按vruntime组织可运行进程 vruntime=150 vruntime=100 vruntime=220 vruntime=80 vruntime=130 vruntime=190 vruntime=280 ★ 被选中运行 vruntime最小=80 调度决策流程: 1. 从红黑树根节点开始,沿左子树递归查找最左叶子节点。 2. 最左叶子节点(vruntime=80)即为当前应被调度的进程。 3. 进程运行后vruntime增加,可能被重新插入到树中更靠右的位置。

3.5 CFS调度周期的完整流程

一个完整的CFS调度周期包含以下关键步骤,这些步骤在每个调度点(时钟中断、进程主动让出CPU、唤醒等)都会被执行:

  1. 更新当前进程vruntime:调用update_curr(),根据当前进程的实际运行时间和权重,计算并累加vruntime。
  2. 检查是否需要抢占:如果当前进程的vruntime超过了红黑树中最左叶子节点的vruntime,则设置TIF_NEED_RESCHED标志。
  3. 选择下一个进程:在调度点(如时钟中断返回时),调用pick_next_task_fair(),从红黑树中取出vruntime最小的进程。
  4. 上下文切换:保存当前进程的寄存器状态,恢复新进程的寄存器状态,完成CPU执行流的切换。
  5. 新进程开始运行:新进程从上次被切换出去的位置继续执行,同时内核开始记录其exec_start时间戳。
公平性保证:由于CFS始终选择vruntime最小的进程运行,而低优先级进程的vruntime增长更快,因此它们被选中运行的时间更短,但频率更高。高优先级进程虽然被选中次数少,但每次运行时间长。最终,在足够长的时间窗口内,所有进程获得的CPU时间比例严格等于其权重比例。

3.6 调度延迟与最小粒度

CFS引入了两个关键参数来控制调度行为,防止进程切换过于频繁或过于稀疏:

参数名称 内核变量 默认值 作用说明
调度延迟(sched_latency) sysctl_sched_latency 6ms(桌面)/ 20ms(服务器) 保证所有可运行进程在至少一个调度延迟周期内都能获得一次运行机会。
最小粒度(min_granularity) sysctl_sched_min_granularity 0.75ms(桌面)/ 4ms(服务器) 进程每次被调度运行的最短时间,防止切换开销过大。

当可运行进程数量增多时,CFS会动态调整每个进程的时间片:

/* 计算每个进程的时间片 */
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se) {
    u64 slice = __sched_period(cfs_rq->nr_running);
    /* 时间片 = 调度延迟 * (进程权重 / 总权重) */
    do_div(slice, cfs_rq->load.weight);
    return slice * se->load.weight;
}

/* 调度周期 = max(调度延迟, 最小粒度 * 进程数) */
static u64 __sched_period(unsigned long nr_running) {
    if (unlikely(nr_running > sched_nr_latency))
        return nr_running * sysctl_sched_min_granularity;
    else
        return sysctl_sched_latency;
}
动态调整:当进程数较少时(≤8个),每个进程获得完整的时间片(调度延迟/进程数)。当进程数过多时,CFS会退化为每个进程只运行最小粒度时间,然后立即切换,以保证所有进程都能及时获得CPU时间。

3.7 CFS调度器的整体架构

下图展示了CFS调度器在内核中的整体架构和核心组件之间的关系:

CFS调度器核心架构与数据流 调度核心 (core.c) fair.c (CFS调度类) 红黑树管理 enqueue_entity/dequeue_entity vruntime计算 update_curr/calc_delta_fair 调度点决策 pick_next_task/check_preempt cfs_rq (运行队列) 红黑树根节点+负载统计 sched_entity (调度实体) vruntime + load.weight task_struct (进程描述符) se指针 + prio/nice 数据流说明: 1. 调度核心调用fair.c中的回调函数(如pick_next_task_fair)。 2. fair.c操作cfs_rq中的红黑树,查找最小vruntime的调度实体。 3. 调度实体(sched_entity)内嵌在task_struct中,通过se指针访问进程的vruntime和权重。
设计总结:CFS通过“虚拟运行时间+红黑树+权重映射”三者的精妙配合,实现了O(log N)时间复杂度的公平调度。其设计哲学可以概括为:不追求每个时间片内的绝对公平,而是追求统计意义上的长期公平。这种设计使得Linux内核在交互式响应、吞吐量和公平性之间取得了极佳的平衡。