2. Linux标准内核调度器:CFS调度器原理、O(1)调度器回顾、调度类与调度策略

在深入实时化改造之前,必须透彻理解Linux标准内核的调度体系。本章从历史演进出发,回顾O(1)调度器的设计动机与局限,然后重点剖析当前完全公平调度器(CFS)的核心原理,最后梳理调度类与调度策略的层次关系。这些知识是理解PREEMPT_RT补丁为何以及如何改造调度器的基石。

2.1 调度器演进:从O(1)到CFS

Linux内核调度器经历了三个主要阶段:早期的O(n)调度器(2.4内核及之前)、O(1)调度器(2.6.0~2.6.22)以及完全公平调度器CFS(2.6.23至今)。O(1)调度器解决了O(n)在大负载下的性能瓶颈,但其交互式优化策略复杂且难以预测。

O(1)调度器的核心设计:
  • 双优先级数组:活跃数组(active)和过期数组(expired),每个数组包含140个优先级队列(0~139,0为最高实时优先级)。
  • 位图查找:使用bitmap在O(1)时间内找到最高优先级的非空队列,取代O(n)遍历。
  • 时间片动态计算:根据静态优先级和交互性启发式算法动态调整时间片长度。
  • 交互性奖励/惩罚:通过sleep_avg跟踪进程睡眠时间,对交互式进程(如X server)给予优先级提升。

O(1)调度器的主要问题在于交互性启发式算法过于复杂,导致调度行为难以预测,且在高负载下交互式进程可能被误判。CFS调度器彻底放弃了固定时间片和优先级队列的设计,转而采用基于虚拟运行时间(vruntime)的红黑树模型。

关键认知:O(1)调度器虽然名字暗示常数时间调度,但其交互性补偿逻辑引入了大量分支和启发式规则,使得调度决策的实际路径长度和结果不确定性增加。CFS的设计哲学是“让调度器尽可能简单、可预测”,这正是实时系统所追求的。

2.2 CFS调度器核心原理

CFS(Completely Fair Scheduler)由Con Kolivas开发,于内核2.6.23合入主线。其核心思想是模拟“理想的多任务处理器”——假设一个CPU可以同时运行所有任务,每个任务获得1/N的CPU时间。CFS通过红黑树维护就绪队列,选择vruntime最小的进程运行。

CFS调度器核心架构 红黑树根节点 vruntime=100ms vruntime=80ms vruntime=120ms vruntime=200ms vruntime=180ms vruntime=250ms 调度选择 调度规则: 每次调度时,选择红黑树中最左节点(vruntime最小)的进程运行。运行过程中,进程的vruntime以实际运行时间为基准递增。 当进程被抢占或时间片用完,重新插入红黑树,调度器再次选择最左节点。

2.2.1 vruntime与权重

vruntime是CFS的核心度量,表示进程的虚拟运行时间。其计算考虑了进程的优先级权重:

vruntime += 实际运行时间 * (NICE_0_LOAD / 进程权重)

其中NICE_0_LOAD是nice值为0时的基准权重(1024)。权重表由内核预计算,nice值每降低1(优先级提升),权重约增加10%。

nice值 权重 相对CPU占比(与nice=0相比)
-20 88761 约86.7倍
-10 11051 约10.8倍
0 1024 1.0倍(基准)
10 95 约0.093倍
19 15 约0.015倍
实时性影响:CFS的vruntime机制本质上是尽力公平的,它不保证任何进程在特定时间范围内获得CPU。高优先级进程(低nice值)虽然获得更多CPU时间,但调度延迟仍然取决于就绪队列中所有进程的vruntime分布。对于需要确定性响应时间的实时任务,CFS的公平模型是不够的。

2.2.2 调度粒度与延迟目标

CFS引入了调度延迟(sched_latency)和最小粒度(min_granularity)两个参数来控制调度频率:

  • 调度延迟(sysctl_sched_latency):默认6ms,表示CFS保证每个就绪进程在6ms内至少获得一次运行机会。
  • 最小粒度(sysctl_sched_min_granularity):默认0.75ms,进程一次运行的最短时间,防止过于频繁的上下文切换。
  • 时间片计算:当就绪进程数N较小时,时间片 = sched_latency / N;当N较大时,时间片固定为min_granularity。

2.3 调度类与调度策略

Linux内核使用调度类(sched_class)实现多态调度架构。每个调度类实现一组统一的接口(如enqueue_task、dequeue_task、pick_next_task等),内核在调度时按优先级顺序遍历调度类,选择最高优先级的任务。

调度类层次结构与调度策略映射 stop_sched_class 优先级最高 dl_sched_class SCHED_DEADLINE rt_sched_class SCHED_FIFO / SCHED_RR fair_sched_class SCHED_OTHER / SCHED_BATCH idle_sched_class SCHED_IDLE 调度类优先级顺序(从高到低) 1. stop_sched_class:停止CPU的任务(如migration),不可被抢占。 2. dl_sched_class:SCHED_DEADLINE,EDF调度,硬实时截止时间。 3. rt_sched_class:SCHED_FIFO/SCHED_RR,静态优先级实时调度。 4. fair_sched_class:SCHED_OTHER/SCHED_BATCH,CFS公平调度。 5. idle_sched_class:SCHED_IDLE,仅当CPU空闲时运行。 调度策略与调度类的对应关系: - SCHED_OTHER(普通分时)→ fair_sched_class - SCHED_BATCH(批量)→ fair_sched_class - SCHED_FIFO(先进先出实时)→ rt_sched_class - SCHED_RR(时间片轮转实时)→ rt_sched_class - SCHED_DEADLINE(截止时间实时)→ dl_sched_class

