Linux内核分析之进程管理-03

11.1 Linux 调度器演进史

Linux 调度器经历了三十多年的持续演进。从 Linus Torvalds 在 1991 年写下的第一版调度器,到如今 Linux 7.0 中基于 EEVDF 理论的现代调度器,每一次重大变革都源于对已有设计瓶颈的深刻反思。本节将追溯这条演进之路,理解每一次变革背后的动机与设计哲学。


11.1.1 原始调度器:Linux 0.01 ~ 2.4(1991-2002)

1.1 O(n) 调度器的设计

Linux 最早的调度器极为简单:系统维护一个全局的任务链表,每次调度时需要遍历所有任务,找出优先级最高(counter 值最大)的那个来运行。这段逻辑可以用伪代码概括:

for_each_task(p) {
    if (p->counter > max_counter)
        next = p;
}

每个任务有一个 counter 字段,代表其剩余时间配额。任务每运行一个时钟滴答,counter 就减一;当 counter 归零时,触发重新调度。当所有可运行任务的 counter 都为零时,系统会为所有任务重新分配配额——这是经典的 round-robin 思想。

1.2 O(n) 的致命缺陷

这种设计的优点是简洁直观,代码量极小。但它的缺点同样是致命的:

复杂度问题:选择下一个任务的时间复杂度为 O(n),其中 n 是系统中所有任务的总数——注意,不仅仅是"可运行"任务,而是所有任务。在早期的单处理器、少量任务的系统中这不是问题,但当 Linux 开始被部署到拥有数千个进程的企业级服务器上时,调度开销变得不可接受。每次调度都需要扫描数千个任务描述符,这在时钟中断驱动的调度场景中尤其糟糕——每个 tick(通常是 1ms 或 4ms)都可能触发调度。

SMP 扩展性问题:在多处理器(SMP)系统中,O(n) 调度器使用一个全局自旋锁保护整个运行队列。这意味着在任何 CPU 上的调度操作都会阻塞所有其他 CPU 上的调度——一种极其严重的锁竞争。随着 CPU 核心数的增长,这个全局锁成为系统扩展的硬瓶颈。

交互式任务响应差:O(n) 调度器缺乏对交互式任务(如桌面应用)的特殊关照。一个忙碌于大量后台计算任务的系统,其桌面交互响应会变得迟钝。

1.3 历史地位

尽管 O(n) 调度器有诸多不足,但它服务了 Linux 社区超过十年。在单处理器或双处理器为主流的时代,简单性本身就是一种美德。它的设计理念——优先级 + 时间片轮转——至今仍是调度器的基本范式。


11.1.2 O(1) 调度器:Linux 2.4/2.5 ~ 2.6.22(2002-2007)

2.1 Ingo Molnar 的重新设计

2002 年,Ingo Molnar 对 Linux 调度器进行了彻底的重新设计,引入了 O(1) 调度器。这个名称来源于其核心设计目标:无论是入队还是选择下一个任务,时间复杂度都是 O(1)——常数时间,与系统中任务数量无关。

2.2 核心设计

O(1) 调度器的核心架构包含以下几个关键设计:

Per-CPU 运行队列:每个 CPU 拥有自己独立的运行队列,彻底消除了全局锁竞争。一个 CPU 上的调度操作完全不会影响其他 CPU。

优先级数组(Priority Arrays):每个运行队列包含两个优先级数组——活跃数组(active)和过期数组(expired)。每个数组有 140 个优先级槽位(0-139),每个槽位是一个链表头,挂载该优先级的所有任务。

struct prio_array {
    unsigned int nr_active;            // 活跃任务总数
    unsigned long bitmap[BITMAP_SIZE]; // 优先级位图
    struct list_head queue[MAX_PRIO];  // 140 个优先级链表
};

选择下一个任务的算法极为高效:首先扫描位图找到最高优先级的非空槽位(O(1)),然后从该槽位的链表头部取出第一个任务。位图操作由 sched_find_first_bit() 这类架构优化的指令(如 x86 的 BSF)完成,效率极高。

双数组轮转:当活跃数组中所有任务都用完时间片后,交换活跃数组与过期数组的指针——又是一步 O(1) 操作。过期数组中的任务在等待期间已经重新分配了时间片。

交互式任务奖励:O(1) 调度器引入了一套复杂的启发式规则来识别和奖励交互式任务。睡眠时间长的任务(通常是在等待用户输入)会被判定为"交互式",获得动态优先级提升,并且不会被降级到过期数组,从而保持即时响应。

2.3 启发式的代价

O(1) 调度器的致命弱点恰恰在于其交互式启发式。这套规则包含了大量的"魔法数字"和经验阈值:

  • 睡眠多长时间才算"交互式"?
  • 如何计算动态优先级奖励?
  • 如何处理既做计算又做 I/O 的混合型任务?

这些问题导致代码复杂且脆弱。在实际部署中,启发式规则经常在边缘情况下失效:某些音频应用出现卡顿,某些编译任务被不公平对待。社区花了大量时间调整参数和修补特殊情况,但始终无法找到一组普适的参数。

2.4 历史遗产

O(1) 调度器留下的最重要的遗产是 Per-CPU 运行队列的设计。这一架构思想被后续的 CFS 和 EEVDF 完全继承,至今仍是 Linux 调度器的基本框架。此外,140 个优先级槽位的设计也延续了下来,尽管内部的排序机制从链表改为了红黑树。


11.1.3 CFS 完全公平调度器:Linux 2.6.23 ~ 6.11(2007-2024)

3.1 设计哲学

2007 年,Ingo Molnar 再次出手,用 CFS(Completely Fair Scheduler,完全公平调度器)取代了 O(1) 调度器。CFS 的设计哲学可以用一句话概括:

"我们用一个非常精确的模型来模拟一个'理想的、完美的多任务处理器'。"

在一个理想的完美多任务处理器上,所有可运行任务可以真正地同时运行,每个任务获得完全相等的 CPU 时间。现实中不存在这样的硬件,但 CFS 通过跟踪每个任务应该获得的 CPU 时间与实际获得的 CPU 时间之间的差距,来近似这一理想模型。

3.2 虚拟运行时间(vruntime)

CFS 的核心概念是虚拟运行时间(vruntime)。每个任务有一个 vruntime 字段,记录它在"虚拟时间"维度上已经运行了多久。虚拟时间的流速与任务的权重(由 nice 值决定)成反比:nice 值为 0 的任务,虚拟时间流速等于物理时间;nice 值为负(高优先级)的任务,虚拟时间流速较慢,因此看起来"运行得更少",从而被优先调度;nice 值为正(低优先级)的任务则相反。

vruntime_delta = delta_exec * (NICE_0_LOAD / weight)

调度决策极其简单:始终选择 vruntime 最小的任务运行。这就像一个排队系统,每个人都按"欠多少服务"排队——被服务最少的排在最前面。

3.3 红黑树

为了高效地找到 vruntime 最小的任务,CFS 将所有可运行任务组织在一棵红黑树(Red-Black Tree)中,以 vruntime 为键值。红黑树保证了 O(log n) 的插入、删除和查找性能。最左节点(rb_leftmost)缓存了 vruntime 最小的任务,使得选择下一个任务的均摊时间复杂度为 O(1)。

3.4 优雅与局限

CFS 最大的成就是消除了 O(1) 调度器中所有不可控的启发式规则。没有魔法数字,没有交互式评分,没有复杂的优先级调整——只有一个清晰的数学模型和一个简单的排序规则。这种优雅使得 CFS 在十七年间几乎没有发生过重大的设计变更,成为 Linux 内核最稳定、最成功的子系统之一。

然而,CFS 并非完美。它在以下场景存在固有的延迟问题:

  • 不同 nice 值任务的延迟差异:一个 nice 0 的任务和一个 nice +10 的任务竞争 CPU 时,低优先级任务可能长时间得不到调度,其调度延迟不可预测。
  • 短生命周期任务的响应:刚唤醒的短任务(如处理网络中断的工作线程)可能因为 vruntime 被设置得过于保守,而无法及时获得 CPU。
  • "buddy" 机制的脆弱性:为了改善延迟,CFS 引入了 next/buddy 机制——手动标记需要"优先照顾"的任务。但这本质上又是一种启发式,与 CFS 的设计哲学相矛盾。

11.1.4 EEVDF 调度器:Linux 6.6 ~ 7.0(2023-至今)

4.1 变革的契机

2023 年,Peter Zijlstra 基于 EEVDF(Earliest Eligible Virtual Deadline First)调度理论,对 CFS 进行了根本性的重构。这不是一个全新的调度器——它仍然使用红黑树、vruntime、Per-CPU 运行队列这些 CFS 的基础设施——但在核心的选择算法上进行了彻底的变革。

EEVDF 理论源自调度理论领域的经典论文(由 Stoica 等人在 1996 年提出),它在理论上证明了能够同时保证比例公平性和延迟边界。

4.2 从"最小 vruntime"到"最早合格截止期"

CFS 的策略是选择 vruntime 最小的任务。EEVDF 的策略要复杂一些:

  1. 每个任务有一个虚拟截止期(virtual deadline):vd_i = vruntime_i + slice_i / weight_i
  2. 每个任务只有在"合格"(eligible)时才能被选择——即它被欠了 CPU 时间
  3. 在所有合格任务中,选择虚拟截止期最早的那个

这个看似微小的变化带来了深刻的区别:

  • 截止期的概念天然地提供了延迟边界:一个任务最多在 slice/weight 的虚拟时间之后就会被调度
  • 合格性检查提供了自然的历史公平性:过度使用 CPU 的任务会被暂时排除在选择之外
  • 这两个概念结合在一起,在理论上保证了比例公平性 + 有界延迟

4.3 从 CFS 到 EEVDF 的过渡

Linux 6.6 引入了最初的 EEVDF 补丁系列,6.12 将其作为默认的公平调度算法。Linux 7.0 在此基础上进行了进一步的优化和完善,包括增强红黑树的 augmented callback、slice 保护机制(RUN_TO_PARITY)、以及更精确的 lag 保存策略。

值得注意的是,这个过渡对用户空间是完全透明的:sysfs 接口、proc 文件系统、nice 值语义都保持不变。用户唯一能感受到的变化是——某些延迟敏感型应用的响应更好了。


11.1.5 调度类层级架构

5.1 struct sched_class

Linux 调度器的模块化设计通过 struct sched_class 实现。定义在 kernel/sched/sched.h 第 2500 行:

// kernel/sched/sched.h:2500
struct sched_class {
    void (*enqueue_task)(struct rq *rq, struct task_struct *p, int flags);
    bool (*dequeue_task)(struct rq *rq, struct task_struct *p, int flags);
    void (*yield_task)(struct rq *rq);
    bool (*yield_to_task)(struct rq *rq, struct task_struct *p);
    void (*wakeup_preempt)(struct rq *rq, struct task_struct *p, int flags);
    int  (*balance)(struct rq *rq, struct task_struct *prev, struct rq_flags *rf);
    struct task_struct *(*pick_task)(struct rq *rq, struct rq_flags *rf);
    struct task_struct *(*pick_next_task)(struct rq *rq, struct task_struct *prev,
                                          struct rq_flags *rf);
    void (*put_prev_task)(struct rq *rq, struct task_struct *p, struct task_struct *next);
    void (*set_next_task)(struct rq *rq, struct task_struct *p, bool first);
    int  (*select_task_rq)(struct task_struct *p, int task_cpu, int flags);
    void (*migrate_task_rq)(struct task_struct *p, int new_cpu);
    void (*task_woken)(struct rq *this_rq, struct task_struct *task);
    void (*set_cpus_allowed)(struct task_struct *p, struct affinity_context *ctx);
    void (*rq_online)(struct rq *rq);
    void (*rq_offline)(struct rq *rq);
    struct rq *(*find_lock_rq)(struct task_struct *p, struct rq *rq);
    void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
    void (*task_fork)(struct task_struct *p);
    void (*task_dead)(struct task_struct *p);
    void (*switching_from)(struct rq *this_rq, struct task_struct *task);
    void (*switched_from)(struct rq *this_rq, struct task_struct *task);
    void (*switching_to)(struct rq *this_rq, struct task_struct *task);
    void (*switched_to)(struct rq *this_rq, struct task_struct *task);
    u64  (*get_prio)(struct rq *this_rq, struct task_struct *task);
    void (*prio_changed)(struct rq *this_rq, struct task_struct *task, u64 oldprio);
    void (*reweight_task)(struct rq *this_rq, struct task_struct *task,
                          const struct load_weight *lw);
    unsigned int (*get_rr_interval)(struct rq *rq, struct task_struct *task);
    void (*update_curr)(struct rq *rq);
    // ...
};

每个调度类通过函数指针实现了这些操作。这种面向对象风格的设计使得不同的调度策略可以独立开发和维护。

5.2 调度类的优先级排列

Linux 调度类通过链接器布局保证优先级顺序。每个调度类在各自的源文件中用 DEFINE_SCHED_CLASS 宏定义:

调度类 源文件 行号 策略 优先级
stop_sched_class kernel/sched/stop_task.c:99 最高 migration/stop 内核线程 最高
dl_sched_class kernel/sched/deadline.c:3427 高 SCHED_DEADLINE (EDF) 高
rt_sched_class kernel/sched/rt.c:2590 中 SCHED_FIFO / SCHED_RR 中
fair_sched_class kernel/sched/fair.c:13953 低 SCHED_NORMAL / SCHED_BATCH (EEVDF) 低
ext_sched_class kernel/sched/ext.c 可选 sched_ext (BPF) 可选
idle_sched_class kernel/sched/idle.c:569 最低 idle/swapper 最低

这些声明在 kernel/sched/sched.h 第 2718-2722 行通过 extern 引入:

// kernel/sched/sched.h:2718-2722
extern const struct sched_class stop_sched_class;
extern const struct sched_class dl_sched_class;
extern const struct sched_class rt_sched_class;
extern const struct sched_class fair_sched_class;
extern const struct sched_class idle_sched_class;

5.3 调度类遍历

__pick_next_task() 函数(kernel/sched/core.c:5910)通过 for_each_active_class 宏从高到低遍历调度类:

// kernel/sched/core.c:5943-5962
restart:
    prev_balance(rq, prev, rf);

    for_each_active_class(class) {
        if (class->pick_next_task) {
            p = class->pick_next_task(rq, prev, rf);
            if (unlikely(p == RETRY_TASK))
                goto restart;
            if (p)
                return p;
        } else {
            p = class->pick_task(rq, rf);
            if (unlikely(p == RETRY_TASK))
                goto restart;
            if (p) {
                put_prev_set_next_task(rq, prev, p);
                return p;
            }
        }
    }

for_each_active_class 宏定义在 kernel/sched/sched.h 第 2749 行,它从 __sched_class_highest 开始迭代到 __sched_class_lowest。活跃类遍历还考虑了 sched_ext 的动态启用/禁用——如果 sched_ext 接管了所有公平调度任务,则跳过 fair_sched_class;如果 sched_ext 被禁用,则跳过 ext_sched_class。

5.4 快速路径优化

Linux 的调度器还包含一个重要的快速路径优化。在 __pick_next_task() 中(kernel/sched/core.c:5927-5941),如果当前 CPU 上所有可运行任务都属于公平调度类,且前一个任务也不是更高优先级类的,则直接调用 pick_next_task_fair() 跳过调度类遍历:

// kernel/sched/core.c:5927-5941
if (likely(!sched_class_above(prev->sched_class, &fair_sched_class) &&
           rq->nr_running == rq->cfs.h_nr_queued)) {

    p = pick_next_task_fair(rq, prev, rf);
    if (unlikely(p == RETRY_TASK))
        goto restart;

    /* Assume the next prioritized class is idle_sched_class */
    if (!p) {
        p = pick_task_idle(rq, rf);
        put_prev_set_next_task(rq, prev, p);
    }

    return p;
}

这个优化在实践中非常有效:绝大多数桌面和服务器工作负载中,几乎所有的任务都是 SCHED_NORMAL,因此这条快速路径被频繁命中,避免了无意义的调度类遍历开销。


11.1.6 核心调度路径:schedule() 到 pick_eevdf()

理解 Linux 调度器的完整调用路径,是深入内核调度机制的关键。让我们从用户空间的角度追踪一次完整的调度过程。

6.1 入口:schedule()

当任务显式调用 schedule(),或者内核在某处检查到 TIF_NEED_RESCHED 标志后调用 schedule() 时,控制流进入 kernel/sched/core.c 第 6975 行:

// kernel/sched/core.c:6975-6988
asmlinkage __visible void __sched schedule(void)
{
    struct task_struct *tsk = current;

    if (!task_is_running(tsk))
        sched_submit_work(tsk);
    __schedule_loop(SM_NONE);
    sched_update_worker(tsk);
}

6.2 核心函数:__schedule()

__schedule() 是真正的调度核心,位于 kernel/sched/core.c 第 6748 行。它的主要步骤包括:

  1. 获取当前 CPU 的运行队列 rq
  2. 获取 rq->lock 自旋锁并禁用中断
  3. 更新运行队列时钟 update_rq_clock(rq)
  4. 处理前一个任务的状态(如果是阻塞,则从运行队列中移除)
  5. 调用 pick_next_task() 选择下一个任务
  6. 如果 prev != next,执行上下文切换 context_switch()
// kernel/sched/core.c:6835-6836
pick_again:
    next = pick_next_task(rq, rq->donor, &rf);
    rq_set_donor(rq, next);

6.3 任务选择:pick_next_task()

pick_next_task() 在核心调度框架和各调度类之间架起了桥梁。在快速路径中,它直接调用 pick_next_task_fair();在慢速路径中,它遍历所有活跃调度类。

6.4 公平调度选择:pick_next_task_fair()

公平调度类的 pick_next_task_fair() 定义在 kernel/sched/fair.c 第 8980 行。它调用 pick_task_fair()(第 8943 行),后者在 Per-CPU 的 CFS 运行队列中层层深入,最终调用 pick_next_entity()(第 5538 行),而 pick_next_entity() 直接调用 pick_eevdf()(第 1010 行)——这是 EEVDF 调度器的核心选择函数。

这条调用链的完整路径为:

schedule() → __schedule() → pick_next_task() → pick_next_task_fair()
  → pick_task_fair() → pick_next_entity() → pick_eevdf()

11.1.7 总结与展望

从 O(n) 到 O(1) 到 CFS 到 EEVDF,Linux 调度器的演进呈现一条清晰的脉络:从经验主义到形式化理论,从启发式规则到数学模型,从"差不多公平"到"理论保证的公平"。每一次变革都不是全盘否定前人,而是在已有基础上精炼和升华:

  • O(n) 调度器建立了优先级 + 时间片的基本框架
  • O(1) 调度器引入了 Per-CPU 运行队列和优先级数组
  • CFS 用虚拟时间和红黑树实现了优雅的比例公平
  • EEVDF 在 CFS 的基础上,通过合格性和截止期,实现了比例公平 + 延迟保证

Linux 7.0 的调度器站在这些巨人的肩膀上,为从嵌入式到超级计算机的广泛场景提供了一致、高效、公平的调度服务。


11.2 CFS 完全公平调度器 —— 核心数据结构

