Linux内核进程调度器演进:CFS与实时调度深度解析

进程调度器是操作系统的核心组件,决定了CPU时间如何在多个可执行实体之间分配。Linux内核的调度器经历了从O(n)到O(1)、再到完全公平调度器(CFS)的三次重大架构演进。本文将深入剖析CFS的设计哲学、vruntime机制、红黑树时间管理、实时调度策略族(SCHED_FIFO/SCHED_RR/SCHED_DEADLINE)、内核抢占模型、Tickless调度以及EAS能耗感知调度,并通过完整的内核源码分析和性能调优案例展示如何在生产环境中优化调度行为。

1. 调度器的设计目标与演进历史

Linux进程调度器的核心矛盾在于:既要保证交互任务的响应延迟,又要最大化批处理任务的吞吐量;既要公平地分配CPU时间,又要充分利用多核处理器的并行能力;既要满足实时任务的确定性时限,又要降低嵌入式系统的能耗。

1.1 O(n)调度器(Linux 2.4)

最早的Linux调度器遍历所有可执行进程,选择counter值最高的进程执行。每次调度决策的时间复杂度为O(n),随着进程数量增长,调度开销线性增大。核心数据结构是一个全局运行队列(runqueue),所有进程竞争同一把锁。

// Linux 2.4 伪代码
for_each_task(p) {
    if (p->counter > best_counter)
        best_counter = p->counter;
}

1.2 O(1)调度器(Linux 2.6.0 ~ 2.6.22)

Ingo Molnár设计的O(1)调度器引入了两个活动/过期优先级数组,每次调度决策在常数时间完成。每个CPU核心拥有独立的运行队列,包含140个优先级的活动数组和过期数组。进程的时间片用完后移至过期数组,当活动数组为空时交换指针。该方案解决了O(n)的可扩展性问题,但交互进程检测的启发式算法复杂且容易出错。

1.3 CFS完全公平调度器(Linux 2.6.23+至今)

CFS摒弃了传统的时间片和优先级轮转概念,转而采用虚拟运行时间(vruntime)作为核心度量。CFS不直接分配时间片,而是保证每个进程的vruntime尽可能相等——这就是"完全公平"的含义。调度决策选择vruntime最小的进程执行,数据结构采用红黑树实现O(log n)的查找和插入。

2. CFS核心机制:vruntime与红黑树

2.1 虚拟运行时间的数学定义

2.1 虚拟运行时间的数学定义

vruntime(虚拟运行时间)记录了进程在CPU上执行过的"加权"时间。权重由进程的nice值决定,nice值越低(优先级越高),单位实际时间产生的vruntime越小——也就是说,高优先级进程的vruntime增长更慢,因而在红黑树中停留时间更长,获得更多的CPU份额。

// kernel/sched/fair.c - 核心公式
vruntime_delta = physical_runtime * (NICE_0_LOAD / task_weight)

// 其中:
// NICE_0_LOAD = 1024(nice=0的基准权重)
// task_weight由nice值查sched_prio_to_weight[]表获得

sched_prio_to_weight表遵循10%增量原则:每降低一个nice值,进程多获得约10%的CPU时间。例如nice=-20的权重约为88761,nice=0的权重为1024,nice=19的权重仅为15。这意味着nice=-20进程获得的CPU时间约为nice=19进程的5000多倍。

2.2 红黑树数据结构

CFS将所有可执行进程组织在一棵以vruntime为键的红黑树中。树的最左节点始终对应vruntime最小的进程,就是下一个被选中的调度实体。

// include/linux/rbtree.h & kernel/sched/fair.c
struct cfs_rq {
    struct rb_root_cached tasks_timeline; // 红黑树根(带缓存最左节点)
    struct sched_entity *curr;           // 当前运行的调度实体
    struct sched_entity *next;           // 抢占后下一个唤醒的实体
    struct sched_entity *last;           // 上次切换出去的实体
    u64 min_vruntime;                     // 该CFS队列的最小vruntime
    unsigned long cfs_rq_load_avg;       // 队列平均负载
};