2.3.1 SCHED_OTHER(普通分时调度)

SCHED_OTHER是默认调度策略,映射到fair_sched_class,由CFS管理。适用于大多数用户态进程,强调公平性和交互响应。进程优先级通过nice值调整(-20~19),nice值越低,获得的CPU时间比例越高。

2.3.2 SCHED_FIFO(先进先出实时调度)

SCHED_FIFO是POSIX实时调度策略之一,映射到rt_sched_class。其核心特性:

  • 静态优先级:范围1~99(数值越大优先级越高),优先级一旦设定不会动态调整。
  • 运行规则:SCHED_FIFO进程会一直运行,直到被更高优先级的实时进程抢占、主动睡眠(如等待I/O)、或调用sched_yield()主动让出CPU。
  • 无时间片:相同优先级的SCHED_FIFO进程之间不会自动轮转,除非高优先级进程阻塞,否则低优先级进程无法运行。
  • 优先级继承:当实时进程持有互斥锁时,如果被低优先级进程阻塞,内核通过优先级继承协议临时提升阻塞进程的优先级,防止优先级反转。
典型应用场景:SCHED_FIFO适用于需要严格优先级控制且执行时间较短的实时任务,如中断处理线程、高频率控制循环。但必须注意:如果SCHED_FIFO进程进入无限循环且优先级高于其他所有进程,将导致系统锁死。

2.3.3 SCHED_RR(时间片轮转实时调度)

SCHED_RR与SCHED_FIFO共享相同的优先级空间(1~99),但增加了时间片轮转机制:

  • 时间片:相同优先级的SCHED_RR进程按时间片轮转执行,默认时间片为100ms(可通过sched_rr_get_interval()查询)。
  • 自动让出:当进程用尽时间片,被移到同优先级队列的尾部,调度器选择下一个同优先级进程运行。
  • 优先级抢占:高优先级的SCHED_RR或SCHED_FIFO进程仍然可以抢占低优先级的SCHED_RR进程。
特性 SCHED_OTHER SCHED_FIFO SCHED_RR
调度类 fair_sched_class rt_sched_class rt_sched_class
优先级范围 nice -20~19(动态) 静态 1~99 静态 1~99
时间片 动态(基于vruntime) 无(运行到阻塞或让出) 固定(默认100ms)
同优先级轮转 是(CFS公平调度) 否 是
被抢占条件 vruntime差值超过阈值 仅被更高优先级实时进程 时间片耗尽或被更高优先级
实时性保证 无 强(需谨慎设计) 中等(有轮转保护)

2.4 调度类与调度策略的交互机制

内核调度器的主入口是__schedule()函数,它按照以下顺序遍历调度类:

// 简化后的调度主路径
static void __schedule(unsigned int sched_mode)
{
    struct task_struct *prev, *next;
    struct rq *rq;
    int cpu;

    cpu = smp_processor_id();
    rq = cpu_rq(cpu);
    prev = rq->curr;

    // 1. 检查是否需要抢占当前进程
    // 2. 按优先级顺序遍历调度类,选择下一个任务
    next = pick_next_task(rq, prev, sched_mode);
    // 3. 如果next != prev,执行上下文切换
    context_switch(rq, prev, next);
}

// pick_next_task 遍历调度类
static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, unsigned int sched_mode)
{
    const struct sched_class *class;
    struct task_struct *p;

    // 优化路径:如果所有任务都是fair_sched_class,直接调用CFS
    // 否则按优先级顺序遍历
    for_each_class(class) {
        p = class->pick_next_task(rq);
        if (p)
            return p;
    }
    // 如果没有可运行任务,返回idle任务
    return rq->idle;
}
实时调度关键点:在标准内核中,rt_sched_class的优先级高于fair_sched_class。这意味着只要有一个SCHED_FIFO或SCHED_RR进程处于就绪状态,所有SCHED_OTHER进程都无法获得CPU。这是实时任务能够获得确定性响应的基础,但也意味着实时任务的编写必须极其谨慎,避免长时间占用CPU导致非实时任务饿死。

2.5 标准内核调度器对实时性的限制

尽管标准内核提供了SCHED_FIFO和SCHED_RR实时调度策略,但其实时能力仍然受到以下根本性限制:

  • 内核抢占点不足:标准内核是自愿抢占(voluntary preemption)模型,内核态代码只有在明确的抢占点(如系统调用返回、中断返回、显式调用schedule())才能被抢占。这意味着实时进程无法立即抢占正在执行内核代码的非实时进程。
  • 中断关闭区域:内核中存在大量使用local_irq_disable()/local_irq_save()的临界区,在这些区域内,即使是最高优先级的实时任务也无法响应中断和调度。
  • 自旋锁持有期间不可抢占:持有自旋锁的代码路径不能被调度,导致优先级反转问题在标准内核中难以完全解决。
  • 中断线程化缺失:标准内核的中断处理程序运行在中断上下文,优先级高于任何进程,且不可被调度。这导致中断处理延迟不可控,且中断处理程序无法使用阻塞操作。
核心结论:标准内核的实时调度策略(SCHED_FIFO/SCHED_RR)只能保证用户态实时任务的优先级调度,但无法保证内核态的抢占延迟。实时任务在陷入内核(如系统调用、缺页异常)后,其响应时间可能被非实时内核代码路径显著延长。这正是PREEMPT_RT补丁要解决的核心问题——通过减少内核中的不可抢占区域,实现内核态的全抢占。