虽然 Linux 7.0 已经将 EEVDF 作为默认的公平调度算法,但 EEVDF 并非凭空而来——它完全建立在 CFS 的数据结构基础设施之上。理解 struct rq、struct cfs_rq、struct sched_entity 以及红黑树,是理解 EEVDF 算法的前提。本节将深入剖析这些核心数据结构,展示它们的字段含义、设计考量以及在调度流程中的角色。


11.2.1 struct rq —— Per-CPU 运行队列

1.1 概述

struct rq 是调度器最核心的数据结构,每个 CPU 拥有一个独立的 rq 实例。它不仅包含公平调度器的子队列,还包含实时调度器和截止期调度器的子队列,以及该 CPU 的全局调度状态。其定义位于 kernel/sched/sched.h 第 1124 行。

// kernel/sched/sched.h:1124
struct rq {
    /* ... */
};

1.2 热路径字段(Cache Line 0)

运行队列的第一个 cache line 包含在调度热路径(hot path)上频繁读取的字段。这些字段被 update_sg_lb_stats() 在每次选择任务时大量访问,因此需要保证缓存友好:

// kernel/sched/sched.h:1133-1150
unsigned int        nr_running;           // 运行队列上的任务总数
unsigned int        ttwu_pending;         // 待处理的唤醒操作数
unsigned long       cpu_capacity;         // CPU 算力(用于负载均衡)

struct task_struct __rcu *donor;          // 调度上下文(被选中的任务)
struct task_struct __rcu *curr;           // 执行上下文(实际运行的任务)
struct task_struct  *idle;                // 该 CPU 的 idle 任务

nr_running 是最常被检查的字段之一。当它为零时,说明该 CPU 没有任何可运行任务,CPU 将进入空闲状态。在快速路径优化中,__pick_next_task() 通过比较 rq->nr_running == rq->cfs.h_nr_queued 来判断是否所有任务都属于公平调度类。

donor 和 curr 在开启了 CONFIG_SCHED_PROXY_EXEC(代理执行)时有不同的含义:donor 是调度决策选中的任务(提供调度上下文),而 curr 是实际在 CPU 上执行的任务。代理执行允许一个任务代表另一个阻塞在 mutex 上的任务运行,从而减少优先级反转。

1.3 运行队列锁

// kernel/sched/sched.h:1158
raw_spinlock_t      __lock;               // 运行队列自旋锁

rq->lock 是调度器最重要的锁之一。所有对运行队列的修改操作——入队、出队、选择下一个任务——都需要在持有此锁的情况下进行。这是一个 raw_spinlock_t(硬自旋锁),在持锁期间中断被禁用,保证了调度操作不会被中断处理程序打断。

在 SMP 系统中,这个锁是多核扩展性的关键瓶颈之一。Linux 通过多种手段缓解锁竞争:Per-CPU 队列设计使得大部分操作不需要跨 CPU 同步;锁的持有时间被精心控制在最小范围内;负载均衡操作尽量在释放锁后进行跨 CPU 操作。

1.4 子调度类队列

// kernel/sched/sched.h:1176-1184
struct cfs_rq        cfs;                 // CFS/EEVDF 子队列
struct rt_rq         rt;                  // 实时调度子队列
struct dl_rq         dl;                  // 截止期调度子队列
struct scx_rq        scx;                 // sched_ext 子队列(可选)
struct sched_dl_entity fair_server;       // 公平调度的 DL server

每个调度类在 rq 中都有自己独立的子队列。fair_server 是一个特殊的调度实体,它将公平调度器包装为一个截止期调度类的客户端。这种嵌套设计使得公平调度器可以获得截止期调度器提供的 CPU 带宽保证——即使系统中有实时任务,公平调度器也能通过 fair_server 获得最低限度的 CPU 时间。

1.5 时钟字段

// kernel/sched/sched.h:1213-1219
u64      clock_task ____cacheline_aligned; // 任务时钟(不含 IRQ 时间)
u64      clock_pelt;                       // PELT 时钟(不含 idle 时间)
u64      clock;                            // 运行队列主时钟
unsigned long lost_idle_time;              // 累计丢失的 idle 时间
u64      clock_pelt_idle;                  // PELT idle 累计
u64      clock_idle;                       // idle 状态的时间戳

三种时钟各有用途:

  • clock:单调递增的主时钟,基于 sched_clock(),在 update_rq_clock() 中更新
  • clock_task:减去了中断处理时间,反映任务实际可用的 CPU 时间
  • clock_pelt:减去了 idle 时间,用于 PELT(Per-Entity Load Tracking)负载追踪

这三个时钟的分离是性能计量的基础:clock_task 用于计算 vruntime,clock_pelt 用于计算负载平均值。


11.2.2 struct cfs_rq —— CFS/EEVDF 运行队列

2.1 概述

struct cfs_rq 是公平调度器的核心队列结构,嵌入在 struct rq 中。它包含所有 SCHED_NORMAL 和 SCHED_BATCH 任务的状态,以及用于 EEVDF 算法的虚拟时间追踪信息。定义位于 kernel/sched/sched.h 第 678 行。

// kernel/sched/sched.h:678
struct cfs_rq {
    struct load_weight  load;              // 总负载权重
    unsigned int        nr_queued;         // 队列中的任务数
    unsigned int        h_nr_queued;       // 层级队列数(含子组)
    unsigned int        h_nr_runnable;     // 层级可运行数
    unsigned int        h_nr_idle;         // 层级 idle 数

    s64                 sum_w_vruntime;    // 加权 vruntime 总和
    u64                 sum_weight;        // 权重总和

    u64                 zero_vruntime;     // vruntime 基准点

    struct rb_root_cached tasks_timeline;  // 增强红黑树

    struct sched_entity *curr;             // 当前运行的调度实体
    struct sched_entity *next;             // 下一个 buddy(延迟优化)

    struct sched_avg    avg;               // PELT 负载追踪
    // ...
};

2.2 任务计数

cfs_rq 中有多个任务计数字段,它们的含义各不相同:

  • nr_queued:直接挂在这个 cfs_rq 红黑树上的调度实体数量。在 __enqueue_entity() 中递增,在 __dequeue_entity() 中递减。
  • h_nr_queued:层级(hierarchical)任务数。对于叶子 cfs_rq(直接包含任务的队列),它等于 nr_queued;对于非叶子 cfs_rq(包含任务组的队列),它等于所有子组 h_nr_queued 之和。这个字段在负载均衡中被大量使用。
  • h_nr_runnable:类似于 h_nr_queued,但只计算真正可运行的任务(不包括被延迟出队的任务)。

2.3 EEVDF 虚拟时间追踪

EEVDF 算法需要计算虚拟时间的加权平均值 V(t),这通过两个字段协同实现:

// kernel/sched/sched.h:685-686
s64      sum_w_vruntime;     // Σ(key_i * weight_i)
u64      sum_weight;          // Σ weight_i

其中 key_i = vruntime_i - zero_vruntime,即每个实体的 vruntime 相对于基准点的偏移量。这个设计避免了 u64 乘法溢出的问题。avg_vruntime() 函数(kernel/sched/fair.c:715)使用这些字段计算虚拟时间的加权平均值:

// kernel/sched/fair.c:715-749
u64 avg_vruntime(struct cfs_rq *cfs_rq)
{
    struct sched_entity *curr = cfs_rq->curr;
    long weight = cfs_rq->sum_weight;
    s64 delta = 0;

    if (curr && !curr->on_rq)
        curr = NULL;

    if (weight) {
        s64 runtime = cfs_rq->sum_w_vruntime;

        if (curr) {
            unsigned long w = scale_load_down(curr->load.weight);
            runtime += entity_key(cfs_rq, curr) * w;
            weight += w;
        }

        /* sign flips effective floor / ceiling */
        if (runtime < 0)
            runtime -= (weight - 1);

        delta = div_s64(runtime, weight);
    } else if (curr) {
        delta = curr->vruntime - cfs_rq->zero_vruntime;
    }

    update_zero_vruntime(cfs_rq, delta);

    return cfs_rq->zero_vruntime;
}

这个函数的关键之处在于:当前正在运行的任务(cfs_rq->curr)虽然不在红黑树中(它在运行时不挂入树中),但它的 vruntime 仍然需要参与平均值的计算。因此,avg_vruntime() 将 sum_w_vruntime(红黑树中实体的累计)与 curr 的贡献(临时计算)合并,得到完整的加权平均值。

2.4 增强红黑树

// kernel/sched/sched.h:694
struct rb_root_cached  tasks_timeline;     // 增强红黑树

tasks_timeline 是一棵增强型红黑树(Augmented RB-Tree),它是 EEVDF 调度器最高频访问的数据结构。这棵树的每个节点对应一个调度实体(struct sched_entity),以虚拟截止期(deadline)为排序键值。

"增强"的含义是每个节点除了存储自身的键值外,还存储了以该节点为根的子树中所有节点的 min_vruntime(子树中最小虚拟运行时间)。这种增强使得 pick_eevdf() 可以在搜索过程中安全地剪枝整个子树——如果某个子树中所有节点的 min_vruntime 都大于当前的平均虚拟时间,那么该子树中不存在合格(eligible)的任务,可以直接跳过。

增强回调在 kernel/sched/fair.c 第 884-909 行定义:

// kernel/sched/fair.c:884-909
static inline bool min_vruntime_update(struct sched_entity *se, bool exit)
{
    u64 old_min_vruntime = se->min_vruntime;
    u64 old_min_slice = se->min_slice;
    u64 old_max_slice = se->max_slice;
    struct rb_node *node = &se->run_node;

    se->min_vruntime = se->vruntime;
    __min_vruntime_update(se, node->rb_right);
    __min_vruntime_update(se, node->rb_left);

    se->min_slice = se->slice;
    __min_slice_update(se, node->rb_right);
    __min_slice_update(se, node->rb_left);

    se->max_slice = se->slice;
    __max_slice_update(se, node->rb_right);
    __max_slice_update(se, node->rb_left);

    return se->min_vruntime == old_min_vruntime &&
           se->min_slice == old_min_slice &&
           se->max_slice == old_max_slice;
}

RB_DECLARE_CALLBACKS(static, min_vruntime_cb, struct sched_entity,
                     run_node, min_vruntime, min_vruntime_update);

注意增强信息实际上包含三个维度:min_vruntime(子树最小 vruntime)、min_slice(子树最小时间片)、max_slice(子树最大时间片)。后两者用于 EEVDF 的 slice 保护机制。


11.2.3 struct sched_entity —— 调度实体

3.1 概述

struct sched_entity 是公平调度器对可调度对象(任务或任务组)的抽象。定义在 include/linux/sched.h 第 575 行:

// include/linux/sched.h:575
struct sched_entity {
    /* For load-balancing: */
    struct load_weight      load;          // 任务权重
    struct rb_node          run_node;      // 红黑树节点
    u64                     deadline;      // EEVDF 虚拟截止期
    u64                     min_vruntime;  // 子树最小 vruntime(增强信息)
    u64                     min_slice;     // 子树最小 slice(增强信息)
    u64                     max_slice;     // 子树最大 slice(增强信息)

    struct list_head        group_node;    // 任务组链表节点
    unsigned char           on_rq;         // 是否在运行队列上
    unsigned char           sched_delayed; // 是否被延迟出队
    unsigned char           rel_deadline;  // deadline 是否为相对值
    unsigned char           custom_slice;  // 是否使用自定义 slice

    u64                     exec_start;    // 本次运行开始时间
    u64                     sum_exec_runtime;       // 累计执行时间
    u64                     prev_sum_exec_runtime;  // 上次统计时的累计执行时间
    u64                     vruntime;      // 虚拟运行时间

    s64                     vlag;          // 虚拟滞后量(EEVDF)
    u64                     vprot;         // 保护的截止期(slice 保护)
    u64                     slice;         // 时间配额(时间片)

    u64                     nr_migrations; // 迁移次数

#ifdef CONFIG_FAIR_GROUP_SCHED
    int                     depth;         // 在 cgroup 层级中的深度
    struct sched_entity     *parent;       // 父调度实体
    struct cfs_rq           *cfs_rq;       // 所属的 cfs_rq
    struct cfs_rq           *my_q;         // 拥有的子 cfs_rq(任务组)
    unsigned long           runnable_weight; // 可运行权重缓存
#endif

    struct sched_avg        avg;           // PELT 负载追踪数据
};

3.2 权重(load)

struct load_weight  load;    // 包含 .weight 和 .inv_weight

load.weight 决定了任务在 CPU 时间分配中的份额。它由任务的 nice 值映射而来——nice 值越低,权重越高,分得的 CPU 时间越多。inv_weight 是权重的倒数(乘法逆元),预计算好后用于将除法转换为乘法,避免昂贵的除法运算。

3.3 Nice 值到权重的映射

权重映射表定义在 kernel/sched/core.c 第 10271 行:

// kernel/sched/core.c:10271-10280
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 偏移量为 0(即 nice 0)对应权重 1024(NICE_0_LOAD),这是基准权重
  • 相邻 nice 值之间的权重比约为 1.25(精确值为 1.25^(nice差值))
  • 这意味着每增加一个 nice 值,CPU 份额大约减少 20%;每减少一个 nice 值,份额增加约 25%
  • Nice -20 的权重(88761)是 Nice +19 权重(15)的约 5917 倍

3.4 vruntime —— 虚拟运行时间

u64  vruntime;    // 虚拟运行时间

vruntime 是 CFS/EEVDF 公平性的核心。它在 update_curr() 中按以下公式递增:

// kernel/sched/fair.c:1301
curr->vruntime += calc_delta_fair(delta_exec, curr);

其中 calc_delta_fair() 定义在 kernel/sched/fair.c 第 290 行:

// kernel/sched/fair.c:290-296
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;
}

对于 nice 0 的任务(权重 1024),calc_delta_fair() 直接返回物理时间增量——虚拟时间等于物理时间。对于其他 nice 值,它将物理时间乘以 NICE_0_LOAD / weight,使得高优先级任务的 vruntime 增长更慢(看起来"欠"更多 CPU 时间),低优先级任务的 vruntime 增长更快(看起来"已用"更多 CPU 时间)。

3.5 deadline 和 slice

u64  deadline;    // EEVDF 虚拟截止期
u64  slice;       // 时间配额

在 EEVDF 中,每个任务在入队时会计算一个虚拟截止期:

deadline = vruntime + calc_delta_fair(slice, se)

slice 是任务的时间配额,默认值为 sysctl_sched_base_slice(0.7ms)。虚拟截止期代表"这个任务应该在虚拟时间到达这个值之前被调度"的目标。在红黑树中,任务按 deadline 排序,而非 CFS 时代的 vruntime。

3.6 vlag —— 虚拟滞后量

s64  vlag;    // 虚拟滞后量

vlag 是 EEVDF 的关键创新之一。它记录任务在离开运行队列(睡眠或被抢占)时的"滞后"程度:

vlag = avg_vruntime(cfs_rq) - se->vruntime
  • 正 vlag 表示任务被"欠"了 CPU 时间(正滞后),它下次唤醒时应该被优先对待
  • 负 vlag 表示任务"透支"了 CPU 时间(负滞后),它下次唤醒时应该被延迟对待

update_entity_lag() 在 kernel/sched/fair.c 第 767 行计算并钳制 vlag:

// kernel/sched/fair.c:767-778
static void update_entity_lag(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    u64 max_slice = cfs_rq_max_slice(cfs_rq) + TICK_NSEC;
    s64 vlag, limit;

    WARN_ON_ONCE(!se->on_rq);

    vlag = avg_vruntime(cfs_rq) - se->vruntime;
    limit = calc_delta_fair(max_slice, se);

    se->vlag = clamp(vlag, -limit, limit);
}

vlag 被钳制在 [-limit, +limit] 范围内,其中 limit 基于队列中最大时间片计算。这个钳制防止了 vlag 在极端情况下无限增长或缩小,保证了算法的稳定性。

3.7 红黑树节点

struct rb_node  run_node;         // 红黑树节点

run_node 将调度实体挂载到 cfs_rq->tasks_timeline 红黑树中。在 __enqueue_entity()(kernel/sched/fair.c:914)中,实体被插入树中:

// kernel/sched/fair.c:914-921
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    sum_w_vruntime_add(cfs_rq, se);
    se->min_vruntime = se->vruntime;
    se->min_slice = se->slice;
    rb_add_augmented_cached(&se->run_node, &cfs_rq->tasks_timeline,
                            __entity_less, &min_vruntime_cb);
}

入队时做了三件事: 1. 将实体的加权 vruntime 累加到 cfs_rq->sum_w_vruntime 2. 初始化增强信息(min_vruntime = vruntime,min_slice = slice) 3. 将节点插入红黑树,排序函数为 __entity_less(),比较的是 deadline

// kernel/sched/fair.c:848-851
static inline bool __entity_less(struct rb_node *a, const struct rb_node *b)
{
    return entity_before(__node_2_se(a), __node_2_se(b));
}

// kernel/sched/fair.c:582-590
static inline bool entity_before(const struct sched_entity *a,
                                 const struct sched_entity *b)
{
    return vruntime_cmp(a->deadline, "<", b->deadline);
}

注意:红黑树按 deadline 排序(而非 CFS 时代的 vruntime),这是 EEVDF 与 CFS 的关键区别之一。


11.2.4 update_curr() —— 时间记账的核心

4.1 函数逻辑

update_curr() 是时间记账的枢纽函数,在几乎所有调度操作中都会被调用。定义在 kernel/sched/fair.c 第 1281 行:

// kernel/sched/fair.c:1281-1327
static void update_curr(struct cfs_rq *cfs_rq)
{
    struct sched_entity *curr = cfs_rq->curr;
    struct rq *rq = rq_of(cfs_rq);
    s64 delta_exec;
    bool resched;

    if (unlikely(!curr))
        return;

    delta_exec = update_se(rq, curr);
    if (unlikely(delta_exec <= 0))
        return;

    curr->vruntime += calc_delta_fair(delta_exec, curr);
    resched = update_deadline(cfs_rq, curr);

    if (entity_is_task(curr)) {
        dl_server_update(&rq->fair_server, delta_exec);
    }

    account_cfs_rq_runtime(cfs_rq, delta_exec);

    if (cfs_rq->nr_queued == 1)
        return;

    if (resched || !protect_slice(curr)) {
        resched_curr_lazy(rq);
        clear_buddies(cfs_rq, curr);
    }
}

这个函数的核心流程是:

  1. 计算执行增量:update_se() 返回自上次调用以来流逝的物理时间
  2. 更新 vruntime:将物理时间按权重转换为虚拟时间,累加到 curr->vruntime
  3. 更新截止期:update_deadline() 检查是否消耗完了当前的时间配额,如果是,则计算新的截止期并返回 true(需要重新调度)
  4. 触发重调度:如果需要重调度(resched = true)或者当前任务的 slice 保护已失效(!protect_slice(curr)),则设置 TIF_NEED_RESCHED 标志

4.2 调用时机

update_curr() 在以下场景被调用:

  • 时钟中断:sched_tick() -> task_tick_fair() -> update_curr()
  • 任务入队:enqueue_entity() -> update_curr()
  • 选择下一个任务:pick_task_fair() -> update_curr()
  • 任务出队:put_prev_entity() -> update_curr()

这意味着在每次调度决策之前,当前任务的执行时间都会被精确记账。


