Linux 内核进程调度器深度实战:从 CFS 红黑树到 EEVDF、实时调度类、cgroups v2 CPU 控制器与生产调优的完全工程指南

操作系统调度器是内核中最核心的组件之一——它决定了哪个线程在何时、哪个 CPU 核上执行。从早期的 O(n) 调度器到 O(1) 调度器,再到完全公平调度器 CFS,直至 Linux 6.6 引入的 EEVDF(Earliest Eligible Virtual Deadline First),Linux 调度器经历了四次重大架构变革。本文将从调度器基本原理出发,深入剖析 CFS 的 vruntime 机制与红黑树实现、EEVDD 算法的革命性改进、实时调度类(SCHED_FIFO/RR/DEADLINE)、cgroups v2 CPU 控制器、NUMA 负载均衡、内核抢占模型、上下文切换开销优化,并给出生产环境下的调优策略与监控方案。

1. 调度器演进简史

Linux 调度器的演进史就是一部"追求公平与效率平衡"的历史:

  • O(n) 调度器(Linux 2.4):遍历所有可运行进程选择时间片最大的进程执行,时间复杂度 O(n),在进程数增长时性能急剧下降,SMP 支持原始。
  • O(1) 调度器(Linux 2.6.0 ~ 2.6.22):引入 active/expired 两个优先级数组和 140 级优先级位图,选择进程时间复杂度 O(1),但交互进程识别启发式算法复杂且易被"欺骗"。
  • CFS(Completely Fair Scheduler,Linux 2.6.23 ~ 6.5):由 Ingo Molnár 提出,核心思想是维护每个进程的 vruntime(虚拟运行时间),使用红黑树选择 vruntime 最小的进程运行,实现数学意义上的"完全公平"。
  • EEVDF(Earliest Eligible Virtual Deadline First,Linux 6.6+ 默认):Mel Gorman 等人推动的替代方案,保留 vruntime 的一致性保证,通过引入 deadline 概念在 O(1) 时间内完成调度决策,同时解决了 CFS 在负载变化时的延迟波动问题。

CFS 的核心哲学极其优雅:不使用传统时间片,而是追踪每个进程已获得的 CPU 时间,每次选择累计运行时间最少的进程。这等价于让所有可运行进程"赛跑",落后的进程被优先调度,最终所有进程趋于相同的 vruntime。

2. CFS 核心原理:vruntime 与红黑树

2.1 vruntime 的权重计算

vruntime(虚拟运行时间)是 CFS 的命脉。每个进程的 vruntime 递增速度与其实际运行时间的关系由进程权重决定:

// 内核源码:kernel/sched/fair.c
static void update_curr(struct cfs_rq *cfs_rq)
{
    struct sched_entity *curr = cfs_rq->curr;
    u64 now = rq_clock_task(rq_of(cfs_rq));
    u64 delta_exec;
    
    delta_exec = now - curr->exec_start;
    curr->exec_start = now;
    curr->sum_exec_runtime += delta_exec;
    
    // 关键公式:vruntime += delta_exec * (NICE_0_LOAD / curr->weight)
    curr->vruntime += calc_delta_fair(delta_exec, curr);
    update_min_vruntime(cfs_rq);
}

其中 calc_delta_fair 的实现为:vruntime += delta_exec * NICE_0_LOAD / weight。这意味着:

  • 高权重(高优先级)进程 vruntime 增长慢 → 更长时间才被追赶上 → 获得更多 CPU 时间。
  • 低权重(低优先级)进程 vruntime 增长快 → 很快被其他进程超过 → 让出 CPU。

Linux 使用 40 级优先级(-20 到 19),相邻级权重比约为 1.25:1(即每降低一个 nice 级,多获得约 25% CPU 时间)。具体权重表在 kernel/sched/core.c 中定义为 sched_prio_to_weight[40],其中 nice=0 对应权重 1024(即 NICE_0_LOAD)。

2.2 红黑树数据结构

CFS 使用红黑树(rb-tree)组织可运行进程,键值为 vruntime。红黑树提供了以下操作复杂度:

  • 插入:O(log n)
  • 删除:O(log n)
  • 选择最小值(最左侧节点):O(log n) 实际 O(1) 因为有缓存

每 CPU 运行队列 cfs_rq 维护自己的红黑树:

struct cfs_rq {
    struct load_weight load;          // 树上所有进程的总权重
    unsigned int nr_running;          // 可运行进程数
    u64 min_vruntime;                  // 树中最小 vruntime(用于新进程初始化)
    struct rb_root_cached tasks_timeline; // 红黑树根节点(缓存最左节点)
    struct sched_entity *curr;        // 当前运行的进程
    // ...
};