struct sched_entity {
    struct rb_node run_node;             // 红黑树节点键(key)=vruntime
    u64 vruntime;                        // 虚拟运行时间
    u64 exec_start;                      // 最近一次开始执行的时间戳
    u64 sum_exec_runtime;                // 累计实际执行时间
    u64 prev_sum_exec_runtime;           // 上次切换前的累计执行时间
    u64 load_weight;                     // 调度权重
    struct cfs_rq *cfs_rq;               // 归属CFS队列
    struct cfs_rq *my_cfs_rq;            // 组调度时自身的CFS队列
};

红黑树的关键操作复杂度:

  • 插入进程(enqueue_entity):O(log n) — 进程被唤醒时插入
  • 移除进程(dequeue_entity):O(log n) — 进程睡眠时移除
  • 选择下一个进程(__pick_next_entity):O(1) — 通过rb_leftmost缓存直接取最左节点
  • 更新当前进程vruntime:O(log n) — vruntime更新后需要重新调整红黑树位置

2.3 调度延迟与最小粒度

CFS在公平性和开销之间设置了两个关键调优参数:

// 调度延迟:目标是在此时间片内让所有可执行进程至少运行一次
// 默认值:6毫秒 × (1 + log2(nr_running))
// 内核参数:kernel.sched_latency_ns = 24000000 (24ms)

// 最小粒度:单个进程在两次抢占之间的最小运行时间
// 默认值:0.75ms × (1 + log2(nr_running))
// 内核参数:kernel.sched_min_granularity_ns = 3000000 (3ms)

// 唤醒抢占粒度:唤醒新进程时的最小粒度阈值
// 默认值:4ms × (1 + log2(nr_running))
// 内核参数:kernel.sched_wakeup_granularity_ns = 5000000 (5ms)

当运行队列中的进程数超过4个时,调度延迟线性增长,而最小粒度保持不变。这意味着在高负载下,虽然每个进程等待更久才能轮到,但一旦获得CPU至少运行3ms,保证了基本的缓存利用效率。

3. 普通进程的调度策略

3.1 SCHED_NORMAL(SCHED_OTHER)

标准分时策略,所有普通用户进程默认使用。CFS把SCHED_NORMAL的进程视为完全公平的竞争者,通过vruntime控制CPU份额。该策略下,进程的 CPU 时间 = CPU总时间 × (进程权重 / 队列总权重),无时间片概念,只有不断更新的vruntime和调度延迟约束。

3.2 SCHED_BATCH

批处理策略适合长时间运行的计算密集型后台任务。与SCHED_NORMAL相比,SCHED_BATCH会产生更少的抢占信号,允许进程持续运行更长时间,减少上下文切换开销。对交互式桌面场景,SCHED_BATCH进程会被CFS隐式惩罚:它们的vruntime增长得更慢,从而让前台交互进程获得响应优先级。

3.3 SCHED_IDLE

空闲级策略是SCHED_NORMAL的极端弱化形式。使用SCHED_IDLE的进程只有在没有任何非SCHED_IDLE进程处于可运行状态时才能执行。权重仅为nice=20进程的1/32,几乎不会干扰系统正常操作。典型应用包括系统备份、索引构建、日志压缩等后台任务。

// 设置SCHED_IDLE的POSIX API
struct sched_param param = { .sched_sched_priority = 0 };
sched_setscheduler(pid, SCHED_IDLE, &param);

// 或使用schedtool
$ schedtool -I -e ./backup_script

4. 实时调度策略族

Linux遵循IEEE 1003.1b (POSIX.1b) 实时调度标准,将实时进程组织在独立的优先级空间(0 ~ 99),与SCHED_NORMAL/CFS进程严格隔离。实时进程总是优先于普通进程被调度,且不会被vruntime机制影响。

4.1 SCHED_FIFO(先进先出)

SCHED_FIFO是非抢占式的实时策略。同一优先级的进程严格按FIFO顺序执行:先到达的进程一直运行到主动放弃CPU(如调用sched_yield()、进入睡眠或被更高优先级进程抢占)。高优先级进程可以抢占任何低优先级进程。