11.2.5 时间片计算

5.1 sysctl_sched_base_slice

Linux 7.0 中,时间片的基础参数为:

// kernel/sched/fair.c:79-80
unsigned int sysctl_sched_base_slice           = 700000ULL;  // 0.7ms
static unsigned int normalized_sysctl_sched_base_slice = 700000ULL;

700000 的单位是纳秒,即 0.7 毫秒。这是每个任务在消耗完时间片后触发重新调度的最小时间单位。

5.2 时间片分配

在 EEVDF 中,时间片的计算比 CFS 更简洁。每个任务的时间片直接为 sysctl_sched_base_slice(除非通过 sched_setattr() 设置了自定义 slice)。虚拟截止期的计算在 update_deadline() 中完成:

// kernel/sched/fair.c:1112-1135
static bool update_deadline(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    if (vruntime_cmp(se->vruntime, "<", se->deadline))
        return false;

    if (!se->custom_slice)
        se->slice = sysctl_sched_base_slice;

    /*
     * EEVDF: vd_i = ve_i + r_i / w_i
     */
    se->deadline = se->vruntime + calc_delta_fair(se->slice, se);
    avg_vruntime(cfs_rq);

    return true;
}

当 se->vruntime 还没有达到 se->deadline 时,说明任务还没有消耗完当前时间配额,函数直接返回 false。一旦 vruntime 追上了 deadline,则分配新的 slice 和 deadline,并返回 true 表示需要重新调度。

5.3 Nice 0 到 Nice +10 的 vruntime 差异

为了直观展示权重对虚拟时间的影响,考虑两个场景:

场景一:两个 nice 0 任务竞争 CPU - 两者权重相同(1024),vruntime 增速相同 - 每个任务各获得 50% 的 CPU 时间

场景二:一个 nice 0 任务和一个 nice +10 任务竞争 CPU - nice 0 权重 1024,nice +10 权重 110 - nice 0 任务的 vruntime 增速为 delta * 1024/1024 = delta(正常流速) - nice +10 任务的 vruntime 增速为 delta * 1024/110 ≈ 9.3 * delta(流速快约 9.3 倍) - 由于 nice +10 的 vruntime 增长快,它在红黑树中会迅速向右移动 - 实际 CPU 分配:nice 0 获得约 1024/(1024+110) ≈ 90.3%,nice +10 获得约 9.7%


11.2.6 任务入队与出队

6.1 enqueue_entity()

当任务被唤醒或创建时,它通过 enqueue_entity()(kernel/sched/fair.c:5274)进入运行队列:

// kernel/sched/fair.c:5274-5337
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    bool curr = cfs_rq->curr == se;

    if (curr)
        place_entity(cfs_rq, se, flags);

    update_curr(cfs_rq);

    update_load_avg(cfs_rq, se, UPDATE_TG | DO_ATTACH);
    se_update_runnable(se);
    update_cfs_group(se);

    if (!curr)
        place_entity(cfs_rq, se, flags);

    account_entity_enqueue(cfs_rq, se);

    if (flags & ENQUEUE_MIGRATED)
        se->exec_start = 0;

    check_schedstat_required();
    update_stats_enqueue_fair(cfs_rq, se, flags);
    if (!curr)
        __enqueue_entity(cfs_rq, se);
    se->on_rq = 1;

    if (cfs_rq->nr_queued == 1) {
        check_enqueue_throttle(cfs_rq);
        list_add_leaf_cfs_rq(cfs_rq);
    }
}

入队的关键步骤:

  1. 放置实体(place_entity()):根据 vlag 调整任务的初始 vruntime 位置,计算虚拟截止期
  2. 更新负载(update_load_avg()):更新 PELT 负载追踪数据
  3. 记账入队(account_entity_enqueue()):将任务权重加到 cfs_rq->load 中
  4. 插入红黑树(__enqueue_entity()):将任务插入时间线红黑树

6.2 place_entity() —— EEVDF 的放置策略

place_entity() 是 EEVDF 中最精妙的函数之一,定义在 kernel/sched/fair.c 第 5160 行:

// kernel/sched/fair.c:5160-5265
place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    u64 vslice, vruntime = avg_vruntime(cfs_rq);
    s64 lag = 0;

    if (!se->custom_slice)
        se->slice = sysctl_sched_base_slice;
    vslice = calc_delta_fair(se->slice, se);

    if (sched_feat(PLACE_LAG) && cfs_rq->nr_queued && se->vlag) {
        // ... lag 保存与补偿逻辑(详见下节)...
        lag = se->vlag;
        load = cfs_rq->sum_weight;
        if (curr && curr->on_rq)
            load += scale_load_down(curr->load.weight);

        lag *= load + scale_load_down(se->load.weight);
        if (WARN_ON_ONCE(!load))
            load = 1;
        lag = div_s64(lag, load);
    }

    se->vruntime = vruntime - lag;

    // ...

    se->deadline = se->vruntime + vslice;
}

这个函数做了两件关键的事:

  1. 基于 lag 设置 vruntime:如果任务有保存的 vlag(正滞后),它的 vruntime 会被设置在平均值之前(获得优先调度);如果 vlag 为负,则 vruntime 会被设置在平均值之后(延迟调度)。
  2. 计算虚拟截止期:deadline = vruntime + vslice,其中 vslice 是 slice 的加权版本。

对于新创建的任务(ENQUEUE_INITIAL 标志),PLACE_DEADLINE_INITIAL 特性会将 vslice 减半,让新任务以"半程"状态加入竞争,避免获得过长的截止期。


11.2.7 总结

本节介绍的核心数据结构构成了 Linux 7.0 调度器的骨架:

  • struct rq:Per-CPU 运行队列,包含所有调度类的子队列和 CPU 级调度状态
  • struct cfs_rq:公平调度器的队列,维护红黑树、虚拟时间统计和 PELT 负载追踪
  • struct sched_entity:调度实体,封装任务/任务组的权重、vruntime、deadline、vlag 等调度参数
  • 增强红黑树:按 deadline 排序,增强 min_vruntime/min_slice/max_slice 信息,支持 O(log n) 操作

这些数据结构是 EEVDF 算法的运行载体。在下一节中,我们将深入探讨 EEVDF 算法本身——它如何利用这些数据结构做出调度决策,以及它为什么比 CFS 更好。


11.3 EEVDF 调度器 —— Linux 7.0 新默认

EEVDF(Earliest Eligible Virtual Deadline First,最早合格虚拟截止期优先)是 Linux 7.0 中公平调度的默认算法。它在 CFS 的基础设施之上重新设计了核心选择逻辑,解决了 CFS 困扰社区多年的延迟问题,同时保持了 CFS 的比例公平性保证。本节将深入剖析 EEVDF 的理论基础、核心算法和实现细节。


11.3.1 为什么 CFS 需要被取代

1.1 CFS 的延迟困境

CFS 的调度策略极为简洁:始终选择 vruntime 最小的任务运行。这个策略在比例公平性方面表现出色——在足够长的时间窗口内,每个任务获得的 CPU 时间严格按权重比例分配。但它在延迟方面存在固有的不足。

考虑以下场景:一个系统中有 100 个 nice 0 的 SCHED_NORMAL 任务在竞争 CPU。CFS 的目标延迟(sched_latency)通常为 6ms,这意味着每个任务的时间片为 6ms / 100 = 0.06ms。这在理论上没问题,但当系统负载更高、或者任务有不同的 nice 值时,某些任务的调度延迟可能变得很长且不可预测。

CFS 的核心问题在于:vruntime 的排序只保证了公平性,但不保证延迟。一个任务的 vruntime 很小(因此被优先调度)并不意味着它"紧急"——它可能只是一个刚被唤醒的长任务。而一个真正需要快速响应的短任务,可能因为 vruntime 设置不够极端而被迫等待。

1.2 Buddy 机制的脆弱性

为了缓解延迟问题,CFS 引入了 buddy 机制——手动标记需要"优先照顾"的任务(cfs_rq->next)。当选择下一个任务时,如果 next buddy 是合格的,它会被优先选择。

// kernel/sched/fair.c:1027-1032 (pick_eevdf 中)
if (sched_feat(PICK_BUDDY) &&
    cfs_rq->next && entity_eligible(cfs_rq, cfs_rq->next)) {
    WARN_ON_ONCE(cfs_rq->next->sched_delayed);
    return cfs_rq->next;
}

Buddy 机制本质上是一种启发式,与 CFS "没有启发式"的设计哲学相矛盾。它的工作依赖于在正确的时机设置和清除 buddy 标记,而这些时机往往是经验性的、难以精确判断的。

1.3 Latency Nice 的不可能性

社区长期希望引入"延迟友好"(latency nice)的概念——类似于 CPU nice 值,但控制的是调度延迟而非 CPU 份额。一个"延迟友好"值很高的任务(如音频处理线程)应该获得更短的调度延迟,即使牺牲一些 CPU 份额也在所不惜。

在 CFS 的 vruntime-only 排序模型中,这几乎不可能实现:vruntime 只有一个维度,无法同时表达"公平份额"和"延迟偏好"两个维度的需求。任何对 vruntime 的调整都会同时影响两者。

1.4 理论基础的缺失

CFS 虽然在实践中工作良好,但它缺乏严格的理论基础。它"看起来公平",但在某些边缘情况下(如任务频繁睡眠/唤醒、cgroup 层级嵌套、权重差异巨大),其行为可能偏离预期。更重要的是,CFS 无法给出延迟的理论上界——你能说的只是"通常是 O(毫秒级)",而不是"保证不超过 X 毫秒"。


11.3.2 EEVDF 理论基础

2.1 起源

EEVDF 算法源自 1996 年 Stoica、Abdel-Wahab 和 Hwang 的论文"A New Family of Scheduling Algorithms for Real-Time and Non-Real-Time Systems"。它属于 WF(Weighted Fair Queuing)调度算法族,最初在网络数据包调度领域被广泛研究,后来被引入到 CPU 调度领域。

2.2 核心概念

EEVDF 建立在以下概念之上:

虚拟时间 V(t):所有任务共享的虚拟时钟,定义为所有可运行任务 vruntime 的加权平均值:

         Σ (v_i × w_i)
V(t) = ────────────────
            Σ w_i

滞后量(Lag):任务 i 的滞后量定义为它应得的服务与实际获得的服务之差:

lag_i = w_i × (V(t) - v_i)
  • lag > 0:任务被"欠"了 CPU 时间(正滞后),应该优先调度
  • lag < 0:任务"透支"了 CPU 时间(负滞后),应该等待
  • lag = 0:任务得到了恰好公平的服务

合格性(Eligibility):任务 i 是"合格"的(eligible),当且仅当 lag_i >= 0,即 V(t) >= v_i。只有合格的任务才能被选择运行。

虚拟截止期(Virtual Deadline):任务 i 在虚拟时间 v_i 处提出一个服务请求 r_i(即它请求运行的时间片),其虚拟截止期定义为:

vd_i = v_i + r_i / w_i

调度策略:在所有合格的任务中,选择虚拟截止期最早的那个运行。

2.3 EEVDF 的理论保证

EEVDF 的优雅之处在于它同时保证了两个性质:

  1. 比例公平性(Proportional Fairness):在任意时间窗口 [t1, t2] 内,任务 i 获得的 CPU 时间的比例与其权重 w_i 成正比,误差有界。

  2. 有界延迟(Bounded Latency):任何合格任务的调度延迟不超过一个可以计算的界。具体地,如果任务 i 的请求为 r_i、权重为 w_i,那么从它变为合格到它被调度运行的最大延迟为:

L_max ≈ Σ(r_j / w_j) × w_i / W

其中 W 是所有可运行任务的权重总和。这个延迟界比 CFS 的隐式延迟更加紧凑和可预测。

2.4 与 CFS 的本质区别

CFS 和 EEVDF 都使用虚拟时间来实现公平性,但它们的调度决策逻辑根本不同:

维度 CFS EEVDF
选择标准 vruntime 最小 合格任务中 deadline 最小
排序键 vruntime deadline
合格性检查 无(所有任务都可选) lag >= 0(只有被欠时间的可选)
延迟保证 无显式保证 有理论界
时间片 sched_period × (weight / total_weight) 固定 base_slice(或自定义)
截止期概念 无 vd = vruntime + slice/weight
延迟友好 不支持 原生支持(通过调整 slice)

关键区别在于"合格性"的概念。CFS 认为所有可运行任务都有资格被选择,而 EEVDF 认为只有被"欠"了 CPU 时间的任务才有资格。这个区别使得 EEVDF 能够自然地惩罚过度使用 CPU 的任务(它们的 vruntime 远大于平均值,因此不合格),同时优先照顾被欠时间的任务。


11.3.3 合格性检查:vruntime_eligible()

3.1 函数实现

合格性检查是 EEVDF 区别于 CFS 的第一步。vruntime_eligible() 定义在 kernel/sched/fair.c 第 797 行:

// kernel/sched/fair.c:797-811
static int vruntime_eligible(struct cfs_rq *cfs_rq, u64 vruntime)
{
    struct sched_entity *curr = cfs_rq->curr;
    s64 avg = cfs_rq->sum_w_vruntime;
    long load = cfs_rq->sum_weight;

    if (curr && curr->on_rq) {
        unsigned long weight = scale_load_down(curr->load.weight);

        avg += entity_key(cfs_rq, curr) * weight;
        load += weight;
    }

    return avg >= vruntime_op(vruntime, "-", cfs_rq->zero_vruntime) * load;
}

3.2 数学推导

这个函数实现了合格性判断:lag_i >= 0,即 V >= v_i。让我们展开推导:

根据 lag 的定义:

lag_i = S - s_i = w_i × (V - v_i)

lag_i >= 0 等价于 V >= v_i。

V 是所有可运行任务 vruntime 的加权平均值:

         Σ (v_j - zero) × w_j
V = ───────────────────────── + zero
            Σ w_j

因此 V >= v_i 等价于:

Σ (v_j - zero) × w_j >= (v_i - zero) × Σ w_j

即 avg >= (v_i - zero) × load,这正是代码中的比较。

当前正在运行的任务 curr 虽然不在红黑树中,但它仍然是可运行任务,其贡献需要被加入 avg 和 load。这就是代码中 if (curr && curr->on_rq) 分支的作用。

3.3 封装函数

entity_eligible() 是对 vruntime_eligible() 的简单封装(kernel/sched/fair.c:813):

// kernel/sched/fair.c:813-816
int entity_eligible(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    return vruntime_eligible(cfs_rq, se->vruntime);
}

11.3.4 核心选择函数:pick_eevdf()

4.1 函数签名

pick_eevdf() 是 EEVDF 调度器的核心选择函数,它从红黑树中找出"合格且虚拟截止期最早"的调度实体。定义在 kernel/sched/fair.c 第 1010 行:

// kernel/sched/fair.c:1010-1079
static struct sched_entity *pick_eevdf(struct cfs_rq *cfs_rq, bool protect)
{
    struct rb_node *node = cfs_rq->tasks_timeline.rb_root.rb_node;
    struct sched_entity *se = __pick_first_entity(cfs_rq);
    struct sched_entity *curr = cfs_rq->curr;
    struct sched_entity *best = NULL;

4.2 快速路径:单一实体

    // kernel/sched/fair.c:1021-1022
    if (cfs_rq->nr_queued == 1)
        return curr && curr->on_rq ? curr : se;

如果运行队列中只有一个实体,则跳过所有复杂的合格性检查和树搜索,直接返回它。这是最简单的快速路径,在低负载系统中频繁命中。

4.3 Buddy 快速路径

    // kernel/sched/fair.c:1027-1032
    if (sched_feat(PICK_BUDDY) &&
        cfs_rq->next && entity_eligible(cfs_rq, cfs_rq->next)) {
        WARN_ON_ONCE(cfs_rq->next->sched_delayed);
        return cfs_rq->next;
    }

如果设置了 next buddy(来自 yield_to 或其他特殊操作),且 buddy 是合格的,则直接返回它。这保留了 CFS 时代 buddy 机制的功能,但增加了一个关键的约束:buddy 必须是合格的。在 CFS 中,buddy 可以无条件的优先被选择,这偶尔会导致不公平。EEVDF 通过合格性检查修复了这个问题。

4.4 Slice 保护

    // kernel/sched/fair.c:1034-1038
    if (curr && (!curr->on_rq || !entity_eligible(cfs_rq, curr)))
        curr = NULL;

    if (curr && protect && protect_slice(curr))
        return curr;

Slice 保护是 EEVDF 的重要优化。当一个任务刚被选中运行但还没有消耗完最小时间片时,protect_slice() 返回 true,阻止其他任务抢占它。protect_slice() 检查当前任务的 vruntime 是否还没有达到其保护截止期 vprot:

// kernel/sched/fair.c:980-983
static inline bool protect_slice(struct sched_entity *se)
{
    return vruntime_cmp(se->vruntime, "<", se->vprot);
}

保护截止期 vprot 在 set_protect_slice() 中设置(kernel/sched/fair.c:958):

// kernel/sched/fair.c:958-971
static inline void set_protect_slice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    u64 slice = normalized_sysctl_sched_base_slice;
    u64 vprot = se->deadline;

    if (sched_feat(RUN_TO_PARITY))
        slice = cfs_rq_min_slice(cfs_rq);

    slice = min(slice, se->slice);
    if (slice != se->slice)
        vprot = min_vruntime(vprot, se->vruntime + calc_delta_fair(slice, se));

    se->vprot = vprot;
}

RUN_TO_PARITY 特性使用队列中最短的时间片(而非固定的基础 slice)作为保护期限。这确保了当前任务至少运行到其 vruntime 达到平均虚拟时间(parity),而不会在运行极短时间后被新唤醒的任务抢占。

4.5 左most 快速路径

    // kernel/sched/fair.c:1040-1044
    /* Pick the leftmost entity if it's eligible */
    if (se && entity_eligible(cfs_rq, se)) {
        best = se;
        goto found;
    }

红黑树的最左节点是 deadline 最小的任务。如果它同时也是合格的,那么它就是答案——无需搜索树的其他部分。这个快速路径在大多数正常负载下都会命中,因为 deadline 最小的任务通常也是 vruntime 较小的(因而是合格的)。

4.6 增强树搜索

当最左节点不合格时,需要遍历红黑树来寻找合格的、deadline 最小的任务:

    // kernel/sched/fair.c:1046-1073
    /* Heap search for the EEVD entity */
    while (node) {
        struct rb_node *left = node->rb_left;

        /*
         * Eligible entities in left subtree are always better
         * choices, since they have earlier deadlines.
         */
        if (left && vruntime_eligible(cfs_rq,
                    __node_2_se(left)->min_vruntime)) {
            node = left;
            continue;
        }

        se = __node_2_se(node);

        /*
         * The left subtree either is empty or has no eligible
         * entity, so check the current node since it is the one
         * with earliest deadline that might be eligible.
         */
        if (entity_eligible(cfs_rq, se)) {
            best = se;
            break;
        }

        node = node->rb_right;
    }