调度时的选择操作极为高效:

// pick_next_task_fair() 中最关键一步
static struct sched_entity *__pick_first_entity(struct cfs_rq *cfs_rq)
{
    struct rb_node *left = cfs_rq->tasks_timeline.rb_leftmost;
    return rb_entry(left, struct sched_entity, run_node);
}

由于 rb_root_cached 缓存了最左端节点,__pick_first_entity 实际为 O(1) 操作。新进程加入时通过 __enqueue_entity 插入树中,时钟中断后若 vruntime 变化则可能需要 __dequeue_entity 和重新插入。

2.3 新进程的 vruntime 初始化

fork 出的新进程 vruntime 初始化为当前 cfs_rq 的 min_vruntime,这防止了"新进程饿死老进程"的问题(如果从 0 开始,新进程会迅速抢占 CPU)。同时也防止了通过网络 fork(如某些迁移场景)把远处进程的过高 vruntime 带来,导致长期不公平。

3. 调度类体系:多级队列与优先级

Linux 调度器不是单一算法,而是通过调度类(sched_class)实现的多级优先级结构。调度类按优先级从高到低排列:

// 内核源码:include/linux/sched.h
extern const struct sched_class stop_sched_class;      // 最高优先级(停机)
extern const struct sched_class dl_sched_class;        // SCHED_DEADLINE
extern const struct sched_class rt_sched_class;        // SCHED_FIFO / SCHED_RR
extern const struct sched_class fair_sched_class;      // SCHED_NORMAL / SCHED_BATCH / SCHED_IDLE
extern const struct sched_class idle_sched_class;      // 最低优先级(空闲)

调度时从 stop → dl → rt → fair → idle 逐级查找,第一个非空的调度类胜出。这意味着:

  • stop 类用于 CPU 热插拔、IPI 等不可被抢占的操作。
  • dl 类的 SCHED_DEADLINE 使用全局 EDF 算法,保证 (runtime, deadline, period) 三元组的硬实时约束。
  • rt 类的 SCHED_FIFO(无时间片,直到主动让出)和 SCHED_RR(带时间片轮转)用于软实时任务。
  • fair 类涵盖普通进程(SCHED_NORMAL)、批处理进程(SCHED_BATCH)和空闲进程(SCHED_IDLE)。
  • idle 类只在完全没有其他可运行进程时执行 idle 线程。

3.1 SCHED_DEADLINE——硬实时保障

SCHED_DEADLINE 基于 Constant Bandwidth Server(CBS)算法,使用 EDF + 带宽隔离。用户通过 sched_setattr() 设置 sched_runtimesched_deadlinesched_period

// 设置一个 SCHED_DEADLINE 任务的 C 示例
#define _GNU_SOURCE
#include 
#include 

struct sched_attr attr = {
    .size = sizeof(attr),
    .sched_policy = SCHED_DEADLINE,
    .sched_runtime  = 30 * 1000 * 1000,  // 30ms runtime
    .sched_deadline = 50 * 1000 * 1000,  // 50ms deadline
    .sched_period   = 50 * 1000 * 1000,  // 50ms period
};
sched_setattr(0, &attr, 0);

内核的准入条件为:Σ(runtime_i / period_i) ≤ 1(多核上为 ≤ 核数 × capacity)。这意味着在保证 100% 单核带宽的前提下,最多只能容纳一个 (30ms/50ms) = 60% 带宽的任务,剩余 40% 可供 CFS 进程使用。

3.2 实时优先级范围

SCHED_FIFO 和 SCHED_RR 的优先级范围是 1-99(1 最低,99 最高)。需要注意的是,RT 进程可以无条件抢占 CFS 进程,甚至 root 权限的 SCHED_FIFO 优先级 99 进程可能会导致系统被锁死(因为连 shell 都无法运行)。因此在生产环境中必须配合 /etc/security/limits.conf 中的 rtprio 限制使用。

4. EEVDF:CFS 的革命性替代

4.1 CFS 的局限性

尽管 CFS 在大多数场景下表现优秀,但存在几个根本性问题:

延迟波动:当高权重进程从睡眠中醒来(wakeup),可能因为 vruntime 显著小于 min_vruntime 而长时间抢占低权重进程,导致延迟敏感应用的抖动。虽然 CFS 引入了 wakeup_granularity 等参数缓解,但根本问题在于 vruntime 与时间缺乏显式映射。