关键特性:

  • 无时间片限制:进程一旦获得CPU,除非阻塞、yield或被更高优先级抢占,否则永不放弃
  • 优先级继承:通过PTHREAD_PRIO_INHERIT协议防止优先级反转
  • Watchdog防护:内核的RT_RUNTIME watchdog可以防止SCHED_FIFO进程死循环导致系统hang
// SCHED_FIFO实时优先级设置
struct sched_param param = { .sched_priority = 50 };
pthread_setschedparam(pthread_self(), SCHED_FIFO, &param);

// 系统级RT带宽限制(防止用户态RT进程耗尽CPU)
/sys/fs/cgroup/cpu,cpuacct/cpu.rt_runtime_us = 950000 (95%)
/sys/fs/cgroup/cpu,cpuacct/cpu.rt_period_us = 1000000 (1s)

4.2 SCHED_RR(轮询)

SCHED_RR是SCHED_FIFO的改进版,同优先级进程之间按时间片轮转。每个SCHED_RR进程被分配一个默认100ms的时时间片,用完后放到同优先级队列末尾。高优先级进程仍然可以抢占低优先级进程。

SCHED_RR的时间片管理:

// kernel/sched/rt.c - RR时间片管理
#define RR_TIMESLICE    (100 * HZ / 1000) // 100ms

static void task_tick_rr(struct rq *rq, struct task_struct *p, int queued) {
    // 每次tick减少时间片,归零时重新入队尾
    if (--p->rt.time_slice)
        return;
    p->rt.time_slice = RR_TIMESLICE;
    // 同优先级轮转
    requeue_task_rt(rq, p, queue);
    set_tsk_need_resched(p);
}

4.3 SCHED_DEADLINE(截止时间调度)

SCHED_DEADLINE是基于最早截止时间优先(EDF)协议的实时策略,自Linux 3.14引入。每个DEADLINE任务声明三个参数:

  • Runtime (Q):任务每个周期内需要的最大执行时间
  • Deadline (D):任务期望在每个周期内完成的截止时间
  • Period (P):任务运行的周期(即两次激活之间的间隔)

内核在准入阶段执行可调度性检验(基于Global EDF的Density Test):

// kernel/sched/deadline.c - 准入控制
// 全局准入条件:Σ(Q_i / P_i) ≤ M (M为CPU数量)
// 每个CPU准入条件:Q_i ≤ D_i ≤ P_i

static int dl_task_can_attach(struct task_struct *p, ...) {
    // 验证Q ≤ D ≤ P
    if (dl_param->deadline < dl_param->runtime ||
        dl_param->period  < dl_param->deadline)
        return -EINVAL;
    // 计算系统总密度
    // density = runtime / min(deadline, period)
    // Σ density ≤ available_capacity
}

SCHED_DEADLINE的内部机制使用两个红黑树:

  • active RB-tree:以绝对截止时间排序,选择最早截止时间的任务运行
  • expired RB-tree:已过截止期的任务,等待下一周期到来后重新激活

SCHED_DEADLINE在工业控制、音视频采集、机器人控制等硬实时场景中具有不可替代的优势:相比FIFO/RR策略,DEADLINE在保证硬实时约束的同时,允许更多任务在系统中并发运行(全局准入检验让系统资源利用率更高)。

5. 内核抢占模型

Linux内核配置决定了内核代码在执行时是否可以被中断并转而执行更高优先级的进程。抢占模型直接影响系统的实时性。

5.1 PREEMPT_NONE(服务器/吞吐优先)

内核不可抢占。进程在内核态执行时不会被抢占,必须等待返回用户态或主动调用cond_resched()。最大延迟取决于系统调用执行时间。适用于吞吐优先的服务器工作负载。

5.2 PREEMPT_VOLUNTARY(桌面平衡模式)

在内核中显式插入抢占点(cond_resched()),减少内核临界区内的最大不可抢占时间,降低到几毫秒级别。这是大多数桌面发行版的默认选择。

5.3 PREEMPT(低延迟桌面)

内核几乎任意位置均可被抢占(除持有自旋锁的临界区)。系统调用中的大部分代码段都可以被打断。最大延迟降低到1~3ms级别,适合音视频编辑、游戏等场景。

5.4 PREEMPT_RT(实时内核补丁)