这段搜索算法利用了增强红黑树的 min_vruntime 信息来进行剪枝。其逻辑如下:

  1. 对于当前节点,先检查其左子树:如果左子树中存在合格的任务(通过 min_vruntime 判断),则进入左子树继续搜索——因为左子树中的任务 deadline 更小
  2. 如果左子树中没有合格任务,则检查当前节点本身:如果它是合格的,它就是最优解(因为比它 deadline 更小的左子树中没有合格任务)
  3. 如果当前节点也不合格,则进入右子树继续搜索

关键剪枝条件是 vruntime_eligible(cfs_rq, __node_2_se(left)->min_vruntime):如果左子树中最小的 vruntime 都不合格,那么左子树中所有任务都不合格(因为它们 的 vruntime 都 >= min_vruntime)。这使得整个左子树可以被安全地剪枝。

4.7 最终选择

    // kernel/sched/fair.c:1074-1078
found:
    if (!best || (curr && entity_before(curr, best)))
        best = curr;

    return best;

找到树中的最优解后,还需要与当前正在运行的任务 curr 比较。如果 curr 的 deadline 比 best 更早,则选择 curr 继续运行(因为 curr 虽然在树外,但其 deadline 可能仍然是最小的)。

4.8 搜索复杂度分析

在正常负载下(大部分任务都是合格的),pick_eevdf() 的时间复杂度为 O(1)——左most 快速路径直接命中。在最坏情况下(大量不合格任务),搜索退化为 O(log n),因为增强信息允许剪枝。这与 CFS 的 O(1) 左most 选择(基于缓存的 rb_leftmost)相比略有增加,但实际测量表明这个额外开销可以忽略不计。


11.3.5 虚拟截止期更新:update_deadline()

5.1 函数实现

update_deadline() 在每次 update_curr() 中被调用,检查任务是否消耗完了当前时间配额。定义在 kernel/sched/fair.c 第 1112 行:

// kernel/sched/fair.c:1112-1135
static bool update_deadline(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    if (vruntime_cmp(se->vruntime, "<", se->deadline))
        return false;

    /*
     * For EEVDF the virtual time slope is determined by w_i (iow.
     * nice) while the request time r_i is determined by
     * sysctl_sched_base_slice.
     */
    if (!se->custom_slice)
        se->slice = sysctl_sched_base_slice;

    /*
     * EEVDF: vd_i = ve_i + r_i / w_i
     */
    se->deadline = se->vruntime + calc_delta_fair(se->slice, se);
    avg_vruntime(cfs_rq);

    /*
     * The task has consumed its request, reschedule.
     */
    return true;
}

5.2 截止期计算

当任务的 vruntime 追上或超过了当前的 deadline 时,说明它已经消耗完了当前请求的时间配额。此时需要分配新的截止期:

vd_new = vruntime_current + slice / weight

这里的 slice / weight 通过 calc_delta_fair() 计算,它将物理时间片转换为虚拟时间。对于权重为 NICE_0_LOAD(1024)的任务,虚拟截止期增量就等于物理 slice(0.7ms);对于更高优先级的任务,虚拟增量更小(因此截止期更近,会被更快地重新调度);对于更低优先级的任务,虚拟增量更大(截止期更远,等待时间更长)。

函数返回 true 表示任务消耗完了配额,触发重调度;返回 false 表示任务还有剩余配额。

5.3 自定义 Slice

se->custom_slice 标志允许任务拥有不同于默认值的时间片。这是实现"延迟友好"(latency nice)特性的基础:一个延迟敏感的任务可以设置较短的 slice,从而获得更频繁的调度机会和更短的截止期。延迟不敏感的任务可以设置较长的 slice,减少调度开销。


11.3.6 虚拟滞后量:update_entity_lag()

6.1 函数实现

update_entity_lag() 在任务即将离开运行队列(被出队)时计算并保存其虚拟滞后量。定义在 kernel/sched/fair.c 第 767 行:

// kernel/sched/fair.c:767-778
static void update_entity_lag(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    u64 max_slice = cfs_rq_max_slice(cfs_rq) + TICK_NSEC;
    s64 vlag, limit;

    WARN_ON_ONCE(!se->on_rq);

    vlag = avg_vruntime(cfs_rq) - se->vruntime;
    limit = calc_delta_fair(max_slice, se);

    se->vlag = clamp(vlag, -limit, limit);
}

6.2 vlag 的含义

vlag = V(t) - v_i
  • 正 vlag(V > v_i):任务在离开队列时被"欠"了 CPU 时间。它获得的实际服务少于公平份额。下次唤醒时,它应该被优待——被放置在队列的前面。
  • 负 vlag(V < v_i):任务在离开队列时"透支"了 CPU 时间。它获得的实际服务多于公平份额。下次唤醒时,它应该被惩罚——被放置在队列的后面。
  • vlag = 0:任务获得了恰好公平的服务。

6.3 钳制(Clamping)

vlag 被钳制在 [-limit, +limit] 范围内:

se->vlag = clamp(vlag, -limit, limit);

其中 limit = calc_delta_fair(max_slice, se),max_slice 是队列中最大时间片加一个 tick。这个钳制是必要的,原因有二:

  1. 数值稳定性:不加钳制的话,一个长时间睡眠的任务可能累积极大的正 vlag,导致唤醒时被放置得过于靠前,反而"抢"了其他任务的 CPU 时间。
  2. 理论保证:EEVDF 论文中证明了在稳态系统中,lag 的绝对值有界。钳制是这一理论结果的工程实现。

11.3.7 放置策略:place_entity()

7.1 Lag 保存与补偿

place_entity() 是任务入队时确定其初始 vruntime 和 deadline 的函数。它在 kernel/sched/fair.c 第 5160 行定义,其中最精妙的部分是 lag 保存与补偿逻辑:

// kernel/sched/fair.c:5160-5265
place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    u64 vslice, vruntime = avg_vruntime(cfs_rq);
    s64 lag = 0;

    if (!se->custom_slice)
        se->slice = sysctl_sched_base_slice;
    vslice = calc_delta_fair(se->slice, se);

    if (sched_feat(PLACE_LAG) && cfs_rq->nr_queued && se->vlag) {
        struct sched_entity *curr = cfs_rq->curr;
        unsigned long load;

        lag = se->vlag;

        load = cfs_rq->sum_weight;
        if (curr && curr->on_rq)
            load += scale_load_down(curr->load.weight);

        lag *= load + scale_load_down(se->load.weight);
        if (WARN_ON_ONCE(!load))
            load = 1;
        lag = div_s64(lag, load);
    }

    se->vruntime = vruntime - lag;

    if (se->rel_deadline) {
        se->deadline += se->vruntime;
        se->rel_deadline = 0;
        return;
    }

    if (sched_feat(PLACE_DEADLINE_INITIAL) && (flags & ENQUEUE_INITIAL))
        vslice /= 2;

    se->deadline = se->vruntime + vslice;
}

7.2 Lag 补偿的数学原理

问题在于:当我们把一个带有 vlag 的任务重新加入运行队列时,虚拟时间的加权平均值 V 会发生变化。具体地,加入一个具有正 vlag 的任务会使 V 减小(因为平均值向该任务的小 vruntime 偏移),这反过来会减小该任务的有效 lag。

为了补偿这个效应,代码对 vlag 做了"膨胀"处理:

lag_adjusted = vlag × (W + w_i) / W

其中 W 是队列中已有的总权重,w_i 是新加入任务的权重。这个膨胀因子使得在加入队列后,考虑 V 的偏移,任务的"有效 vlag"恰好等于保存时的值。

这个数学推导在代码注释中有详细的步骤:

加入前:V = Σ(w_j × v_j) / W

加入后:V' = (Σ(w_j × v_j) + w_i × v_i) / (W + w_i)
          = (W × V + w_i × (V - vlag_i)) / (W + w_i)
          = V - w_i × vlag_i / (W + w_i)

有效 vlag:vlag'_i = V' - v_i
                = V - w_i × vlag_i / (W + w_i) - (V - vlag_i)
                = vlag_i - w_i × vlag_i / (W + w_i)
                = vlag_i × W / (W + w_i)

要使 vlag'_i = 目标值 vlag_target:
vlag_i = vlag_target × (W + w_i) / W

代码中的 lag *= load + scale_load_down(se->load.weight); lag = div_s64(lag, load); 正是实现了这个膨胀。

7.3 vruntime 设置

经过 lag 补偿后,任务的 vruntime 被设置为:

se->vruntime = vruntime - lag;    // vruntime = V - lag_adjusted
  • 正 lag(被欠 CPU):vruntime < V,任务被放在队列"前面"
  • 负 lag(透支 CPU):vruntime > V,任务被放在队列"后面"

7.4 截止期设置

se->deadline = se->vruntime + vslice;

新任务的截止期从其 vruntime 开始计算。对于初始创建的任务(ENQUEUE_INITIAL 标志),PLACE_DEADLINE_INITIAL 特性将 vslice 减半:

if (sched_feat(PLACE_DEADLINE_INITIAL) && (flags & ENQUEUE_INITIAL))
    vslice /= 2;

这背后的直觉是:现有任务平均已经消耗了半个时间片,因此新任务以"半程"状态加入竞争,其初始截止期比完整 slice 更近,从而获得更快的首次调度。

7.5 迁移时的截止期保存

PLACE_REL_DEADLINE 特性(定义在 kernel/sched/features.h:15)在任务迁移时保持其相对虚拟截止期。当任务从 CPU A 迁移到 CPU B 时,其 vruntime 会根据两个运行队列的 min_vruntime 进行调整,而相对截止期(deadline - vruntime)被保持不变,通过 rel_deadline 标志实现。


11.3.8 延迟出队:DELAY_DEQUEUE

8.1 动机

在 EEVDF 中,当一个任务的 vruntime 超过了平均虚拟时间(lag < 0,不合格),传统的做法是将其从运行队列中出队。但这样做有一个问题:当系统负载较低时,不合格任务被出队后,队列可能变为空,CPU 进入 idle。而不合格任务可能在很短时间后就会重新变为合格(因为 V 在推进)。

8.2 实现

DELAY_DEQUEUE 特性(kernel/sched/features.h:58)解决了这个问题。当一个不合格任务本应被出队时,调度器不立即将其从红黑树中移除,而是标记为 sched_delayed:

// kernel/sched/fair.c:5538-5551
pick_next_entity(struct rq *rq, struct cfs_rq *cfs_rq, bool protect)
{
    struct sched_entity *se;

    se = pick_eevdf(cfs_rq, protect);
    if (se->sched_delayed) {
        dequeue_entities(rq, se, DEQUEUE_SLEEP | DEQUEUE_DELAYED);
        return NULL;
    }
    return se;
}

当 pick_eevdf() 选中的是一个延迟任务时,它才被真正出队。在延迟期间,任务仍然在红黑树中参与合格性判断,随着 V 的推进(其他任务运行导致 V 增长),它的 lag 可能从负变为零甚至正——此时它重新变为合格,可以被正常调度。

DELAY_ZERO 特性进一步将出队时的 vlag 钳制为零,避免负 lag 在出队时被保存并影响下次唤醒。


11.3.9 唤醒抢占:wakeup_preempt_fair()

9.1 抢占决策

当一个任务被唤醒时,调度器需要决定是否抢占当前正在运行的任务。wakeup_preempt_fair() 定义在 kernel/sched/fair.c 第 8803 行:

// kernel/sched/fair.c:8803
static void wakeup_preempt_fair(struct rq *rq, struct task_struct *p, int wake_flags)
{
    // ...
    update_curr(cfs_rq);

    if (sched_feat(PREEMPT_SHORT) && (pse->slice < se->slice)) {
        preempt_action = PREEMPT_WAKEUP_SHORT;
        goto pick;
    }
    // ...
}

9.2 PREEMPT_SHORT

PREEMPT_SHORT 特性(kernel/sched/features.h:25)允许具有更短 slice 的唤醒任务取消当前任务的 slice 保护。这是合理的:如果一个短任务(如处理鼠标事件的工作线程,slice 很短)唤醒了,而当前任务有很长的 slice(如视频编码器),允许短任务立即抢占可以显著改善交互响应。

9.3 RUN_TO_PARITY

RUN_TO_PARITY 特性(kernel/sched/features.h:20)是 slice 保护的默认策略。它阻止新唤醒的任务抢占当前任务,直到当前任务的 vruntime 达到了平均虚拟时间(parity point)或消耗完了 slice。

RUN_TO_PARITY 和 PREEMPT_SHORT 协同工作:默认情况下保护当前任务不被抢占(RUN_TO_PARITY),但如果唤醒任务的 slice 比当前任务的更短,则取消保护(PREEMPT_SHORT)。这种组合在吞吐量和延迟之间取得了良好的平衡。


11.3.10 完整调度流程示例

让我们通过一个具体的例子来追踪 EEVDF 的完整调度流程。

10.1 初始状态

假设系统中有三个 SCHED_NORMAL 任务,nice 值均为 0(权重 1024):

任务 vruntime deadline 状态
A 100 170 正在运行
B 110 180 在红黑树中
C 120 190 在红黑树中

V = (100 + 110 + 120) / 3 = 110(简化计算,实际使用加权平均)

10.2 任务 A 运行

任务 A 运行了一段时间,其 vruntime 从 100 增长到 115。update_curr() 调用 update_deadline():

  • vruntime (115) < deadline (170),不需要重新分配截止期
  • 但 protect_slice() 检查:vruntime (115) < vprot(约 100 + 0.7ms_虚拟 ≈ 107.5),115 > 107.5,保护已失效
  • 设置 TIF_NEED_RESCHED 标志

10.3 重新调度

__schedule() 被调用,进入 pick_next_task_fair() -> pick_task_fair() -> pick_next_entity() -> pick_eevdf():

  1. 检查合格性: - V ≈ (115 + 110 + 120) / 3 ≈ 115 - B 的 vlag = 115 - 110 = 5 > 0,合格 - C 的 vlag = 115 - 120 = -5 < 0,不合格

  2. 最左节点是 B(deadline 180),且 B 合格 -> 选择 B

  3. 任务 B 被选中运行

10.4 任务 B 运行

任务 B 运行,vruntime 从 110 增长。当 vruntime 达到 180 时(deadline),update_deadline() 分配新的截止期:

  • 新 deadline = 180 + calc_delta_fair(0.7ms, B) = 180 + 0.7ms_virtual
  • 返回 true,触发重调度

10.5 再次调度

此时所有任务可能都已合格,pick_eevdf() 选择 deadline 最小的那个。


11.3.11 CFS 与 EEVDF 的完整对比

特性 CFS EEVDF
选择标准 最小 vruntime 合格任务中最小 deadline
红黑树排序键 vruntime deadline
合格性检查 无(所有任务可选) V >= v_i(lag >= 0)
虚拟截止期 不存在 vd = vruntime + slice/weight
时间片 动态(sched_period × weight/W) 固定 base_slice 或自定义
滞后量 不追踪 vlag 在睡眠/唤醒间保存
延迟保证 依赖启发式 buddy 算法内在保证
Latency Nice 不支持 通过自定义 slice 原生支持
Slice 保护 无(依赖 min_granularity) RUN_TO_PARITY / vprot
延迟出队 不支持 DELAY_DEQUEUE
理论基础 非正式("模拟理想多任务") 形式化(EEVDF 论文)
入队放置 vruntime = min_vruntime vruntime = V - lag_adjusted

11.3.12 sched_features 控制

EEVDF 的行为可以通过 /sys/kernel/debug/sched/features(或 /sys/kernel/debug/sched/feature)在运行时调整。主要的 EEVDF 相关特性定义在 kernel/sched/features.h:

特性 默认 说明
PLACE_LAG 开启 保存和补偿 lag(EEVDF 核心)
PLACE_DEADLINE_INITIAL 开启 新任务半 slice 加入竞争
PLACE_REL_DEADLINE 开启 迁移时保持相对截止期
RUN_TO_PARITY 开启 保护当前任务运行到 parity
PREEMPT_SHORT 开启 允许短 slice 任务抢占
PICK_BUDDY 开启 允许 next buddy 优先
DELAY_DEQUEUE 开启 延迟不合格任务的出队
DELAY_ZERO 开启 延迟出队时钳制 vlag 为零
WAKEUP_PREEMPTION 开启 允许唤醒时抢占

管理员可以通过以下命令查看和修改特性:

# 查看所有特性状态
cat /sys/kernel/debug/sched/features

# 关闭 RUN_TO_PARITY(可能导致更多抢占)
echo NO_RUN_TO_PARITY > /sys/kernel/debug/sched/features

# 重新开启
echo RUN_TO_PARITY > /sys/kernel/debug/sched/features

11.3.13 总结

EEVDF 代表了 Linux 公平调度器二十年来最重要的算法变革。它不是对 CFS 的否定,而是升华:在保留了 CFS 的虚拟时间、红黑树、Per-CPU 运行队列等优秀基础设施的同时,用基于理论的选择算法取代了基于启发式的决策逻辑。

EEVDF 的核心创新可以概括为三点:

  1. 合格性机制:只有被"欠"了 CPU 时间的任务才能被选择,自然地惩罚了过度使用 CPU 的任务
  2. 虚拟截止期:每个任务有一个明确的截止期,提供了可预测的延迟边界
  3. Lag 保存:任务在睡眠/唤醒周期之间保持其滞后状态,实现了跨周期的公平性

这三个创新共同解决了 CFS 的延迟问题,并为 latency nice 等新特性奠定了基础。Linux 7.0 选择 EEVDF 作为默认,标志着 Linux 调度器从"经验上公平"走向了"理论上公平"的新时代。


11.4 实时调度 -- SCHED_FIFO 与 SCHED_RR

Linux 内核不仅需要为普通桌面和服务器工作负载提供公平的 CPU 分配,还需要为对时间敏感的任务提供确定性保证。实时调度类(RT sched class)正是为此而设计的。它确保实时任务能够在可预测的时间范围内获得 CPU 资源,是工业控制、音频处理、通信基站等场景的核心支撑机制。

11.4.1 实时调度的基本原理

实时操作系统(RTOS)理论将实时性分为两类:

硬实时(Hard Real-Time):任务必须在绝对截止时间内完成,否则会导致系统故障甚至灾难性后果。例如汽车刹车控制系统、心脏起搏器。标准 Linux 内核不提供硬实时保证,但通过 PREEMPT_RT 补丁集可以接近这一目标。

软实时(Soft Real-Time):系统尽最大努力满足截止时间要求,偶尔的超时是可以接受的。例如视频播放、音频处理——偶尔丢帧令人不快但不致命。Linux 原生支持的 SCHED_FIFO、SCHED_RR 和 SCHED_DEADLINE 属于这一范畴。

实时调度的核心原则是优先级高于一切:任何时刻,系统中优先级最高的可运行实时任务必须获得 CPU。这意味着实时任务总是会抢占普通(SCHED_NORMAL/SCHED_FAIR)任务的执行。调度类的优先级层次从高到低依次为:

SCHED_DEADLINE > SCHED_FIFO / SCHED_RR > SCHED_NORMAL / SCHED_FAIR > SCHED_IDLE

11.4.2 实时运行队列:struct rt_rq

每个 CPU 的运行队列(struct rq)中都包含一个专用的实时运行队列 rt_rq,其定义位于 kernel/sched/sched.h(第 831 行):