唤醒抢占限制sched_wakeup_preempt 逻辑中,只有当新唤醒进程的 vruntime 减去当前进程的 vruntime 大于 sysctl_sched_wakeup_granularity(默认 1ms)时才允许抢占。这在高负载时会导致微小的"调度时延"。

时间片计算代价:CFS 的时间片 ideal_runtime = sched_latency / nr_running 在进程数增加时减小,当 nr_running 很大时可能降到 min_granularity(默认 0.75ms)以下,此时进程频繁切换导致上下文切换开销上升。

4.2 EEVDF 算法核心

EEVDF(Earliest Eligible Virtual Deadline First)引入三个关键变量解决上述问题:

  • runtime:已执行的 CPU 时间(等同于 CFS 的 sum_exec_runtime)。
  • deadline = arrival_time + (granularity / weight):下次可以调度它的时间点。arrival_time 是上一次被唤醒/入队时的当前时间(不是 vruntime)。
  • lag = runtime - (weight_sum × (now - arrival_time) / total_weight):进程被"亏欠"或"透支"的 CPU 时间。

调度决策简化为:选择 deadline 最小的可运行进程。这天然解决了 CFS 中的唤醒抢占问题——唤醒进程的 deadline 比普通运行进程小一个精度(granularity/weight),它将在第一个 deadline 时刻公平地参与竞争,而不会无限期霸占 CPU。

关键性质:EEVDF 是 work-conservinglag-preserving 的。任何被延迟的进程(lag > 0)会在接下来的调度中获得补偿,直到 lag 恢复为零。整个系统的总 lag 恒为零。

4.3 EEVDF 数据结构与实现

EEVDF 保留了红黑树结构,但键值从 vruntime 变为 deadline:

// kernel/sched/fair.c (EEVDF 后)
struct sched_entity {
    u64         exec_start;      // 本次调度开始时间
    u64         sum_exec_runtime;// 累计执行时间
    u64         vruntime;        // 仍保留用于计算 lag
    u64         deadline;        // 核心调度键值 = arrival + (granularity/weight)
    u64         min_vruntime;    // 兼容性保留
    unsigned long       weight;  // nice 权重
    // ...
};

// 入队时计算 deadline
static void enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    // ...
    se->deadline = calc_delta_fair(se->granularity, se->load) + sched_clock();
    __enqueue_entity(cfs_rq, se);  // 以 deadline 为键插入红黑树
}

实际内核实现中 EEVDF 引入了时间戳截断(latency nice)概念,允许进程牺牲延迟换取吞吐量(类似 CFS 的 sched_nice,但更精细)。默认 latency_nice = 0,CFS 行为接近于 Nice 值映射。

4.4 如何切换回 CFS

Linux 6.6 起 EEVDF 成为默认,但保留了 CFS 作为可选项:

# 切换回 CFS(需要重启)
sudo grubby --update-kernel=ALL --args="sched_tickless=0"
# 或者运行时切换(6.6+)
# EEVDF 和 CFS 都编译在内核中,可通过 kernel parameter sched_policy=cfs 切回
sysctl kernel.sched=0  # 0=EEVDF, 1=CFS

注意:不同内核版本实现可能有变化,生产切换前必须全面测试

5. 上下文切换深度剖析

5.1 切换开销构成

上下文切换(context switch)是调度器最昂贵的操作之一,通常需要 1-10μs,具体构成:

  • 硬件状态保存/恢复:通用寄存器、浮点/SIMD 寄存器(Linux 使用惰性 FP 切换优化)、PC/SP。
  • MMU 切换:更新 CR3 寄存器指向新进程的页表;触发 TLB 刷新(实际上用 PCID 标签避免完整刷新)。
  • 内核栈切换thread_info 指针从 old task 转移到 new task。
  • SPECULATIVE 防护开销:含 IBRS/STIBP/L1D flush 等缓解 Spectre/Meltdown 的指令。
  • Btr FS 切换:保存/恢复 Branch History Buffer 状态。

Linux 的 惰性 FPU 切换 是个重要优化:首次在切换后进程使用 FPU 时触发 #NM 异常,内核才恢复 FPU 状态。对于大量不使用浮点运算的进程,这节省了很多不必要的状态保存。

5.2 进程切换 vs 线程切换 vs 协程切换

切换类型共享资源典型开销地址空间
进程切换无(除文件描述符表)2-10μs需要更换页表(PCID 优化后 TLB 命中率提升)
线程切换(同进程)地址空间、文件映射0.5-3μs同进程无需切换页表
用户态协程切换所有内核状态10-100ns完全不经过内核