PREEMPT_RT补丁集将内核转化为一个软实时操作系统:

  • 自旋锁转换为RT-mutex:将spinlock_t替换为可睡眠的互斥锁,消除自旋等待对抢占的阻断
  • 中断线程化:将硬件中断处理转换为内核线程(如IRQ-thread),赋予它们调度优先级
  • hrtimer高精度定时器:提供微秒级精度的定时机制,替代传统的jiffies时间片
  • 优先级继承(pi-mutex):CFS和RT调度类共享PI机制,防止优先级反转

自从Linux 6.12开始,PREEMPT_RT补丁集已正式合并主线内核。这意味着主流发行版可以直接启用实时内核功能:

$ grep CONFIG_PREEMPT /boot/config-$(uname -r)
CONFIG_PREEMPT_RT=y          # 实时抢占已启用
CONFIG_HZ=1000               # 高精度tick频率

# 查看当前最大调度延迟
$ cyclictest --mlockall --smp --priority=99 --interval=200 --loops=100000
Min   Avg   Max   Act
  3    8    42    35 # 微秒(μs)级别

6. Tickless调度与NO_HZ

传统的Linux内核依赖固定频率的时钟中断(CONFIG_HZ,通常为250/300/1000Hz)来驱动调度器tick。每个tick触发时检查是否需要进行调度切换。然而,固定节拍带来了功耗问题和计算开销。

6.1 NO_HZ_IDLE(动态Tick)

当CPU运行队列为空时,内核关闭周期tick,进入完全空闲直到下一个定时器或有外部中断唤醒。这已经是所有现代内核的默认行为。

6.2 NO_HZ_FULL(全动态Tick)

NO_HZ_FULL进一步允许CPU在有任务运行时也停止周期tick。前提是该CPU运行队列只有一个可执行任务——因为没有竞争就不需要频繁的调度检查。NO_HZ_FULL将调度tick频率大幅降低到约1Hz,对高性能计算(HPC)和实时性关键应用有直接的性能收益:

  • 减少tick开销:消除周期中断导致的±几微秒延迟抖动
  • 降低功耗:CPU可以更长时间驻留在深度睡眠状态
  • 提高确定性:任务运行时不受调度tick的随机打断
/sys/devices/system/cpu/nohz_full    # 隔离CPU列表
/sys/devices/system/cpu/isolcpus      # 内核启动隔离参数
isolcpus=1,2,3 nohz_full=1,2,3 rcu_nocbs=1,2,3

7. EAS能耗感知调度

EAS(Energy-Aware Scheduling)是面向ARM big.LITTLE和异构多核架构的调度扩展,自Linux 4.7引入。EAS同时考虑CPU的性能域(Performance Domain)和能耗模型(Power Model),做出兼顾性能和功耗的调度决策。

7.1 能耗感知域层级

┌─────────────────────────────────────────────┐
│              Performance Domains              │
├─────────────────┬───────────────────────────┤
│   Little Cluster │       Big Cluster         │
│  Cortex-A55×4   │     Cortex-A76×4         │
│  容量: 512       │     容量: 2048            │
│  功率: 低        │     功率: 高              │
├─────────────────┴───────────────────────────┤
│         Clock Domain (共享时钟)               │
├─────────────────────────────────────────────┤
│         CPU Capacity (抽象性能单位)           │
└─────────────────────────────────────────────┘

7.2 EAS决策流程

EAS在每次唤醒或迁移时,通过Energy Model (EM)表获得每个OPP(Operating Performance Point)的功耗数据,然后计算将任务分配给目标CPU所需的功耗增量:

// 能耗计算核心逻辑
ΔE_cpu = Power_cpu(new_util) - Power_cpu(old_util)

if (ΔE_cpu < 0 || find_better_pd(target_cpu))
    migrate_task(task, target_cpu);
else
    keep_task_on_current_cpu(task);

EAS的触发条件:当一个CPU空闲且EAS认为唤醒它的功耗代价过高时,任务会被推到已活跃的CPU上运行,从而让更多CPU保持空闲并进入深度省电模式。这对混合架构笔记本和移动设备的续航至关重要。

7.3 SMP与HMP的博弈