// kernel/sched/sched.h, line 831
struct rt_rq {
    struct rt_prio_array  active;          // 优先级位数组 + 链表
    unsigned int          rt_nr_running;   // 队列中实时任务数
    unsigned int          rr_nr_running;   // SCHED_RR 任务数
    struct {
        int  curr;   // 当前最高优先级
        int  next;   // 次高优先级
    } highest_prio;
    bool                  overloaded;      // 是否过载(多个 RT 任务)
    struct plist_head     pushable_tasks;  // 可推送任务列表(用于负载均衡)
    int                   rt_queued;       // 队列是否有内容
    // ... 组调度相关字段省略
};

rt_prio_array 是实时调度的核心数据结构,它由一个 100 位的位图(bitmap[100])和一个包含 100 个 list_head 的数组(queue[100])组成。每个优先级(0-99)对应位图中的一位和链表数组中的一条链表。初始化代码位于 kernel/sched/rt.c(第 70 行):

// kernel/sched/rt.c, line 70
struct rt_prio_array *array;
int i;

array = &rt_rq->active;
for (i = 0; i < MAX_RT_PRIO; i++) {
    INIT_LIST_HEAD(array->queue + i);
    __clear_bit(i, array->bitmap);
}
/* delimiter for bitsearch: */
__set_bit(MAX_RT_PRIO, array->bitmap);

位图的最后一个位置 MAX_RT_PRIO(值为 100)被置位,作为 sched_find_first_bit() 搜索的哨兵——当所有优先级的链表都为空时,搜索会在哨兵处停止,保证不会越界。

这种设计的精妙之处在于它实现了 O(1) 复杂度的优先级查找:通过 sched_find_first_bit(array->bitmap) 直接找到最高优先级的非空链表,时间复杂度与任务数量无关。

11.4.3 实时调度实体:struct sched_rt_entity

每个任务的实时调度信息存储在 struct sched_rt_entity 中,嵌入在 task_struct 内部。定义位于 include/linux/sched.h(第 623 行):

// include/linux/sched.h, line 623
struct sched_rt_entity {
    struct list_head    run_list;       // 同优先级链表中的节点
    unsigned long       timeout;        // 看门狗超时计数
    unsigned long       watchdog_stamp; // 看门狗时间戳
    unsigned int        time_slice;     // 剩余时间片(SCHED_RR 使用)
    unsigned short      on_rq;          // 是否在运行队列上
    unsigned short      on_list;        // 是否在链表上
    struct sched_rt_entity  *back;      // 链表反向指针
    // ... 组调度相关字段省略
};

run_list 将同一优先级的任务串联在一起。time_slice 仅对 SCHED_RR 有意义——SCHED_FIFO 任务不使用时间片。on_rq 和 on_list 是两个不同的状态标志:on_rq 表示实体在逻辑上属于运行队列,on_list 表示实体已实际插入到优先级链表中。在组调度场景下,两者可能不同步。

11.4.4 SCHED_FIFO:先入先出策略

SCHED_FIFO(Scheduled First-In-First-Out)是最简单的实时调度策略,其行为规则如下:

  1. 无时间片:SCHED_FIFO 任务一旦获得 CPU,就可以无限期地运行下去。
  2. 让出条件:只有以下情况才会让出 CPU: - 任务主动调用 sched_yield() 或阻塞(如等待 I/O、睡眠) - 一个更高优先级的实时任务变为可运行状态
  3. 同优先级顺序:同优先级的 SCHED_FIFO 任务按到达顺序排列。新唤醒的 SCHED_FIFO 任务排在同优先级链表的尾部,不会抢占正在运行的同优先级任务。

这意味着一个优先级为 99 的 SCHED_FIFO 任务如果设计有缺陷(例如死循环),将永久霸占 CPU,导致系统中所有其他任务(包括内核线程)都无法运行。

选择下一个任务的函数 _pick_next_task_rt() 位于 kernel/sched/rt.c(第 1689 行):

// kernel/sched/rt.c, line 1689
static struct task_struct *_pick_next_task_rt(struct rq *rq)
{
    struct sched_rt_entity *rt_se;
    struct rt_rq *rt_rq = &rq->rt;

    do {
        rt_se = pick_next_rt_entity(rt_rq);
        if (unlikely(!rt_se))
            return NULL;
        rt_rq = group_rt_rq(rt_se);
    } while (rt_rq);

    return rt_task_of(rt_se);
}

其中 pick_next_rt_entity()(第 1673 行)通过位图找到最高优先级的非空链表,然后取链表头部:

// kernel/sched/rt.c, line 1673
struct rt_prio_array *array = &rt_rq->active;
struct sched_rt_entity *next = NULL;
struct list_head *queue;
int idx;

idx = sched_find_first_bit(array->bitmap);  // O(1) 找最高优先级
BUG_ON(idx >= MAX_RT_PRIO);

queue = array->queue + idx;
next = list_entry(queue->next, struct sched_rt_entity, run_list);
return next;

整个选路过程是 O(1) 的——不依赖队列中的任务数量。

11.4.5 SCHED_RR:时间片轮转策略

SCHED_RR(Scheduled Round-Robin)是 SCHED_FIFO 的轮转变体,唯一的区别在于同优先级任务之间会按时间片轮流执行。

默认时间片定义在 kernel/sched/rt.c(第 10 行):

// kernel/sched/rt.c, line 10
int sched_rr_timeslice = RR_TIMESLICE;

其中 RR_TIMESLICE 通常等于 (100 * HZ / 1000),即在 HZ=1000 的系统上为 100ms,在 HZ=250 的系统上为 250ms。该值可通过 /proc/sys/kernel/sched_rr_timeslice_ms 进行动态调整(第 27 行):

// kernel/sched/rt.c, line 27
static int sysctl_sched_rr_timeslice = (MSEC_PER_SEC * RR_TIMESLICE) / HZ;

时间片管理在时钟节拍函数 task_tick_rt() 中实现(第 2529 行):

// kernel/sched/rt.c, line 2529
static void task_tick_rt(struct rq *rq, struct task_struct *p, int queued)
{
    struct sched_rt_entity *rt_se = &p->rt;

    update_curr_rt(rq);
    update_rt_rq_load_avg(rq_clock_pelt(rq), rq, 1);
    watchdog(rq, p);

    /* RR tasks need a special form of time-slice management.
     * FIFO tasks have no timeslices. */
    if (p->policy != SCHED_RR)
        return;

    if (--p->rt.time_slice)
        return;

    p->rt.time_slice = sched_rr_timeslice;  // 重新填充时间片

    /* 如果同优先级还有其他任务,重新排队到尾部 */
    if (rt_se->run_list.prev != rt_se->run_list.next) {
        requeue_task_rt(rq, p, 0);  // 0 = 放到尾部
        resched_curr(rq);           // 请求重新调度
        return;
    }
}

当时间片耗尽时,任务被移到同优先级链表的末尾(requeue_task_rt,第 1480 行),并触发重新调度。如果该优先级只有一个任务,则继续运行。

11.4.6 实时优先级体系

Linux 实时优先级的范围是 0-99,方向与 nice 值相反:

优先级值 含义
0 非实时任务(对应 SCHED_NORMAL,映射到 nice -20 到 19)
1-99 实时优先级,99 最高

存储在 task_struct 的 rt_priority 字段(include/linux/sched.h 第 869 行):

// include/linux/sched.h, line 869
unsigned int  rt_priority;

注意这里的"方向":rt_priority 的值越大,优先级越高。但内核内部使用的 prio 字段方向相反——值越小优先级越高。转换关系为:

prio = MAX_RT_PRIO - 1 - rt_priority

所以 rt_priority = 99 对应 prio = 0(最高优先级),rt_priority = 1 对应 prio = 98。

关键点:任何 rt_priority >= 1 的任务都优先于所有 SCHED_NORMAL/SCHED_FAIR 任务。即使 rt_priority = 1(最低实时优先级),也会抢占 nice = -20 的普通任务。

11.4.7 入队与出队

实时任务的入队操作 enqueue_task_rt() 位于 kernel/sched/rt.c(第 1431 行):

// kernel/sched/rt.c, line 1431
enqueue_task_rt(struct rq *rq, struct task_struct *p, int flags)
{
    struct sched_rt_entity *rt_se = &p->rt;

    if (flags & ENQUEUE_WAKEUP)
        rt_se->timeout = 0;

    check_schedstat_required();
    update_stats_wait_start_rt(rt_rq_of_se(rt_se), rt_se);

    enqueue_rt_entity(rt_se, flags);

    if (task_is_blocked(p))
        return;

    if (!task_current(rq, p) && p->nr_cpus_allowed > 1)
        enqueue_pushable_task(rq, p);  // 多 CPU 可迁移任务加入推送列表
}

入队过程将 sched_rt_entity 插入对应优先级的链表末尾。如果任务允许在多个 CPU 上运行,还会将其添加到 pushable_tasks 列表中,以便其他 CPU 拉取。

11.4.8 RT 节流(RT Throttling)

实时任务的无限制运行可能导致普通任务完全饿死。为防止这种情况,Linux 引入了 RT 节流机制。

节流参数通过两个 sysctl 接口控制(第 18-24 行):

// kernel/sched/rt.c, line 18
int sysctl_sched_rt_period = 1000000;    // 周期:1,000,000 微秒 = 1 秒

// kernel/sched/rt.c, line 24
int sysctl_sched_rt_runtime = 950000;    // RT 可用时间:950,000 微秒 = 0.95 秒

对应用户空间接口: - /proc/sys/kernel/sched_rt_period_us:节流周期(微秒) - /proc/sys/kernel/sched_rt_runtime_us:周期内 RT 任务最大运行时间(微秒)

默认配置允许 RT 任务在每个 1 秒周期内最多运行 950ms,保留 50ms 给普通任务。将 sched_rt_runtime_us 设为 -1 可完全禁用节流。

节流检查在 sched_rt_runtime_exceeded() 中实现(第 863 行):

// kernel/sched/rt.c, line 863
static int sched_rt_runtime_exceeded(struct rt_rq *rt_rq)
{
    u64 runtime = sched_rt_runtime(rt_rq);

    if (rt_rq->rt_throttled)
        return rt_rq_throttled(rt_rq);

    if (runtime >= sched_rt_period(rt_rq))
        return 0;    // runtime >= period,不节流

    balance_runtime(rt_rq);
    runtime = sched_rt_runtime(rt_rq);
    if (runtime == RUNTIME_INF)
        return 0;    // 无限制模式

    if (rt_rq->rt_time > runtime) {
        struct rt_bandwidth *rt_b = sched_rt_bandwidth(rt_rq);
        if (likely(rt_b->rt_runtime)) {
            rt_rq->rt_throttled = 1;
            printk_deferred_once("sched: RT throttling activated\n");
        }
        // ...
    }
    return 0;
}

当 RT 任务的累计运行时间超过配额时,rt_throttled 被置 1,该 rt_rq 上的所有 RT 任务不再被调度选择。在下一个周期边界,配额会被重新填充,节流解除。

在 update_curr_rt() 中(第 974 行),每次时钟更新都会累积运行时间并检查是否超过限额:

// kernel/sched/rt.c, line 974
static void update_curr_rt(struct rq *rq)
{
    struct task_struct *donor = rq->donor;
    s64 delta_exec;

    if (donor->sched_class != &rt_sched_class)
        return;

    delta_exec = update_curr_common(rq);
    if (unlikely(delta_exec <= 0))
        return;

    // 累积 rt_time 并检查是否超限
    for_each_sched_rt_entity(rt_se) {
        struct rt_rq *rt_rq = rt_rq_of_se(rt_se);
        // ... 累积 rt_rq->rt_time
        exceeded = sched_rt_runtime_exceeded(rt_rq);
        if (exceeded)
            resched_curr(rq);  // 触发重新调度
    }
}

11.4.9 RT 负载均衡

在 SMP 系统中,实时任务的负载均衡对保证实时性至关重要。核心问题是:当高优先级 RT 任务在低优先级 CPU 上等待,而另一个 CPU 上运行着低优先级 RT 任务时,需要尽快迁移。

过载检测:当某个 CPU 上有多个可运行的 RT 任务时,该 CPU 被标记为"过载"(第 344 行):

// kernel/sched/rt.c, line 344
static inline void rt_set_overload(struct rq *rq)
{
    if (!rq->online)
        return;
    cpumask_set_cpu(rq->cpu, rq->rd->rto_mask);
    // 设置内存屏障确保 mask 先于 count 可见
    smp_wmb();
    atomic_inc(&rq->rd->rto_count);
}

推送(Push)操作:当一个 CPU 上有多个 RT 任务时,将低优先级任务推送到其他 CPU。push_rt_tasks() 函数(第 2066 行)循环调用 push_rt_task() 直到没有更多可推送任务:

// kernel/sched/rt.c, line 2066
static void push_rt_tasks(struct rq *rq)
{
    /* push_rt_task will return true if it moved an RT */
    while (push_rt_task(rq, false))
        ;
}

push_rt_task()(第 1948 行)选择 pushable_tasks 中优先级最高的任务,然后通过 find_lock_lowest_rq() 寻找优先级最低的目标 CPU:

// kernel/sched/rt.c, line 1948
static int push_rt_task(struct rq *rq, bool pull)
{
    struct task_struct *next_task;
    struct rq *lowest_rq;
    int ret = 0;

    if (!rq->rt.overloaded)
        return 0;

    next_task = pick_next_pushable_task(rq);
    if (!next_task)
        return 0;

retry:
    /* 如果推送任务的优先级比当前运行任务还高,直接重调度当前 */
    if (unlikely(next_task->prio < rq->donor->prio)) {
        resched_curr(rq);
        return 0;
    }
    // ... 寻找最低优先级 rq 并迁移
}

拉取(Pull)操作:当一个 CPU 即将空闲或调度新任务时,从其他过载 CPU 拉取 RT 任务。pull_rt_task() 位于第 2249 行:

// kernel/sched/rt.c, line 2249
static void pull_rt_task(struct rq *this_rq)
{
    int this_cpu = this_rq->cpu, cpu;
    bool resched = false;
    struct task_struct *p, *push_task;
    struct rq *src_rq;
    int rt_overload_count = rt_overloaded(this_rq);

    if (likely(!rt_overload_count))
        return;  // 没有过载 CPU,无需拉取

    smp_rmb();  // 匹配 rt_set_overload 中的屏障

    /* 如果只有自己过载,也不需要拉取 */
    if (rt_overload_count == 1 &&
        cpumask_test_cpu(this_rq->cpu, this_rq->rd->rto_mask))
        return;

    for_each_cpu(cpu, this_rq->rd->rto_mask) {
        if (this_cpu == cpu)
            continue;
        src_rq = cpu_rq(cpu);
        /* 跳过优先级不高于本 CPU 的源 */
        if (src_rq->rt.highest_prio.next >=
            this_rq->rt.highest_prio.curr)
            continue;
        // ... 从 src_rq 拉取任务
    }
}

推送和拉取通过调度回调机制触发。在 balance_rt() 函数(第 1594 行)中,当切换到 RT 任务时会检查是否需要拉取:

// kernel/sched/rt.c, line 1594
static int balance_rt(struct rq *rq, struct task_struct *p, struct rq_flags *rf)
{
    if (!on_rt_rq(&p->rt) && need_pull_rt_task(rq, p)) {
        rq_unpin_lock(rq, rf);
        pull_rt_task(rq);
        rq_repin_lock(rq, rf);
    }
    return sched_stop_runnable(rq) || sched_dl_runnable(rq) || sched_rt_runnable(rq);
}

11.4.10 CPU 选择

当 RT 任务被唤醒时,select_task_rq_rt()(第 1499 行)为其选择目标 CPU:

// kernel/sched/rt.c, line 1499
select_task_rq_rt(struct task_struct *p, int cpu, int flags)
{
    struct task_struct *curr, *donor;
    struct rq *rq;
    bool test;

    /* For anything but wake ups, just return the task_cpu */
    if (!(flags & (WF_TTWU | WF_FORK)))
        goto out;

    rq = cpu_rq(cpu);
    rcu_read_lock();
    curr = READ_ONCE(rq->curr);
    donor = READ_ONCE(rq->donor);

    /* 如果当前 CPU 运行的是低优先级 RT 或 FAIR 任务,
     * 尝试找一个优先级更低的 CPU */
    // ... 详细选择逻辑
}

RT 任务的 CPU 选择策略相对简单:优先选择当前优先级最低的 CPU。这确保高优先级 RT 任务总能尽快运行。

11.4.11 PREEMPT_RT 与实时性增强

标准 Linux 内核虽然支持实时调度策略,但由于内核中存在大量不可抢占的区域(如自旋锁保护的临界区、中断处理程序),实际上无法保证微秒级的响应时间。

PREEMPT_RT 补丁集(由 Thomas Gleixner 等人维护)通过以下改造显著提升了 Linux 的实时性:

  1. 线程化中断:将硬件中断处理程序移到内核线程中执行,使其可被高优先级任务抢占。主线内核已包含此特性的基础设施(CONFIG_IRQ_FORCED_THREADING)。

  2. 可抢占自旋锁:将大多数自旋锁替换为可抢占的 rt_mutex,使得持有锁的代码可被高优先级任务抢占。

  3. 优先级继承:rt_mutex 实现了优先级继承协议——当高优先级任务等待低优先级任务持有的锁时,低优先级任务临时提升到高优先级,避免优先级反转。

  4. 可抢占 RCU:允许 RCU 读侧临界区被抢占。

主线内核(包括 Linux 7.0.10)已经合并了部分 PREEMPT_RT 特性,完整的 PREEMPT_RT 支持仍在持续合入中。

11.4.12 实时调度实践建议

  1. 谨慎使用高优先级:SCHED_FIFO 优先级 99 应留给最关键的系统任务。普通实时应用使用 50-80 范围。

  2. 避免长时间忙等:SCHED_FIFO 任务如果进入死循环将导致系统挂起。建议设置看门狗。

  3. 锁住内存:使用 mlockall() 防止页面错误导致的非确定性延迟。

  4. CPU 亲和性:将 RT 任务绑定到专用 CPU(通过 sched_setaffinity() 或 cpuset),减少被其他任务干扰。

  5. 监控 RT 节流:如果 /proc/sys/kernel/sched_rt_runtime_us 触发了节流,检查 RT 任务是否占用过多 CPU。

  6. 使用 SCHED_DEADLINE:如果需要严格的时间保证,优先考虑 SCHED_DEADLINE 而非 SCHED_FIFO/RR(下一节详述)。

11.5 SCHED_DEADLINE -- 最早截止时间优先

SCHED_DEADLINE 是 Linux 3.14 引入的调度策略,基于经典的 EDF(Earliest Deadline First,最早截止时间优先)算法和 CBS(Constant Bandwidth Server,恒定带宽服务器)资源预留机制。与基于优先级的 SCHED_FIFO/RR 不同,SCHED_DEADLINE 基于时间约束来调度任务,提供形式化的可调度性保证。

11.5.1 EDF 理论基础

EDF 是单处理器上调度的最优算法——如果一个任务集在任何算法下都不可调度,那么在 EDF 下也不可调度。其核心思想极为简洁:

每个任务指定三个参数: - runtime(运行时间):每个周期内需要的最大 CPU 时间 - deadline(相对截止时间):从周期开始到必须完成的最大延迟 - period(周期):两次激活之间的间隔

调度器始终选择绝对截止时间最早的任务运行。