生产环境中的黄金法则:当协程切换开销成为瓶颈时(如百万级频切换),使用异步 I/O(io_uring)+ 单线程事件循环模型能获得最佳性能。Nginx/Redis 的成功正是基于此原理。

6. 内核抢占模型

Linux 提供四种内核抢占模型,控制内核代码执行何时可被抢占:

6.1 PREEMPT_NONE(服务器/批处理)

CONFIG_PREEMPT_NONE:只有当系统调用返回、中断处理完成或显式调用 cond_resched() 时才切换。最大化吞吐量,但响应延迟不可控(可达数十至数百毫秒)。适合 HPC 和批处理。

6.2 PREEMPT_VOLUNTARY(桌面默认)

CONFIG_PREEMPT_VOLUNTARY:在显式的抢占点(might_sleepmutex_lock 等)检查是否需要抢占。平衡吞吐量与响应性。

6.3 PREEMPT(低延迟桌面/基础 RT)

CONFIG_PREEMPT:内核代码大部分位置(除持有 spinlock 的关键区)可被抢占。将默认调度延迟从数十毫秒降到几毫秒。SCHED_FIFO/RR 配合使用可实现数十至数百微秒级响应。代价是吞吐量下降约 1-3%。

6.4 PREEMPT_RT(硬实时)

CONFIG_PREEMPT_RT:实时抢占补丁集(已于 Linux 6.12 前逐步合入主线),将大多数 spinlock 转换为可睡眠的 rtmutex,将硬 IRQ 线程化。目标是将最坏情况延迟降低到

检查当前系统抢占模型:

$ cat /sys/kernel/debug/sched/preempt
# 或查看内核配置
zcat /proc/config.gz | grep PREEMPT

7. CPU 拓扑感知调度与 NUMA 优化

7.1 调度域(Sched Domain)层次

Linux 调度器通过 sched_domain 树形结构感知硬件拓扑,从低到高:

DIE/MC domainsPKG domainNUMA domain

  • MC(Multi-core)domain:同一 CPU die 内的核,共享 L1/L2 cache。
  • DIE domain:某些架构(如 Zen3+)将多个 CCX 组成一个 die。
  • PKG domain:同一物理插槽内的核,共享 L3 cache。
  • NUMA domain:多插槽系统中按 NUMA 节点划分。

负载均衡从最底层向上逐层搜索空闲 CPU 以平衡负载,优先在共享 cache 的核间迁移(这样减少 cache miss)。

7.2 NUMA 调度策略

调度器通过 task_numa_placement() 将进程尽量放在靠近其内存的 NUMA 节点上。跨节点访问内存的延迟是同节点的 1.5-3 倍。关键机制:

  • NUMA Balancing:内核周期性扫描进程地址空间,统计每个页面被哪个节点上的 CPU 访问最多(通过缺页异常采样)。将频繁页面迁移到访问它的 CPU 所在节点。
  • AutoNUMA:通过 numactl --interleave 与内核自适应策略更早地放置进程。
  • fault_around:当采样到某段内存被远程访问时,将整个 folio(2MB 大页)迁移。

NUMA Balancing 的开关与参数:

sysctl kernel.numa_balancing=1          # 默认开启
sysctl kernel.numa_balancing_scan_delay_ms=1000  # 首次扫描延迟
sysctl kernel.numa_balancing_scan_period_min_ms=2000
sysctl kernel.numa_balancing_scan_period_max_ms=60000
sysctl kernel.numa_balancing_scan_size_mb=256     # 每次扫描大小

7.3 跨节点访问性能陷阱

在双路 EPYC 7763(64 核 × 2)服务器上,跨 NUMA 节点访问 DDR4-3200 内存的延迟从 ~80ns 升至 ~140ns,带宽下降 40%。对于内存密集型应用(如 NVMe-oF 目标端、大规模 Redis),错误的 NUMA 绑定会导致吞吐量断崖式下降。

检查 NUMA 拓扑:

$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 ... 63
node 0 size: 257698 MB
node 0 free: 245632 MB
node 1 cpus: 64 65 66 ... 127
node 1 size: 257698 MB
node 1 free: 253104 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

距离 10 表示同节点,21 表示跨节点(具体数值取决于架构,距离越大性能越差)。

8. cgroups v2 CPU 资源控制

cgroups v2 重建了 CPU 资源管理机制,使用

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部