EAS要求调度器和CPUFreq子系统紧密耦合。如果系统没有注册能耗模型(Energy Model),或者性能域超过4个核心,调度器会自动回退到传统的负载均衡策略(SMP balance)。因此EAS只适用于明确的异构架构或精心调优的嵌入式设备。

8. 多核负载均衡

多核系统中,进程不仅需要选择何时运行,还需要决定在哪颗核心上运行。负载均衡层位于CFS之上,负责将任务从繁忙核心迁往空闲核心。

8.1 调度域与调度组

Linux内核将CPU组织为以下层级化的调度域结构:

  • MC Domain (MultiCore):同一物理CPU封装内的核心,如8个性能核心
  • DIE Domain:同一裸片内的所有MC域
  • PKG Domain (Package):同一物理CPU插槽内的所有DIE域
  • NUMA Domain:跨节点的全系统负载均衡

负载均衡从MC域开始逐级向上检查:先在共享L3 Cache的核心间均衡,再跨CCX,最后跨NUMA节点。这最大程度减少了Cache失效和内存带宽开销。

8.2 负载均衡触发时机

负载均衡在以下四种情境下被触发:

  • IDLE Balance:CPU即将空闲时,从其他核心拉取任务
  • Periodic Balance:定时器驱动(每个tick或4ms周期),扫描各域的负载统计
  • New Idle Balance:CPU首次进入空闲时的积极均衡
  • Fork Balance:新进程创建时选择负载最轻的CPU

8.3 负载指标

CFS使用PELT(Per-Entity Load Tracking)和WALT(Window-Assisted Load Tracking)两种负载跟踪算法:

// PELT(Per-Entity Load Tracking)- 默认算法
// y^n = y^(n-1) * decay_avg + active_time
// 时间常数:半衰期32ms,周期1024μs(1ms)$

// WALT(高通主导,主要用于EAS)
// 基于滑动窗口的负载计算,对短时突发更敏感
// window_size = 20ms (20ms内平均)

/sys/kernel/debug/sched/last_load_update    # PELT调试
/proc/sys/kernel/sched_use_walt            # 切换为WALT

9. 调度类层次与pick_next_task

Linux调度器采用优先级分层的调度类设计。每种策略对应一个调度类,内核通过全局链表遍历所有类,选择最高优先级类中的第一个可执行实体。

// kernel/sched/core.c - 调度类优先级链表 (从高到低)
1.  stop_sched_class      ─→ 停止任务(用于CPU热插拔、migration)
2.  dl_sched_class       ─→ SCHED_DEADLINE (EDF调度类)
3.  rt_sched_class       ─→ SCHED_FIFO / SCHED_RR (实时调度类)
4.  fair_sched_class     ─→ SCHED_NORMAL / SCHED_BATCH / SCHED_IDLE
5.  idle_sched_class     ─→ SCHED_IDLE (idle时运行的swapper进程)

// 统一调度入口
static void __schedule(bool scheduling) {
    next = pick_next_task(rq); // 按优先级遍历所有类
    // ...
    context_switch(rq, prev, next);
}

static inline struct task_struct *pick_next_task(struct rq *rq) {
    // 遍历sched_class链表,找到第一个可以运行的task
    for_each_class(class) {
        next = class->pick_next_task(rq);
        if (next) return next;
    }
}

这种设计的关键在于:SCHED_DEADLINE任务总是优先于RT任务,RT任务优先于CFS任务,而idle任务只在系统空闲时运行。每个类的pick_next_task实现互不干扰,DEADLINE使用绝对截止时间红黑树,RT使用优先级位图+链表,CFS使用vruntime红黑树。

10. 性能调优与生产实践

10.1 常用调优参数

# === CFS调优 ===
# 最小粒度(默认3ms):减少可增加吞吐,增加降低延迟
sysctl -w kernel.sched_min_granularity_ns=1000000  # 1ms

# 调度延迟(默认24ms):越小响应越快,越大吞吐越高
sysctl -w kernel.sched_latency_ns=12000000          # 12ms

# 唤醒抢占粒度(默认5ms):防止新唤醒进程过度抢占
sysctl -w kernel.sched_wakeup_granularity_ns=4000000 # 4ms