可调度性条件(准入测试):对于 n 个任务,系统可调度的充要条件是:

sum(runtime_i / period_i) <= 1.0    (对 i = 1..n)

即所有任务的带宽(bandwidth = runtime/period)之和不超过 CPU 总容量(1.0 = 100%)。这被称为利用率上界测试。

EDF 相比固定优先级调度(如 Rate Monotonic)的优势在于:后者只能保证总利用率不超过约 69.3%(Liu 和 Layland 上界),而 EDF 可以达到 100%。

11.5.2 Deadline 调度实体:struct sched_dl_entity

每个 Deadline 任务的信息存储在 struct sched_dl_entity 中,定义位于 include/linux/sched.h(第 644 行):

// include/linux/sched.h, line 644
struct sched_dl_entity {
    struct rb_node    rb_node;        // 红黑树节点

    /* 原始调度参数(由 sched_setattr 设置,运行期间不变) */
    u64    dl_runtime;    // 每个周期内的最大运行时间
    u64    dl_deadline;   // 每个实例的相对截止时间
    u64    dl_period;     // 任务周期
    u64    dl_bw;         // 带宽 = dl_runtime / dl_period
    u64    dl_density;    // 密度 = dl_runtime / dl_deadline

    /* 运行时调度参数(持续更新) */
    s64    runtime;       // 当前实例的剩余运行时间(可为负)
    u64    deadline;      // 当前实例的绝对截止时间
    unsigned int  flags;  // 调度行为标志

    /* 布尔标志位 */
    u64    dl_throttled  : 1;  // 已被节流(runtime 耗尽)
    u64    dl_boosted    : 1;  // 被优先级继承提升
    u64    dl_yielded    : 1;  // 主动让出
    // ... 更多标志位
};

这里有几个关键设计要点:

静态参数 vs 动态参数:dl_runtime、dl_deadline、dl_period 是用户通过 sched_setattr() 设置的静态参数,在下次 sched_setattr() 之前不会改变。runtime 和 deadline 是运行时持续更新的动态参数。

带宽与密度:dl_bw = dl_runtime / dl_period 用于准入控制,确保系统不过载。dl_density = dl_runtime / dl_deadline 用于更精确的可调度性分析(当 deadline != period 时尤为重要)。

runtime 可为负:runtime 字段是 s64(有符号),可以变成负值,表示任务已超出其分配的运行时间(overrun)。这在优先级继承场景中可能发生。

11.5.3 Deadline 运行队列:struct dl_rq

与 RT 的位图+链表结构不同,DL 使用红黑树来组织任务,定义位于 kernel/sched/sched.h(第 866 行):

// kernel/sched/sched.h, line 866
struct dl_rq {
    /* 按绝对截止时间排序的红黑树 */
    struct rb_root_cached  root;

    unsigned int  dl_nr_running;  // DL 任务数量

    /* 缓存当前运行任务和最早就绪任务的截止时间 */
    struct {
        u64  curr;   // 当前运行任务的截止时间
        u64  next;   // 最早就绪(非运行)任务的截止时间
    } earliest_dl;

    bool  overloaded;  // 是否过载

    /* 可推送到其他 CPU 的任务(按截止时间排序) */
    struct rb_root_cached  pushable_dl_tasks_root;

    /* 带宽统计 */
    u64  running_bw;   // 当前活跃任务的总带宽
    u64  this_bw;      // 本 rq 的总带宽(包括阻塞任务)
    // ... 更多字段
};

红黑树以绝对截止时间为键值,这意味着取最早截止时间的任务只需取红黑树的最左节点——O(1) 操作。这与 CFS 的红黑树以虚拟运行时间排序类似,但排序标准不同。

earliest_dl.curr 和 earliest_dl.next 的缓存对迁移决策至关重要。当一个 CPU 考虑是否从其他 CPU 拉取任务时,可以快速比较截止时间而不需要遍历红黑树。

11.5.4 Deadline 调度流程

入队操作

任务的入队函数 enqueue_task_dl() 位于 kernel/sched/deadline.c(第 2292 行):

// kernel/sched/deadline.c, line 2292
static void enqueue_task_dl(struct rq *rq, struct task_struct *p, int flags)
{
    if (is_dl_boosted(&p->dl)) {
        /* 优先级继承提升时,可能 runtime 为负但仍需入队 */
        if (p->dl.dl_throttled) {
            // 取消节流,因为 boost 优先
        }
    }

    // ... 更新参数

    __enqueue_dl_entity(&p->dl, dl_rq_of_se(&p->dl));
    // 插入红黑树,按绝对截止时间排序

    if (!task_current(rq, p) && p->nr_cpus_allowed > 1)
        enqueue_pushable_dl_task(rq, p);
}

插入红黑树时,__enqueue_dl_entity() 以 dl_se->deadline(绝对截止时间)为键值,确保最早截止时间的任务始终在最左节点。

选择下一个任务

__pick_task_dl() 位于第 2602 行:

// kernel/sched/deadline.c, line 2602
static struct task_struct *__pick_task_dl(struct rq *rq, struct rq_flags *rf)
{
    struct sched_dl_entity *dl_se;
    struct dl_rq *dl_rq = &rq->dl;
    struct task_struct *p;

again:
    if (!sched_dl_runnable(rq))
        return NULL;

    dl_se = pick_next_dl_entity(dl_rq);
    WARN_ON_ONCE(!dl_se);

    if (dl_server(dl_se)) {
        // 如果是 DL 服务器(如 fair_server),委托服务器选任务
        p = dl_se->server_pick_task(dl_se, rf);
        if (!p) {
            dl_server_stop(dl_se);
            goto again;
        }
        rq->dl_server = dl_se;
    } else {
        p = dl_task_of(dl_se);
    }

    return p;
}

pick_next_dl_entity() 直接取红黑树的最左节点(第 2588 行):

// kernel/sched/deadline.c, line 2588
static struct sched_dl_entity *pick_next_dl_entity(struct dl_rq *dl_rq)
{
    struct rb_node *left = rb_first_cached(&dl_rq->root);
    if (!left)
        return NULL;
    return __node_2_dle(left);
}

rb_first_cached() 利用 rb_root_cached 结构中缓存的左节点指针,实现 O(1) 查找。

时钟更新与节流

每次时钟更新调用 update_curr_dl()(第 1939 行),递减当前任务的 runtime:

// kernel/sched/deadline.c, line 1939
static void update_curr_dl(struct rq *rq)
{
    struct task_struct *donor = rq->donor;
    struct sched_dl_entity *dl_se = &donor->dl;
    s64 delta_exec;

    if (!dl_task(donor) || !on_dl_rq(dl_se))
        return;

    delta_exec = update_curr_common(rq);
    update_curr_dl_se(rq, dl_se, delta_exec);
}

在 update_curr_dl_se() 中,如果 runtime 耗尽(dl_runtime_exceeded(),第 1355 行),任务将被节流:

// kernel/sched/deadline.c, line 1355
int dl_runtime_exceeded(struct sched_dl_entity *dl_se)
{
    return (dl_se->runtime <= 0);
}

节流时的处理(在 update_curr_dl_se() 中,约第 1510 行):

if (dl_runtime_exceeded(dl_se) || dl_se->dl_yielded) {
    dl_se->dl_throttled = 1;

    if (dl_runtime_exceeded(dl_se) &&
        (dl_se->flags & SCHED_FLAG_DL_OVERRUN))
        dl_se->dl_overrun = 1;   // 通知用户态超限

    dequeue_dl_entity(dl_se, 0);  // 从红黑树移除
    // ... 启动高精度定时器,在下一个周期补充 runtime
}

周期补充(Replenishment)

当节流的高精度定时器到期时,调用 replenish_dl_entity()(第 795 行)为任务补充 runtime 并推进截止时间:

// kernel/sched/deadline.c, line 795
static void replenish_dl_entity(struct sched_dl_entity *dl_se)
{
    struct dl_rq *dl_rq = dl_rq_of_se(dl_se);
    struct rq *rq = rq_of_dl_rq(dl_rq);

    WARN_ON_ONCE(pi_of(dl_se)->dl_runtime <= 0);

    if (dl_se->dl_deadline == 0 ||
        (dl_se->dl_defer_armed && dl_entity_overflow(dl_se, rq_clock(rq)))) {
        dl_se->deadline = rq_clock(rq) + pi_of(dl_se)->dl_deadline;
        dl_se->runtime = pi_of(dl_se)->dl_runtime;
    } else {
        // 正常推进
        while (dl_se->runtime <= 0) {
            dl_se->deadline += pi_of(dl_se)->dl_deadline;
            dl_se->runtime += pi_of(dl_se)->dl_runtime;
        }
    }
}

补充逻辑确保 runtime 恢复到 dl_runtime,deadline 推进到下一个绝对截止时间点。如果任务之前已经超支(runtime 为负),会循环推进直到 runtime 恢复正值。

11.5.5 准入控制(Admission Control)

SCHED_DEADLINE 的关键特性之一是强制准入控制。通过 sched_setattr() 创建或修改 DL 任务时,内核会检查系统是否有足够的带宽容纳新任务。

准入检查通过 dl_bw_manage() 实现(第 3776 行):

// kernel/sched/deadline.c, line 3776
static int dl_bw_manage(enum dl_bw_request req, int cpu, u64 dl_bw)
{
    unsigned long flags, cap;
    struct dl_bw *dl_b;
    bool overflow = 0;
    u64 dl_server_bw = 0;

    rcu_read_lock_sched();
    dl_b = dl_bw_of(cpu);
    raw_spin_lock_irqsave(&dl_b->lock, flags);

    cap = dl_bw_capacity(cpu);
    switch (req) {
    case dl_bw_req_free:
        __dl_sub(dl_b, dl_bw, dl_bw_cpus(cpu));
        break;
    case dl_bw_req_alloc:
        overflow = __dl_overflow(dl_b, cap, 0, dl_bw);
        if (!overflow) {
            __dl_add(dl_b, dl_bw, dl_bw_cpus(cpu));
        }
        break;
    // ... 其他情况
    }
    // ...
}

如果 new_bw + existing_bw > total_bw(总容量),则 __dl_overflow() 返回 true,sched_setattr() 将返回 -EBUSY。

带宽以 root_domain 为单位管理。每个 root_domain 的 dl_bw 结构跟踪该域内所有 CPU 的总 DL 带宽。这意味着准入检查是跨 CPU 的——即使在某个空闲 CPU 上创建 DL 任务,也必须检查整个域的带宽余量。

公共接口(第 3852 行):

// kernel/sched/deadline.c, line 3852
int dl_bw_alloc(int cpu, u64 dl_bw)
{
    return dl_bw_manage(dl_bw_req_alloc, cpu, dl_bw);
}

void dl_bw_free(int cpu, u64 dl_bw)
{
    dl_bw_manage(dl_bw_req_free, cpu, dl_bw);
}

11.5.6 fair_server:为公平调度提供带宽保证

Linux 7.0.10 引入了一个精巧的机制——通过 DL 服务器为公平调度类(CFS/EEVDF)提供形式化的带宽保证。

每个 CPU 的运行队列包含一个 fair_server(rq->fair_server),它是一个 DL 实体,但不是服务于某个用户任务的,而是服务于该 CPU 上的所有公平调度任务。初始化代码位于 kernel/sched/deadline.c(约第 1854 行):

// kernel/sched/deadline.c, line 1854
dl_se = &rq->fair_server;

WARN_ON(dl_server(dl_se));

dl_server_apply_params(dl_se, runtime, period, 1);

dl_se->dl_server = 1;     // 标记为 DL 服务器
dl_se->dl_defer = 1;      // 启用延迟模式
setup_new_dl_entity(dl_se);

fair_server 的工作方式:

  1. 它被赋予一定的 DL 带宽(runtime/period),例如 50ms/100ms 意味着公平任务在每 100ms 内至少获得 50ms 的 CPU 时间。
  2. 当 fair_server 被选中运行时,它不是执行某个特定任务,而是调用公平调度器的 pick_task 从 CFS/EEVDF 运行队列中选择任务。
  3. 当 fair_server 的 runtime 耗尽时,它被节流,公平任务让位给 DL 或 RT 任务。

这确保了即使系统中有大量 DL 任务,公平任务也能获得保证的最小带宽——一个非常有价值的形式化保证。

11.5.7 Deadline 任务迁移

与 RT 任务类似,DL 任务也可以在 CPU 之间迁移,但需要满足更严格的约束。

推送机制:pushable_dl_tasks_root 红黑树存储可迁移的 DL 任务,按截止时间排序。当一个 CPU 有多个 DL 任务时,截止时间最晚(最不紧急)的任务是推送的首选候选。

跨 CPU 准入检查:当 DL 任务迁移到新 CPU 时,必须在新 CPU 的 root_domain 内通过准入检查。这通过 dl_bw_manage() 的分配路径完成。

带宽管理还涉及 root_domain 级别的统计。当 CPU 上线时,需要将 fair_server 的带宽加入 root_domain 的总带宽(第 3237 行):

// kernel/sched/deadline.c, line 3237
dl_se = &cpu_rq(cpu)->fair_server;
if (dl_server(dl_se) && cpu_active(cpu))
    __dl_add(&rd->dl_bw, dl_se->dl_bw, dl_bw_cpus(cpu));

dl_clear_root_domain() 函数(第 3263 行)在重建 root_domain 时重置所有带宽统计,然后重新加入 DL 服务器的贡献。

11.5.8 CBS(Constant Bandwidth Server)与 GRUB

Linux 的 SCHED_DEADLINE 不仅实现了基本的 EDF,还集成了 CBS(恒定带宽服务器)机制,确保任务不会超出其声明的带宽。

CBS 的核心规则:

  1. 当任务被激活时,如果当前 runtime > 0,直接入队,不改变 deadline。
  2. 如果 runtime <= 0,推进到下一个周期:deadline += period,runtime = dl_runtime。
  3. 运行时持续递减 runtime。当 runtime <= 0 时节流。
  4. 在节流定时器到期时,执行补充(replenishment)。

Linux 7.0.10 还实现了 GRUB(Greedy Reclamation of Unused Bandwidth)回收算法。当系统中的 DL 任务没有用完其分配的带宽时,GRUB 允许活跃任务利用这些空闲带宽来加速执行。这在 update_curr_dl_se() 中通过调整 runtime 的递减速率实现:

dq = -(max{u_i, (Umax - Uinact - Uextra)} / Umax) * dt

其中 u_i 是任务利用率,Umax 是最大可回收利用率,Uinact 是非活跃利用率,Uextra 是额外带宽。这使得 DL 任务在系统不繁忙时可以运行得更快。

11.5.9 SCHED_FIFO/RR vs SCHED_DEADLINE 对比

特性 SCHED_FIFO / SCHED_RR SCHED_DEADLINE
调度依据 固定优先级 时间约束(截止时间)
准入控制 无 必须通过(否则 -EBUSY)
过载处理 高优先级饿死低优先级 形式化保证(CBS 带宽隔离)
时间保证 最佳努力 硬性带宽保证
选择复杂度 O(1)(位图+链表) O(log n)(红黑树)+ 准入 O(1)
配置方式 优先级(0-99) runtime + deadline + period
优先级反转 需手动处理 PI 自动处理(rt_mutex)
适用场景 简单实时需求 需要形式化保证的实时系统

11.5.10 SCHED_DEADLINE 使用示例

创建一个 DL 任务的基本方式:

struct sched_attr attr;
attr.size = sizeof(attr);
attr.sched_policy = SCHED_DEADLINE;
attr.sched_runtime  = 10 * 1000000;   // 10ms 运行时间
attr.sched_deadline = 30 * 1000000;   // 30ms 截止时间
attr.sched_period   = 100 * 1000000;  // 100ms 周期

// 带宽 = 10ms / 100ms = 10%
// 准入控制会检查:当前总带宽 + 10% <= 100%

if (sched_setattr(0, &attr, 0) < 0) {
    perror("sched_setattr");
    // 可能是 -EBUSY(带宽不足)
}

注意事项: - 所有时间参数以纳秒为单位。 - sched_runtime 必须 <= sched_deadline,sched_deadline 必须 <= sched_period(隐式截止时间模型)。 - 当前实现要求 sched_period >= sched_deadline >= sched_runtime。 - DL 任务不能设置 CPU 亲和性(sched_setaffinity() 返回 -EPERM),因为迁移需要准入控制。 - DL 任务的优先级高于所有 RT 和 FAIR 任务,仅低于 stop/machine 任务。

11.6 负载跟踪 -- PELT 与负载均衡

在现代 SMP 系统中,调度器不仅需要在本 CPU 上合理分配时间,还需要在多个 CPU 之间均衡负载。负载跟踪是负载均衡的基础——调度器需要精确知道每个 CPU、每个调度实体甚至每个任务组的负载水平,才能做出正确的迁移决策。Linux 的 PELT(Per-Entity Load Tracking)机制正是为此而设计。

11.6.1 为什么需要负载跟踪

负载跟踪服务于多个子系统:

  1. 负载均衡:识别过载和空闲的 CPU,将任务从繁忙 CPU 迁移到空闲 CPU。
  2. 任务唤醒:当任务被唤醒时,选择负载最低的目标 CPU(select_task_rq_fair())。
  3. 频率调节:CPUFreq 调频器根据 CPU 利用率决定运行频率——利用率高则升频,利用率低则降频。
  4. 过载检测:EAS(Energy-Aware Scheduling)根据负载判断是否需要唤醒大核。
  5. 组调度:控制组(cgroup)的带宽分配需要精确的负载度量。

早期的 Linux 只跟踪整个 CPU 的运行队列长度( nr_running),粒度粗、响应慢。PELT 的革命性在于将跟踪粒度细化到每个调度实体(sched_entity),实现了精确的逐任务负载度量。

11.6.2 PELT 基本原理

PELT 的核心思想是使用几何衰减的滑动窗口来跟踪负载:最近的历史比更早的历史权重更大。

具体参数: - 基本时间单位:1024 微秒(约 1ms)为一个 period - 半衰期:32 个 period(约 32ms),由 LOAD_AVG_PERIOD 定义 - 最大累积窗口:LOAD_AVG_MAX = 47742(无穷级数之和)

这些常量定义在 kernel/sched/sched-pelt.h:

// kernel/sched/sched-pelt.h, line 14
#define LOAD_AVG_PERIOD 32
#define LOAD_AVG_MAX 47742

半衰期 32ms 的含义是:32ms 前的负载对当前平均值的贡献恰好是一半。64ms 前的贡献是四分之一,依此类推。这意味着 PELT 是一个"指数移动平均"(EMA)——响应足够快(能反映最近的负载变化),又足够平滑(不会因瞬态波动而剧烈抖动)。

衰减公式为:

y^n ≈ 0.5^(n/32)

其中 y ≈ 0.978(精确值为 y^32 = 0.5,所以 y = 0.5^(1/32) ≈ 0.97847)。

11.6.3 struct sched_avg:负载跟踪数据结构

每个调度实体的负载信息存储在 struct sched_avg 中,定义位于 include/linux/sched.h(第 510 行):