# 迁移成本:控制跨NUMA迁移的积极程度
sysctl -w kernel.sched_migration_cost_ns=500000      # 0.5ms

10.2 实时进程的保护

# 限制实时进程占用的CPU带宽(默认95%)
echo 950000 > /sys/fs/cgroup/cpu,cpuacct/cpu.rt_runtime_us
echo 1000000 > /sys/fs/cgroup/cpu,cpuacct/cpu.rt_period_us

# 查看进程调度属性
chrt -p 1234
# pid 1234's current scheduling policy: SCHED_FIFO
# pid 1234's current scheduling priority: 50

# 启动SCHED_DEADLINE任务
sched_setattr() / chrt --deadline --sched-runtime=10000000 \
  --sched-deadline=20000000 --sched-period=20000000

10.3 CPU隔离与绑核

高性能计算场景中,将关键任务绑定到专属CPU核心,配合isolcpus参数和cgroup cpuset进行完整的隔离:

/proc/cmdline:
  isolcpus=2-5 nohz_full=2-5 rcu_nocbs=2-5

# 使用cgroup cpuset绑定任务
echo 2-5 > /sys/fs/cgroup/cpuset/rt_tasks/cpuset.cpus
echo 0   > /sys/fs/cgroup/cpuset/rt_tasks/cpuset.mems
echo 1234 > /sys/fs/cgroup/cpuset/rt_tasks/tasks

# taskset绑核
taskset -c 4,5,6,7 ./benchmark

10.4 eBPF调度监控

利用eBPF可以对调度行为进行深度监控,无需修改内核代码:

// BPF程序:统计每次上下文切换的延迟
SEC("tp_btf/sched_switch")
int BPF_PROG(trace_sched_switch, bool preempt,
    struct task_struct *prev, struct task_struct *next)
{
    u64 pid = bpf_get_current_pid_tgid() >> 32;
    u64 now = bpf_ktime_get_ns();
    u64 *last = bpf_map_lookup_elem(&switch_time, &pid);
    if (last) {
        u64 latency = now - *last;
        bpf_map_update_elem(&run_latency, &pid, &latency, BPF_ANY);
    }
    bpf_map_update_elem(&switch_time, &pid, &now, BPF_ANY);
    return 0;
}

11. 未来演进:Sched_ext与用户态调度

Linux 6.12引入了Sched_ext(Scheduler Extensibility),允许在用户空间定义定制调度策略并以eBPF方式加载到内核。这是调度器架构的一个里程碑式变化:

// Sched_ext调度器定义
struct sched_ext_ops scx_ops = {
    .name                  = "scx_rusty",
    .select_cpu            = rusty_select_cpu,
    .enqueue               = rustx_enqueue,
    .dispatch              = rustx_dispatch,
    .update_idle           = rustx_update_idle,
    .set_weight            = rustx_set_weight,
    .init_task             = rustx_init_task,
    .exit_task             = rustx_exit_task,
};

Sched_ext通过BPF将用户提交的C/rust代码编译后注入内核,在不修改内核源码的前提下实现新的调度算法,目前已包含scx_rusty(公平分类调度)、scx_simple(EDF+空闲优先)、scx_lavd(延迟敏感调度)等参考实现。对用户空间开发者来说,这意味着你可以为特定负载场景定制调度器而无需维护内核补丁,尤其在数据中心的异构硬件调优方面有广阔前景。

12. 总结

Linux内核的调度架构已经从简单的遍历选择演化为一个分层、多策略、高度可配置的复杂系统。CFS通过vruntime和红黑树实现了对所有普通进程的完全公平调度;SCHED_FIFO/SCHED_RR/SCHED_DEADLINE三类实时策略满足了从软到硬的实时需求;PREEMPT_RT补丁让内核态具备了抢占能力;Tickless和EAS优化了功耗表现;而Sched_ext则标志着调度器进一步向用户空间开放。

理解这些组件的设计哲学和交互方式,是进行系统级性能调优、实时性保证和异构平台优化的基础。随着数据中心和边缘计算的持续演进,Linux调度器仍将不断进化,以满足日益复杂多样的工作负载需求。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部