// include/linux/sched.h, line 510
struct sched_avg {
    u64             last_update_time;   // 上次更新的时间戳
    u64             load_sum;           // 负载累积值(受优先级加权)
    u64             runnable_sum;       // 可运行时间累积(含等待)
    u32             util_sum;           // 利用率累积(实际运行时间)
    u32             period_contrib;     // 当前 period 的部分贡献
    unsigned long   load_avg;           // 衰减后的负载均值
    unsigned long   runnable_avg;       // 衰减后的可运行均值
    unsigned long   util_avg;           // 衰减后的利用率均值
    unsigned int    util_est;           // 利用率估计(用于唤醒)
} ____cacheline_aligned;

这里有三种不同的度量:

load_avg(负载均值):加权负载。不仅考虑任务是否在运行,还考虑任务的优先级(nice 值)。nice 为 0 的任务权重为 1024,nice 为 -20 的任务权重约为 8.2 倍。load_avg 会因优先级而"膨胀"。

runnable_avg(可运行均值):任务在运行队列上等待+运行的总时间比例。只要任务在运行队列中就贡献,无论是否实际在 CPU 上运行。这反映了任务的"需求"。

util_avg(利用率均值):任务实际在 CPU 上运行的时间比例。这是最直接的度量——它告诉调度器这个任务真正消耗了多少 CPU 时间。CPUFreq 主要使用这个值来决策频率。

三者的区别用一个例子说明:假设一个 nice=0 的任务在 32ms 内运行了 20ms、等待了 10ms(在运行队列上等待 CPU)、睡眠了 2ms,那么: - util_avg 约为 20/32 = 62.5% - runnable_avg 约为 30/32 = 93.75% - load_avg 约为 30/32 * 1024(受优先级加权)

11.6.4 PELT 更新机制

PELT 的核心更新函数是 ___update_load_sum(),位于 kernel/sched/pelt.c(第 181 行):

// kernel/sched/pelt.c, line 181
___update_load_sum(u64 now, struct sched_avg *sa,
                   unsigned long load, unsigned long runnable, int running)
{
    u64 delta;

    delta = now - sa->last_update_time;

    /* 处理时钟回退 */
    if ((s64)delta < 0) {
        sa->last_update_time = now;
        return 0;
    }

    /* 使用 1024ns (~1us) 作为基本单位 */
    delta >>= 10;
    if (!delta)
        return 0;
    // ...
}

更新过程分两步:

第一步:衰减旧值。根据经过的 period 数量,对 load_sum、runnable_sum、util_sum 乘以衰减因子(第 116 行):

// kernel/sched/pelt.c, line 116
sa->load_sum = decay_load(sa->load_sum, periods);
sa->runnable_sum = decay_load(sa->runnable_sum, periods);
sa->util_sum = decay_load((u64)(sa->util_sum), periods);

decay_load() 函数(第 32 行)实现了 val * y^n 的计算:

// kernel/sched/pelt.c, line 32
static u64 decay_load(u64 val, u64 n)
{
    unsigned int local_n;

    if (unlikely(n > LOAD_AVG_PERIOD * 63))
        return 0;    // 衰减到零

    local_n = n;

    /* 利用 y^PERIOD = 1/2 的性质:
     * y^n = 1/2^(n/PERIOD) * y^(n%PERIOD)
     * 用查找表计算 y^(n%PERIOD) */
    if (unlikely(local_n >= LOAD_AVG_PERIOD)) {
        val >>= local_n / LOAD_AVG_PERIOD;
        local_n %= LOAD_AVG_PERIOD;
    }

    val = mul_u64_u32_shr(val, runnable_avg_yN_inv[local_n], 32);
    return val;
}

这个实现巧妙地利用了 y^32 = 0.5 的性质,将衰减计算分解为整数次右移(每次右移等于除以 2)和一次查表(0-31 范围内的精确衰减因子),实现了常数时间的衰减计算。

第二步:累加新贡献(第 142 行):

// kernel/sched/pelt.c, line 142
if (load)
    sa->load_sum += load * contrib;
if (runnable)
    sa->runnable_sum += runnable * contrib << SCHED_CAPACITY_SHIFT;
if (running)
    sa->util_sum += contrib << SCHED_CAPACITY_SHIFT;

其中 contrib 是通过 __accumulate_pelt_segments()(第 58 行)计算的新增贡献,它精确处理了跨越多个 period 的情况:

// kernel/sched/pelt.c, line 58
static u32 __accumulate_pelt_segments(u64 periods, u32 d1, u32 d3)
{
    u32 c1, c2, c3 = d3; /* y^0 == 1 */

    c1 = decay_load((u64)d1, periods);
    c2 = LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024;

    return c1 + c2 + c3;
}

该函数处理的是不完整 period 的情况——上次更新位于某个 period 的中间(d1 是该 period 的剩余部分),经过完整的 periods 个 period,然后在当前 period 的 d3 位置结束。

第二步之后:将累积值转换为均值。___update_load_avg()(第 258 行)完成转换:

// kernel/sched/pelt.c, line 258
___update_load_avg(struct sched_avg *sa, unsigned long load)
{
    u32 divider = get_pelt_divider(sa);

    sa->load_avg = div_u64(load * sa->load_sum, divider);
    sa->runnable_avg = div_u64(sa->runnable_sum, divider);
    WRITE_ONCE(sa->util_avg, sa->util_sum / divider);
}

divider 是 LOAD_AVG_MAX - 1024 + period_contrib,近似等于无穷级数的归一化因子。均值范围是 0-1024,其中 1024 代表 100% 利用率。

11.6.5 不同层级的 PELT 更新

PELT 的更新发生在多个层级:

调度实体层级(__update_load_avg_se(),第 307 行):

// kernel/sched/pelt.c, line 307
int __update_load_avg_se(u64 now, struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    if (___update_load_sum(now, &se->avg, !!se->on_rq, se_runnable(se),
                            cfs_rq->curr == se)) {
        ___update_load_avg(&se->avg, se_weight(se));
        cfs_se_util_change(&se->avg);
        return 1;
    }
    return 0;
}

这里的三个参数含义: - !!se->on_rq:任务在运行队列上(load) - se_runnable(se):任务可运行(runnable) - cfs_rq->curr == se:任务正在 CPU 上运行(running/util)

CFS 运行队列层级(__update_load_avg_cfs_rq(),第 321 行):

// kernel/sched/pelt.c, line 321
int __update_load_avg_cfs_rq(u64 now, struct cfs_rq *cfs_rq)
{
    if (___update_load_sum(now, &cfs_rq->avg,
                            scale_load_down(cfs_rq->load.weight),
                            cfs_rq->h_nr_runnable,
                            cfs_rq->curr != NULL)) {
        ___update_load_avg(&cfs_rq->avg, 1);
        return 1;
    }
    return 0;
}

CFS 运行队列的 load 是其所有子实体的权重之和,runnable 是可运行实体数,running 是是否有当前正在运行的任务。

RT 和 DL 层级:RT 和 DL 调度类也有自己的 PELT 跟踪(第 349 行和第 375 行),但只跟踪 running 状态(因为 RT/DL 任务一旦在队列上就会立即运行或等待运行):

// kernel/sched/pelt.c, line 349 (RT)
if (___update_load_sum(now, &rq->avg_rt, running, running, running)) {
    ___update_load_avg(&rq->avg_rt, 1);
    // ...
}

11.6.6 PELT 传播链

PELT 的数据从底层到顶层逐级传播:

task_struct->se.avg          (每个任务的 PELT)
         |
         v
cfs_rq->avg                  (CFS 运行队列的 PELT)
         |
         v
rq->cfs.avg                  (CPU 运行队列的公平调度部分)
rq->avg_rt                   (CPU 运行队列的 RT 部分)
rq->avg_dl                   (CPU 运行队列的 DL 部分)
         |
         v
sched_group_capacity         (调度组容量)
sched_domain                 (调度域统计)

每个层级在更新时都会聚合子层级的信息。例如,当任务的 sched_avg 更新时,其所属的 cfs_rq 的统计也会更新,进而影响 CPU 级别的负载计算。

11.6.7 调度域层次结构

负载均衡的决策依赖于调度域(sched_domain)层次结构,定义在 include/linux/sched/topology.h(第 73 行):

// include/linux/sched/topology.h, line 73
struct sched_domain {
    struct sched_domain __rcu *parent;  /* 顶级域的 parent 为 NULL */
    struct sched_domain __rcu *child;   /* 底级域的 child 为 NULL */
    struct sched_group   *groups;       /* 该域中的调度组 */
    unsigned long  min_interval;        /* 最小均衡间隔 */
    unsigned long  max_interval;        /* 最大均衡间隔 */
    unsigned int   busy_factor;         /* 繁忙时的均衡抑制因子 */
    unsigned int   imbalance_pct;       /* 不均衡阈值百分比 */
    unsigned int   cache_nice_tries;    /* 缓存热任务的保留次数 */
    unsigned int   imb_numa_nr;         /* NUMA 不均衡阈值 */
    int            nohz_idle;           /* NOHZ 空闲状态 */
    int            flags;               /* SD_* 标志位 */
    int            level;               /* 层级深度 */
    // ... 更多字段
};

典型的调度域层次结构(从底到顶):

CPU 0  CPU 1  CPU 2  CPU 3  CPU 4  CPU 5  CPU 6  CPU 7
  |______|      |______|      |______|      |______|
   SMT域         SMT域         SMT域         SMT域
  (超线程兄弟)  (超线程兄弟)  (超线程兄弟)  (超线程兄弟)
  |_____________|             |_____________|
      MC域 (共享L2)               MC域 (共享L2)
  |___________________________|
      DIE域 (同一插槽)
  |__________________________________________|
        NUMA域 (跨插槽)

每个调度域包含一组调度组(sched_group),定义在 kernel/sched/sched.h(第 2184 行):

// kernel/sched/sched.h, line 2184
struct sched_group {
    struct sched_group *next;           /* 循环链表的下一个组 */
    atomic_t    ref;
    unsigned int group_weight;          /* 组内 CPU 数量 */
    unsigned int cores;
    struct sched_group_capacity *sgc;   /* 容量信息 */
    int         asym_prefer_cpu;        /* 组内最高优先级 CPU */
    int         flags;
    unsigned long cpumask[];            /* 组覆盖的 CPU 掩码 */
};

每个调度组关联一个容量结构(sched_group_capacity,第 2167 行):

// kernel/sched/sched.h, line 2167
struct sched_group_capacity {
    atomic_t      ref;
    unsigned long capacity;       /* 组的总容量 */
    unsigned long min_capacity;   /* 组内单 CPU 最小容量 */
    unsigned long max_capacity;   /* 组内单 CPU 最大容量 */
    unsigned long next_update;
    int           imbalance;      /* 不均衡度 */
    int           id;
    unsigned long cpumask[];      /* 均衡掩码 */
};

11.6.8 负载均衡触发机制

负载均衡通过以下几种机制触发:

周期性均衡:sched_balance_trigger()(kernel/sched/fair.c 第 13099 行)在时钟节拍中检查是否到了均衡时间:

// kernel/sched/fair.c, line 13099
void sched_balance_trigger(struct rq *rq)
{
    if (unlikely(on_null_domain(rq) || !cpu_active(cpu_of(rq))))
        return;

    if (time_after_eq(jiffies, rq->next_balance))
        raise_softirq(SCHED_SOFTIRQ);

    nohz_balancer_kick(rq);
}

当当前时间超过 next_balance 时间戳时,触发 SCHED_SOFTIRQ 软中断。均衡间隔由 sched_domain 的 min_interval 和 max_interval 决定,并根据 CPU 繁忙程度调整。

空闲均衡:当 CPU 进入空闲状态时,主动从最繁忙的 CPU 拉取任务。这是减少延迟的重要手段——空闲 CPU 不需要等到下一次定时均衡。

NOHZ 空闲均衡:当某些 CPU 进入 tickless 模式后,它们的负载信息可能过时。nohz_balancer_kick() 会唤醒一个空闲 CPU 来代为执行均衡。

新建任务均衡:fork 或 exec 时,select_task_rq_fair() 选择目标 CPU,这是一种隐含的负载均衡。

11.6.9 负载均衡主函数

负载均衡的核心函数 sched_balance_rq()(第 11867 行,旧版内核中名为 load_balance):

// kernel/sched/fair.c, line 11867
static int sched_balance_rq(int this_cpu, struct rq *this_rq,
                            struct sched_domain *sd, enum cpu_idle_type idle,
                            int *continue_balancing)
{
    int ld_moved, cur_ld_moved, active_balance = 0;
    struct sched_domain *sd_parent = sd->parent;
    struct sched_group *group;
    struct rq *busiest;
    struct rq_flags rf;
    struct cpumask *cpus = this_cpu_cpumask_var_ptr(load_balance_mask);
    struct lb_env env = {
        .sd         = sd,
        .dst_cpu    = this_cpu,
        .dst_rq     = this_rq,
        .dst_grpmask = group_balance_mask(sd->groups),
        .idle       = idle,
        .loop_break = SCHED_NR_MIGRATE_BREAK,
        .cpus       = cpus,
        .fbq_type   = all,
        .tasks      = LIST_HEAD_INIT(env.tasks),
    };
    // ...

lb_env 结构封装了均衡操作的所有环境信息。均衡过程的主要步骤:

  1. find_busiest_group():遍历调度域中的所有调度组,找到负载最重的组。这涉及到 PELT 数据的汇聚——每个组的负载是其内所有 CPU 的 load_avg 之和。

  2. find_busiest_queue():在最繁忙的组内,找到负载最重的 CPU(rq)。

  3. detach_tasks():从最繁忙的 CPU 上选择合适的任务进行分离。选择标准包括:任务允许在目标 CPU 上运行(亲和性检查)、迁移后能改善不均衡等。

  4. attach_tasks():将分离的任务附加到目标 CPU 的运行队列上。

11.6.10 组分类与不均衡判定

负载均衡使用 enum group_type(第 9278 行)来分类调度组的状态,决定均衡策略:

// kernel/sched/fair.c, line 9278
enum group_type {
    group_has_spare = 0,     /* 有空闲容量 */
    group_fully_busy,        /* 完全繁忙 */
    group_misfit_task,       /* 有不适配任务 */
    group_smt_balance,       /* SMT 组需均衡 */
    group_asym_packing,      /* 非对称打包 */
    group_imbalanced,        /* 已标记为不均衡 */
    group_overloaded,        /* 过载 */
};

组类型的判断逻辑反映了负载均衡策略的优先级:

  1. group_overloaded:组的负载超过容量,且运行任务数超过 CPU 数。这是最需要均衡的情况。
  2. group_misfit_task:有任务的利用率超过当前 CPU 容量(在异构系统中,大任务跑在小核上)。需要迁移到大核。
  3. group_fully_busy:所有 CPU 都在运行,但没有超载。
  4. group_has_spare:有 CPU 空闲,可以接收迁移过来的任务。

均衡策略根据源和目标的组类型决定: - 从 group_overloaded 迁移到 group_has_spare:基于负载均衡 - 从 group_misfit_task 迁移到 group_has_spare:基于容量适配 - 从 group_overloaded 迁移到 group_fully_busy:尝试使负载均等

11.6.11 迁移类型

与组类型对应,迁移也有不同类型(第 9314 行):

// kernel/sched/fair.c, line 9314
enum migration_type {
    migrate_load = 0,    // 基于负载迁移
    migrate_util,        // 基于利用率迁移
    migrate_task,        // 单任务迁移
    migrate_misfit,      // 不适配任务迁移
};
  • migrate_load:从过载 CPU 拉取高负载任务,使负载在 CPU 间均衡。选择 load_avg 最高的任务。
  • migrate_util:在空闲 CPU 上拉取高利用率任务。选择 util_avg 最高的任务。
  • migrate_task:当只需迁移一个任务时(例如目标 CPU 完全空闲)。
  • migrate_misfit:将利用率超过 CPU 容量的任务迁移到更高容量的 CPU。

11.6.12 PELT 与 CPUFreq

PELT 的 util_avg 是 CPUFreq 调频器(特别是 schedutil)的核心输入。schedutil 直接使用 util_avg 来计算目标频率:

target_freq = max_freq * util_avg / SCHED_CAPACITY_SCALE

例如,如果 CPU 的最大频率是 3GHz,任务的 util_avg 为 512(即 50%),则目标频率为 1.5GHz。

为避免频繁切换频率,schedutil 还考虑了利用率的变化趋势(通过 util_est 和 DWCF 窗口)。

PELT 的 clock_pelt 还会根据 CPU 的当前频率进行缩放(kernel/sched/pelt.h 第 100 行):

// kernel/sched/pelt.h, line 123
delta = cap_scale(delta, arch_scale_cpu_capacity(cpu_of(rq)));
delta = cap_scale(delta, arch_scale_freq_capacity(cpu_of(rq)));

这确保了 PELT 的利用率度量与 CPU 的实际计算能力无关——在 1GHz 上运行 10ms 和在 2GHz 上运行 5ms 产生的 util 贡献是相同的。

11.6.13 PELT 的局限性

尽管 PELT 是 Linux 调度器的重要进步,它仍有几个已知局限:

  1. 响应延迟:半衰期 32ms 意味着任务负载的上升和下降需要约 100ms 才能完全反映在 avg 中。这对于短期突发负载可能不够快。

  2. 对睡眠任务的处理:任务睡眠时不更新 PELT,唤醒时的负载可能过高或过低。util_est 机制部分缓解了这个问题。

  3. PELT 不可跨 CPU 比较:不同 CPU 的 PELT 时钟可能不同步(特别是 tickless 模式下)。迁移任务时需要特殊处理(migrate_se_pelt_lag())。

  4. 对 RT/DL 的支持有限:RT 和 DL 的 PELT 只跟踪 running 状态,不跟踪排队等待时间。

11.7 SMP 调度与迁移

对称多处理器(Symmetric Multi-Processing,SMP)系统中的调度面临单核系统所没有的一系列挑战。每个 CPU 拥有自己的运行队列,任务可以在 CPU 之间迁移,但迁移是有代价的——缓存失效、TLB 刷新、NUMA 远程访问延迟都会影响性能。SMP 调度的核心目标是在最小化迁移代价的同时保持负载均衡。

11.7.1 SMP 调度的挑战

缓存亲和性(Cache Affinity):任务在某个 CPU 上运行时,其数据会填充该 CPU 的 L1/L2 缓存。如果任务被迁移到另一个 CPU,新 CPU 的缓存是冷的,需要重新从内存加载数据,造成性能损失。因此,调度器应尽量让任务留在上次运行的 CPU 上。

负载不均衡:在理想状态下,所有 CPU 的负载应大致相等。但实际上,任务的创建、唤醒和退出会导致负载在 CPU 间分布不均。某些 CPU 可能过载(任务排队等待),而其他 CPU 可能空闲。

NUMA 效应:在多插槽系统中,访问远程节点的内存比访问本地节点慢得多(可能慢 2-5 倍)。调度器需要尽量让任务运行在其内存所在的节点上。

异构计算:ARM big.LITTLE 和 Intel Alder Lake 等处理器具有不同性能等级的核心。大核性能强但功耗高,小核性能弱但功耗低。调度器需要根据任务的特性选择合适的核心。

11.7.2 任务唤醒时的 CPU 选择

任务唤醒(wakeup)是调度器选择目标 CPU 的关键时机。select_task_rq_fair() 是公平调度类的 CPU 选择函数,位于 kernel/sched/fair.c(第 8574 行):

// kernel/sched/fair.c, line 8574
static int
select_task_rq_fair(struct task_struct *p, int prev_cpu, int wake_flags)
{
    int sync = (wake_flags & WF_SYNC) && !(current->flags & PF_EXITING);
    struct sched_domain *tmp, *sd = NULL;
    int cpu = smp_processor_id();
    int new_cpu = prev_cpu;
    int want_affine = 0;
    int sd_flag = wake_flags & 0xF;

    lockdep_assert_held(&p->pi_lock);

    if (wake_flags & WF_TTWU) {
        record_wakee(p);

        /* WF_CURRENT_CPU: 强制唤醒到当前 CPU */
        if ((wake_flags & WF_CURRENT_CPU) &&
            cpumask_test_cpu(cpu, p->cpus_ptr))
            return cpu;

        /* 能效感知调度:优先选择节能的 CPU */
        if (!is_rd_overutilized(this_rq()->rd)) {
            new_cpu = find_energy_efficient_cpu(p, prev_cpu);
            if (new_cpu >= 0)
                return new_cpu;
            new_cpu = prev_cpu;
        }

        want_affine = !wake_wide(p) && cpumask_test_cpu(cpu, p->cpus_ptr);
    }

    rcu_read_lock();
    for_each_domain(cpu, tmp) {
        /* SD_WAKE_AFFINE: 偏好唤醒到唤醒者所在 CPU */
        if (want_affine && (tmp->flags & SD_WAKE_AFFINE) &&
            cpumask_test_cpu(prev_cpu, sched_domain_span(tmp))) {
            if (cpu != prev_cpu)
                new_cpu = wake_affine(tmp, p, cpu, prev_cpu, sync);
            sd = NULL;  /* Prefer wake_affine over balance flags */
            break;
        }

        /* 查找具有 SD_BALANCE_* 标志的域 */
        if (tmp->flags & sd_flag)
            sd = tmp;
        else if (!want_affine)
            break;
    }

    if (unlikely(sd)) {
        /* 慢路径:遍历调度域找最空闲的 CPU */
        new_cpu = sched_balance_find_dst_cpu(sd, p, cpu, prev_cpu, sd_flag);
    } else if (wake_flags & WF_TTWU) {
        /* 快路径:在兄弟 CPU 中找空闲的 */
        new_cpu = select_idle_sibling(p, prev_cpu, new_cpu);
    }
    rcu_read_unlock();

    return new_cpu;
}

这个函数展示了一个精心设计的选择策略:

快速路径(大多数情况): 1. 检查 want_affine——如果唤醒者和被唤醒者有缓存共享关系,尝试唤醒到同一 CPU 或附近的 CPU。 2. 调用 select_idle_sibling() 在目标 CPU 的 SMT 兄弟或共享缓存的 CPU 中寻找空闲的。 3. WF_SYNC 标志表明唤醒者和被唤醒者会很快同步(如生产者-消费者),此时偏好将它们放在同一 CPU。

慢速路径(少数情况): 1. 当没有合适的 wake_affine 目标时,调用 sched_balance_find_dst_cpu()(第 7497 行)。 2. 从最低层调度域开始,逐层向上查找最空闲的调度组和 CPU。

能效路径: 1. 当系统未被标记为 overutilized 时,调用 find_energy_efficient_cpu()(第 8379 行)。 2. 基于 Energy Model 选择能效最优的 CPU——可能不是最空闲的,但总体能耗最低。

11.7.3 CPU 亲和性

任务的 CPU 亲和性决定了它可以在哪些 CPU 上运行。存储在 task_struct 的 cpus_mask 字段(include/linux/sched.h 第 925 行):

// include/linux/sched.h, line 925
cpumask_t    cpus_mask;
void        *migration_pending;
unsigned short  migration_disabled;
unsigned short  migration_flags;

用户空间通过 sched_setaffinity() 系统调用设置 CPU 亲和性。内核内部通过 set_cpus_allowed_ptr() 和 __sched_setscheduler() 修改。

cpus_ptr 通常指向 cpus_mask,但在某些特殊情况下(如 kthread_bind)可能指向其他位置。migration_disabled 字段用于内核内部临时禁止迁移(如 RT 任务的某些临界区)。

调度器在所有 CPU 选择决策中都会检查亲和性约束——通过 cpumask_test_cpu(cpu, p->cpus_ptr) 确保目标 CPU 在允许的掩码内。

cpuset 和 cgroup 可以进一步限制任务的 CPU 亲和性。当一个 cpuset 的 CPU 集合变化时,其中的任务会被自动迁移到新的 CPU 集合上。

11.7.4 迁移线程

每个 CPU 都有一个专用的迁移内核线程(migration/N),用于执行强制迁移。当正常的负载均衡无法迁移某个任务时(例如任务正在运行中),需要通过迁移线程来完成。

迁移线程的核心函数是 active_load_balance_cpu_stop()(第 12197 行):

// kernel/sched/fair.c, line 12197
static int active_load_balance_cpu_stop(void *data)
{
    struct rq *busiest_rq = data;
    int busiest_cpu = cpu_of(busiest_rq);
    int target_cpu = busiest_rq->push_cpu;
    struct rq *target_rq = cpu_rq(target_cpu);
    struct sched_domain *sd;
    struct task_struct *p = NULL;
    struct rq_flags rf;

    rq_lock_irq(busiest_rq, &rf);
    /* 检查 CPU 是否仍然活跃 */
    if (!cpu_active(busiest_cpu) || !cpu_active(target_cpu))
        goto out_unlock;

    if (unlikely(busiest_cpu != smp_processor_id() ||
                 !busiest_rq->active_balance))
        goto out_unlock;

    if (busiest_rq->nr_running <= 1)
        goto out_unlock;

    /* 从最繁忙的运行队列中寻找可迁移的任务 */
    // ... 找到任务并迁移到 target_rq
}

主动负载均衡的触发条件:

  1. misfit 任务:任务的 util_avg 超过当前 CPU 的容量,需要迁移到更大容量的 CPU。此时 rq->misfit_task_load 被设置(第 5140 行):
// kernel/sched/fair.c, line 5140
rq->misfit_task_load = max_t(unsigned long, task_h_load(p), 1);
  1. 不均衡无法通过被动迁移解决:当最繁忙 CPU 上只有一个正在运行的任务时,常规的 detach/attach 方法无法将其迁移(因为它不在运行队列上等待),只能通过 stop-machine 机制强制迁移。

11.7.5 NUMA 感知调度

在 NUMA(Non-Uniform Memory Access)系统中,内存访问延迟取决于 CPU 和内存所在的节点。NUMA 调度的目标是尽量让任务运行在其频繁访问的内存所在的节点上。

NUMA 故障跟踪:内核通过 NUMA hinting fault 机制自动检测任务的内存访问模式。task_numa_fault() 函数(第 3220 行)在每次 NUMA hinting fault 时被调用:

// kernel/sched/fair.c, line 3220
void task_numa_fault(int last_cpupid, int mem_node, int pages, int flags)
{
    struct task_struct *p = current;
    bool migrated = flags & TNF_MIGRATED;
    int cpu_node = task_node(current);
    int local = !!(flags & TNF_FAULT_LOCAL);
    struct numa_group *ng;
    int priv;

    if (!static_branch_likely(&sched_numa_balancing))
        return;

    if (!p->mm)
        return;
    // ... 更新故障统计
}

NUMA hinting fault 的工作原理:内核周期性地将任务的某些页面标记为不可访问(通过修改页表项)。当任务访问这些页面时触发页面错误,内核记录下访问的节点信息,然后恢复页面的可访问性。这样内核就能知道任务主要访问哪些节点的内存。

NUMA 迁移决策:task_numa_migrate()(第 2557 行)根据累积的故障统计决定是否迁移任务:

// kernel/sched/fair.c, line 2557
static int task_numa_migrate(struct task_struct *p)
{
    struct task_numa_env env = {
        .p = p,
        .src_cpu = task_cpu(p),
        .src_nid = task_node(p),
        .imbalance_pct = 112,
        .best_task = NULL,
        .best_imp = 0,
        .best_cpu = -1,
    };
    unsigned long taskweight, groupweight;
    struct sched_domain *sd;
    long taskimp, groupimp;
    struct numa_group *ng;
    struct rq *best_rq;
    int nid, ret, dist;
    // ...
}

该函数评估将任务迁移到每个候选节点的收益。如果某个节点的内存访问量显著高于当前节点,并且有可用的 CPU,就执行迁移。

NUMA 偏好节点:任务结构中的 numa_preferred_nid(include/linux/sched.h 第 1366 行)记录了当前首选的 NUMA 节点:

// include/linux/sched.h, line 1366
int  numa_preferred_nid;
unsigned long  numa_migrate_retry;
u64  node_stamp;
u64  last_task_numa_placement;
u64  last_sum_exec_runtime;
struct callback_head  numa_work;
struct numa_group __rcu  *numa_group;

在任务唤醒时,调度器会优先考虑 numa_preferred_nid 上的 CPU。select_task_rq_fair() 的 NUMA 路径会检查首选节点的 CPU 是否可用,如果可用则优先选择。

11.7.6 容量感知调度

现代处理器越来越多地采用异构核心设计。ARM 的 big.LITTLE、Intel 的 P-core/E-core 都是典型的异构架构。不同核心的计算能力(capacity)不同,调度器必须感知这种差异。

CPU 容量:每个 CPU 的相对计算能力通过 arch_scale_cpu_capacity() 查询,返回 0 到 SCHED_CAPACITY_SCALE(1024)之间的值。大核通常返回 1024,小核返回较低的值(如 512 或 384)。

容量感知唤醒:在异构系统中,set_task_max_allowed_capacity()(第 8697 行)设置任务允许运行的最大容量:

// kernel/sched/fair.c, line 8697
static void set_task_max_allowed_capacity(struct task_struct *p)
{
    struct asym_cap_data *entry;

    if (!sched_asym_cpucap_active())
        return;

    rcu_read_lock();
    list_for_each_entry_rcu(entry, &asym_cap_list, link) {
        cpumask_t *cpumask;

        cpumask = cpu_capacity_span(entry);
        if (!cpumask_intersects(p->cpus_ptr, cpumask))
            continue;

        p->max_allowed_capacity = entry->capacity;
        break;
    }
    rcu_read_unlock();
}

Misfit 任务检测:当任务的 util_avg 超过其当前 CPU 的容量时,该任务被视为 "misfit"。misfit_task_load 记录了这类任务的负载(第 5132 行):

// kernel/sched/fair.c, line 5132-5140
rq->misfit_task_load = max_t(unsigned long, task_h_load(p), 1);

misfit 任务需要被迁移到更高容量的 CPU。在负载均衡中,group_misfit_task 组类型优先级高于普通的负载不均衡——确保大任务优先获得足够的 CPU 容量。

util_fits_cpu() 函数(第 4986 行)综合判断任务是否适合某个 CPU:

// kernel/sched/fair.c, line 4986
static inline int util_fits_cpu(unsigned long util,
                                unsigned long uclamp_min,
                                unsigned long uclamp_max,
                                int cpu)

该函数不仅检查任务的利用率是否超过 CPU 容量,还考虑了 uclamp 约束(用户指定的最小/最大利用率限制)和硬件压力(如热节流导致的容量下降)。

11.7.7 能效感知调度(EAS)

Energy-Aware Scheduling(EAS)利用处理器的 Energy Model(能耗模型)来优化 CPU 选择。当系统未被标记为 overutilized 时,find_energy_efficient_cpu()(第 8379 行)被调用:

// kernel/sched/fair.c, line 8379
static int find_energy_efficient_cpu(struct task_struct *p, int prev_cpu)
{
    struct cpumask *cpus = this_cpu_cpumask_var_ptr(select_rq_mask);
    unsigned long prev_delta = ULONG_MAX, best_delta = ULONG_MAX;
    unsigned long p_util_min = uclamp_is_used() ? uclamp_eff_value(p, UCLAMP_MIN) : 0;
    unsigned long p_util_max = uclamp_is_used() ? uclamp_eff_value(p, UCLAMP_MAX) : 1024;
    struct root_domain *rd = this_rq()->rd;
    int cpu, best_energy_cpu, target = -1;
    struct sched_domain *sd;
    struct perf_domain *pd;
    struct energy_env eenv;
    // ...
}

EAS 的策略是:在满足性能需求的前提下,选择能耗最低的 CPU。具体做法:

  1. 遍历所有性能域(perf_domain,通常对应一个cluster或一组同频率的核心)。
  2. 对每个域,计算将任务放置到该域后系统的总能耗增量(energy_env)。
  3. 选择能耗增量最小的域,然后在该域内选择最合适的 CPU。

EAS 仅在系统未被 overutilized 时启用。当系统整体负载很高时,性能优先于能效,调度器回退到标准的负载均衡路径。

11.7.8 核心调度(Core Scheduling)

超线程(SMT/Hyper-Threading)技术允许同一个物理核心上的两个硬件线程共享执行资源(如 L1 缓存、TLB、分支预测器)。这带来了安全风险——恶意任务可以通过侧信道攻击(如 Spectre 变体)从同一核心上的另一个任务中窃取数据。

CONFIG_SCHED_CORE 提供了核心调度机制,强制同一物理核心的超线程兄弟运行具有相同"cookie"的任务。

任务的核心 cookie 存储在 task_struct 的 core_cookie 字段(include/linux/sched.h 第 882 行):

// include/linux/sched.h, line 882
unsigned long    core_cookie;
unsigned int     core_occupation;

核心调度的实现在 kernel/sched/core.c 中。sched_core_enqueue() 将任务加入核心调度队列(第 302 行):

// kernel/sched/core.c, line 302
void sched_core_enqueue(struct rq *rq, struct task_struct *p)
{
    if (p->se.sched_delayed)
        return;

    rq->core->core_task_seq++;
    // ... 将任务按 cookie 排序插入核心红黑树
}

核心调度的选路过程确保 SMT 兄弟上的任务具有相同的 cookie。具体地,__sched_core_less() 函数(第 268 行)比较任务的 cookie 来排序:

// kernel/sched/core.c, line 268
if (a->core_cookie < b->core_cookie)
    return true;

if (a->core_cookie > b->core_cookie)
    return false;

/* flip prio, so high prio is leftmost */
if (prio_less(b, a, !!task_rq(a)->core->core_forceidle_count))
    return true;

当核心调度启用时,每个物理核心有一个"核心运行队列"(rq->core),选路过程首先在该核心队列中选择最高优先级的 cookie 组,然后在该组内选择任务。SMT 兄弟必须运行同一 cookie 组的任务——如果一个兄弟空闲而另一个正在运行任务 A,那么空闲兄弟只能运行与 A 相同 cookie 的任务,或者保持空闲。

11.7.9 NOHZ 与空闲负载均衡

现代处理器支持 tickless(NOHZ)模式——当 CPU 空闲或只有一个任务运行时,停止定期的时钟中断以节省功耗。但这给负载均衡带来了挑战:tickless CPU 的负载信息可能过时。

Linux 通过 NOHZ 空闲均衡机制解决此问题:

  1. nohz idle balancer:当某个 CPU 进入 idle 并开启 NOHZ 模式时,系统选择一个"ilb CPU"(idle load balancer)来代为执行负载均衡。
  2. nohz balancer kick:当其他 CPU 检测到负载不均衡时,通过 IPI 唤醒 ilb CPU 执行均衡。
  3. nohz_flags:每个 CPU 的 rq 维护 NOHZ 状态标志,跟踪哪些 CPU 在 NOHZ 模式中。

NOHZ 均衡的关键是正确处理"过时"的 PELT 数据。当 CPU 长时间处于 tickless 模式时,其负载衰减没有被正常更新。nohz_next_balance 时间戳确保下次均衡时这些 CPU 的数据会被刷新。

11.7.10 CPU 热插拔与调度

CPU 热插拔(hotplug)是另一个影响 SMP 调度的重要因素。当 CPU 被离线(offline)时,其上的所有任务必须被迁移到其他 CPU;当 CPU 上线(online)时,需要将其加入调度域并开始负载均衡。

CPU 离线时,migrate_tasks() 函数遍历即将离线的 CPU 的运行队列,将每个任务迁移到 select_fallback_rq() 选择的目标 CPU。这通常选择与离线 CPU 共享缓存的在线 CPU。

CPU 上线时,调度域需要重建(通过 partition_sched_domains()),新的 CPU 被包含在适当的调度组和调度域中。

11.7.11 调度域构建

调度域的构建是 SMP 调度的初始化基础。kernel/sched/topology.c 负责根据硬件拓扑(SMT、MC、NUMA 等)构建调度域层次结构。

struct sched_domain_topology_level(include/linux/sched/topology.h 第 191 行)定义了拓扑层的模板:

// include/linux/sched/topology.h, line 191
struct sched_domain_topology_level {
    sched_domain_mask_f  mask;       // 返回该层覆盖的 CPU 掩码
    sched_domain_flags_f sd_flags;   // 返回该层的 SD_* 标志
    int                  numa_level;
    struct sd_data       data;       // 调度域、组、容量数据
    char                *name;       // 层名称(如 "SMT", "MC", "NUMA")
};

sd_data 结构包含四组每 CPU 数据(第 184 行):

// include/linux/sched/topology.h, line 184
struct sd_data {
    struct sched_domain         *__percpu *sd;    // 调度域
    struct sched_domain_shared  *__percpu *sds;   // 共享数据
    struct sched_group          *__percpu *sg;    // 调度组
    struct sched_group_capacity *__percpu *sgc;   // 容量
};

默认的拓扑层次通常为: 1. SMT 层(SD_SHARE_CPUCAPACITY):超线程兄弟,共享执行单元。 2. MC 层(SD_SHARE_PKG_RESOURCES):共享 L2 缓存的核心。 3. DIE 层:同一插槽内的所有核心。 4. NUMA 层:跨插槽的所有 CPU。

每个层为每个 CPU 创建一个 sched_domain 实例,通过 parent/child 指针形成树状层次。调度域内的 CPU 被划分为 sched_group(每个组包含一个或多个 CPU),组之间形成循环链表。

11.7.12 负载均衡算法总结

Linux SMP 调度可以总结为以下几个层次:

第一层:任务放置(wakeup/fork/exec) - 在任务创建或唤醒时选择最优 CPU - 考虑缓存亲和性、负载均衡、能效、NUMA 偏好 - 决策路径:wake_affine -> select_idle_sibling -> sched_balance_find_dst_cpu

第二层:周期性均衡 - 定期检查调度域内的负载均衡状态 - 从最繁忙组拉取任务到最空闲组 - 受 interval、busy_factor、imbalance_pct 等参数控制

第三层:主动均衡 - 当被动均衡无法解决问题时启动 - 通过 migration 线程强制迁移正在运行的任务 - 主要用于 misfit 任务和不均衡修复

第四层:NUMA 均衡 - 独立于上述机制的周期性检查 - 基于 NUMA hinting fault 统计做迁移决策 - 可能涉及任务和内存的同时迁移

这四个层次协同工作,共同维护 SMP 系统的负载均衡和性能优化。每一层都有不同的触发条件、决策粒度和迁移代价,形成了一个多层次、自适应的均衡体系。