进程调度器是 Linux 内核最核心的组件之一,它决定了哪个进程在什么时候获得 CPU 时间。从早期的 O(n) 调度器,到 O(1) 调度器,再到 2.6.23 引入的 CFS(Completely Fair Scheduler),再到 Linux 6.6 开始采用的 EEVDF(Earliest Eligible Virtual Deadline First),Linux 调度器经历了巨大的架构演进。本文将深入剖析调度器的核心数据结构、算法原理,并结合实际案例展示如何观察、调试和优化调度行为。

一、调度器架构总览

Linux 内核的调度器采用分级调度类(sched_class)的设计。每个调度类定义了一组操作函数,调度器按优先级从高到低依次检查每个调度类,选择最高优先级的可执行任务。调度类的优先级顺序如下:

优先级调度类说明策略标志
最高stop_sched_class停机调度类,用于 CPU 热插拔、停机操作N/A
↑dl_sched_classDeadline 调度类,基于 EDF 算法SCHED_DEADLINE
↑rt_sched_class实时调度类,FIFO 或 Round-RobinSCHED_FIFO / SCHED_RR
↑fair_sched_class完全公平调度类(CFS/EEVDF)SCHED_NORMAL / SCHED_BATCH / SCHED_IDLE
最低idle_sched_class空闲调度类,仅在没有任务时运行N/A

这种层次设计保证了实时任务的确定性延迟、死限期任务的截止时间满足,以及普通任务的公平分配。每个 CPU 的运行队列(rq)中嵌入了各个调度类的私有数据结构,形成如下结构:

struct rq {
    raw_spinlock_t        lock;
    unsigned int          nr_running;     /* 当前可运行任务数 */
    unsigned long         nr_load;        /* 负载统计 */
    
    struct cfs_rq         cfs;            /* CFS 运行队列 */
    struct rt_rq          rt;             /* 实时运行队列 */
    struct dl_rq          dl;             /* Deadline 运行队列 */
    
    struct task_struct   *curr;           /* 当前运行的进程 */
    struct task_struct   *idle;           /* 空闲进程 */
    struct mm_struct     *prev_mm;        /* 上一个进程的 mm_struct */
    // ...
} ____cacheline_aligned;

二、CFS 完全公平调度器

2.1 设计理念:理想多任务处理器

CFS 的核心思想非常简单:如果有一个无限快的 CPU(理想多任务处理器),每个 n 个可运行进程应该恰好获得 1/n 的 CPU 时间。CFS 通过维护每个进程的虚拟运行时间(vruntime)来逼近这一理想状态。vruntime 增长越慢,说明进程获得的 CPU 时间越多;vruntime 增长越快,说明获得的越少。调度器每次选择 vruntime 最小的进程运行,从而保证长期上的公平性。

vruntime 的计算公式如下:

Δvruntime = Δwall_time × (NICE_0_LOAD / se.load.weight)

其中 NICE_0_LOAD 是 nice=0 时的权重(值为1024),se.load.weight 是进程的实际权重。低 nice 值(高优先级)的进程权重更大,因此相同真实时间下 vruntime 增长更慢,从而更容易被选中执行。具体映射关系:

Nice 值权重每 1ms 真实时间 vruntime 增量
-2088761~0.0115 ms
-1011058~0.0926 ms
010241.0000 ms
10110~9.3091 ms
1915~68.2667 ms

这意味着 nice=-10 的进程获得的 CPU 时间大约是 nice=0 进程的 (1024/110) ≈ 9.3 倍,而在用户感知上表现为 nice=-10 的进程 vruntime 增长更慢,频繁获得运行机会。

2.2 红黑树与 vruntime 管理

CFS 使用红黑树(rbtree)来组织所有可运行进程,以 vruntime 为键值。红黑树的所有操作都是 O(log n) 时间复杂度,即使系统中存在数千个可运行进程,调度决策的开销仍然可控。

关键数据结构:

/**
 * CFS 运行队列 - 每个 CPU 一个
 */
struct cfs_rq {
    struct load_weight    load;            /* 队列总权重 */
    unsigned int          nr_running;      /* 可运行进程数 */
    u64                   min_vruntime;    /* 队列最小 vruntime */
    
    struct rb_root_cached tasks_timeline;  /* 红黑树根(rbtree) */
    struct rb_node       *rb_leftmost;     /* 最左节点(vruntime 最小) */
    
    /* 组调度相关 */
    struct sched_entity  *curr;            /* 当前正在运行 */
    struct sched_entity  *next;            /* 抢占时被唤醒立即运行 */
    struct sched_entity  *last;            /* 上次运行,用于上下文切换预测 */
};

/**
 * 调度实体 - 嵌入在 task_struct 中
 */
struct sched_entity {
    struct load_weight    load;            /* 权重 */
    struct rb_node        run_node;        /* 红黑树节点 */
    u64                   vruntime;        /* 虚拟运行时间 */
    u64                   exec_start;      /* 本轮执行开始时间 */
    u64                   sum_exec_runtime;/* 累计执行时间 */
    u64                   prev_sum_exec;   /* 上次切换时累计值 */
    
    /* 组调度:指向包含此实体的运行队列 */
    struct cfs_rq         *cfs_rq;
    struct cfs_rq         *my_q;           /* 如果是组,指向子队列 */
};

rb_leftmost 指针指向红黑树的最左节点,这是 vruntime 最小的进程,调度器的 pick_next_task_fair() 可以直接取出它,无需遍历整棵树。这是 CFS 的关键性能优化——通常只需要一次指针解引用。

当新进程被唤醒或被创建时,place_entity() 函数将其 vruntime 设置为当前队列的 min_vruntime 附近。如果新进程已经落后太多(比如从睡眠中醒来),则补偿一部分 vruntime,避免饥饿但又不会过度补偿导致老进程饿死。

2.3 时间片与调度周期

CFS 不使用固定时间片,而是基于调度周期(sched_latency)动态计算。调度周期是指所有可运行进程各运行一轮的总时间:

sysctl_sched_latency       = 24 ms   /* 调度周期默认值 */
sysctl_sched_min_granularity = 3 ms  /* 最小粒度 */
sysctl_sched_wakeup_granularity = 4 ms /* 唤醒粒度 */

/* 计算时间片: */
if (nr_running > (sched_latency / min_granularity))
    time_slice = min_granularity  /* 进程太多时缩到最小 */
else
    time_slice = sched_latency / nr_running
# 查看调度器参数
$ cat /proc/sys/kernel/sched_latency_ns
24000000

$ cat /proc/sys/kernel/sched_min_granularity_ns
3000000

$ cat /proc/sys/kernel/sched_wakeup_granularity_ns
4000000

$ cat /proc/sys/kernel/sched_cfs_bandwidth_slice_us
5000

三、Linux 6.6 的 EEVDF:调度器的又一次革新

3.1 CFS 的痛点与 EEVDF 的诞生

CFS 存在几个长期被诟病的问题:

  • 延迟敏感性不足:CFS 的公平性模型在处理延迟敏感工作负载时表现不够好,特别是交互式进程的响应时间波动较大。
  • 唤醒抢占开销:新唤醒进程需要从 min_vruntime 开始计算,补偿机制复杂且容易误判。
  • 调度延迟抖动:红黑树虽然效率高,但最坏情况下的调度延迟难以保证确定性边界。
  • NUMA 扩展性:在大型 NUMA 系统上,CFS 的负载均衡机制不够精细。

2023 年,Peter Zijlstra 提交了 EEVDF(Earliest Eligible Virtual Deadline First)调度器,并被合并到 Linux 6.6 内核中作为 CFS 的替代方案。EEVDF 论文的作者是 MIT 的 Julia Lawley,其思想来自经典的实时调度理论。

3.2 EEVDF 的核心算法

EEVDF 为每个调度实体引入三个时间参数:

struct sched_entity {
    u64 vruntime;     /* 虚拟运行时间 - 与 CFS 相同 */
    u64 vdeadline;    /* 虚拟截止时间 - 新增 */
    u64 vslice;       /* 虚拟时间片 - 新增 */
    // ...
};

/* 计算 deadline: */
se.vdeadline = se.vruntime + (se.vslice * sched_period) / total_weight;

/* 选择下一个任务:取 vdeadline 最小的进程 */
pick_next = min(entity.vdeadline) for all runnable entities

EEVDF 同时维护两个数据结构:一个按 vruntime 排序的红黑树(用于入队/出队),一个按 vdeadline 排序的红黑树(用于调度决策)。实际上,EEVDF 使用了一种高效的单一红黑树结构,以 (vruntime, vdeadline) 为复合键,兼顾公平性和延迟确定性。

EEVDF 的关键优势在于它是 延迟确定性 的:在任意长度为 L 的窗口内,每个权重为 w、总权重为 W 的任务都保证获得至少 (w/W) × L 的 CPU 时间,且最大连续空闲时间有严格上界。

3.3 EEVDF vs CFS:性能对比

在实际基准测试中,EEVDF 在以下场景有显著提升:

/* 设置进程为 SCHED_FIFO,优先级 50 */ struct sched_param param = { .sched_priority = 50 }; sched_setscheduler(pid, SCHED_FIFO, ¶m); /* 设置进程为 SCHED_RR,优先级 30 */ param.sched_priority = 30; sched_setscheduler(pid, SCHED_RR, ¶m); /* 命令行工具 */ chrt -f 50 my_realtime_app /* SCHED_FIFO priority 50 */ chrt -r 30 -p $$ /* SCHED_RR priority 30 for shell */ /* 查看进程调度策略 */ chrt -p $$

重要限制:实时任务必须具有 CAP_SYS_NICE 能力或以 root 运行。内核还会根据 sched_rt_runtime_us 限制实时任务占用 CPU 的总时间比例:

$ cat /proc/sys/kernel/sched_rt_runtime_us
950000        /* 实时任务最多用 950% 每 1000ms 周期 */

$ cat /proc/sys/kernel/sched_rt_period_us
1000000       /* 周期为 1 秒 */

/* 允许实时任务 100% 使用 CPU(谨慎!) */
echo -1 > /proc/sys/kernel/sched_rt_runtime_us

4.2 SCHED_DEADLINE — 基于 EDF 的限期调度

Linux 3.14 引入了 SCHED_DEADLINE 调度策略(内核CONFIG_SCHED_DL),它是真正的实时 EDF 调度,适用于有时间约束的周期性任务(音频处理、视频编解码、工业控制)。

每个 Deadline 任务声明三个参数:

  • Runtime (Q):每周期内最大运行时间
  • Deadline (D):从周期开始到必须完成的最大时间(D ≤ Period)
  • Period (P):任务触发的周期
#include <linux/sched.h>

struct sched_attr attr = {
    .size = sizeof(attr),
    .sched_policy = SCHED_DEADLINE,
    .sched_runtime  = 10 * 1000 * 1000,  /* 10ms */
    .sched_deadline = 33 * 1000 * 1000,  /* 33ms(约30fps) */
    .sched_period   = 33 * 1000 * 1000,  /* 33ms */
};

ret = sched_setattr(0, &attr, 0);

/* 新内核 (>= 5.7) 也可以使用 sched_ext API */
attr.sched_flags |= SCHED_FLAG_KEEP_POLICY;

内核通过密度测试(Density Test)来判断一组 Deadline 任务是否可调度:

可调度条件: Σ(Qi/Pi) + max(Qi, (Pi - Di))/min(Pi, Di) ≤ M

其中 M 是 CPU 核数。这个条件确保了即使在最坏情况下,所有任务都能在截止时间前完成。

五、调度策略的选择指南

面对多种调度策略,如何选择合适的?以下决策树可以作为参考:

你的任务需要什么?
├── 严格的截止时间保证 → SCHED_DEADLINE (需要精确声明 Q/D/P)
├── 低延迟优先级抢占 → SCHED_FIFO 或 SCHED_RR (实时任务)
├── 后台低优先级任务 → SCHED_IDLE (nice 19 + 不干扰其他进程)
├── 批处理但响应系统事件 → SCHED_BATCH (比 NORMAL 更“自主”)
└── 通用计算/混合负载 → SCHED_NORMAL (默认,由 CFS/EEVDF 管理)

各策略的典型使用场景:

  • SCHED_NORMAL:数据库、Web 服务器、编译、通用应用(默认策略)
  • SCHED_BATCH:科学计算、科学模拟、大规模数据处理(对交互不敏感)
  • SCHED_IDLE:系统监控、低优先级后台清理、日志处理
  • SCHED_FIFO:音频 DSP、实时数据采集、中断线程
  • SCHED_RR:多媒体编码、需要公平轮转的实时任务
  • SCHED_DEADLINE:周期性控制系统、音视频精确帧率控制

六、NUMA 感知调度

在 NUMA(Non-Uniform Memory Access)架构系统中,进程所在 CPU 与内存节点之间的距离对性能影响巨大。Linux 调度器通过以下机制优化 NUMA 性能:

6.1 拓扑感知与负载均衡

内核将 CPU 组织为调度域(sched_domain)层级:

sched_domain 层级 (从上到下):
┌─────────────────────────────────────────────┐
│  DIE Domain (L3 共享的所有核心)              │
│  ┌─────────────────────────────────────────┐│
│  │  MC Domain (共享 L2 的双核/多核组)      ││
│  │  ┌─────────────────────────────────┐   ││
│  │  │  SMT Domain (超线程兄弟核心)     │   ││
│  │  │  [CPU0] [CPU1] [CPU2] [CPU3]    │   ││
│  │  └─────────────────────────────────┘   ││
│  └───────────────────────────────────────-─┘│
└─────────────────────────────────────────────┘

负载均衡按从低层到高层的顺序执行:首先在 SMT 兄弟间平衡,然后在 MC 域内平衡,最后在 DIE/NUMA 域间平衡。这种分层设计避免了不必要的跨 NUMA 节点迁移。

6.2 NUMA 平衡(AutoNUMA)

Linux 的自动 NUMA 平衡(AutoNUMA,配置 CONFIG_NUMA_BALANCING)会定期检查进程的内存访问模式,并将访问远程内存的进程迁移到数据所在的 NUMA 节点:

# 查看 NUMA 平衡状态
$ cat /proc/sys/kernel/numa_balancing
1  /* 1=启用, 0=禁用 */

# 查看进程的 NUMA 统计
$ cat /proc/self/numa_maps
7f0000000000 default file=/usr/lib/libc.so mapped=64 N0=48 N1=16

# 使用 numastat 查看全局统计
$ numastat
                           node0           node1
numa_miss                  1234            5678      /* 远程访问次数 */
numa_foreign               9876            5432      /* 本应为本地但去了其他节点 */
numa_interleave_hit        100              50       /* 跨节点交错分配命中 */
local_node              1000000         900000          /* 本地访问 */
other_node                50000          150000          /* 非本地访问 */

# 使用 numactl 手动绑定
numactl --cpunodebind=0 --membind=0 ./my_app     /* 使用 NUMA 节点0 */
numactl --interleave=all ./my_app                /* 跨所有节点交错 */
numactl --preferred=1 ./my_app                   /* 优先使用节点1 */

在大型数据库(如 PostgreSQL、Redis)场景中,NUMA 调优可以获得 20-40% 的性能提升。最佳实践是:OLTP 数据库通常绑定到单个 NUNU 节点,以避免跨节点延迟。

七、组调度与资源控制(cgroups)

Linux 的组调度(Group Scheduling)允许对一组进程统一分配 CPU 资源,这通过 cgroups(Control Groups)实现。

7.1 CPU Bandwidth 控制 (cpu.max)

cgroup v2 的 cpu.max 控制器提供精确的 CPU 带宽限制:

# 创建 cgroup
mkdir /sys/fs/cgroup/my_group

# 限制:每 100ms 周期内最多用 50ms CPU 时间(半核)
echo "50000 100000" > /sys/fs/cgroup/my_group/cpu.max

# 将进程加入 cgroup
echo $$ > /sys/fs/cgroup/my_group/cgroup.procs

# 查看当前使用量
cat /sys/fs/cgroup/my_group/cpu.stat
  # nr_periods 253         /* 已经经历的周期数 */
  # nr_throttled 10        /* 被节流的周期数 */
  # throttled_usec 500000  /* 总节流时间(微秒) */
  # usage_usec 5234000     /* 总 CPU 使用时间 */

7.2 实时任务的 RT Throttling

对于使用 SCHED_FIFO/RR 的实时任务,需要特别小心防止它们占满整个 CPU 导致系统死锁。内核通过 sched_rt_runtime_us 和 sched_rt_period_us 来全局限制实时任务:

/* 通过 cgroup v2 控制实时 CPU */
echo "80000 100000" > /sys/fs/cgroup/rt_group/cpu.max
/* 含义:100ms 周期内最多 80ms 用于 RT 任务,剩余 20ms 留给普通任务 */

八、调度器的 BPF 追踪与观测

8.1 fentry/fexit 追踪调度函数

使用 BPF 可以在不修改内核代码的情况下追踪调度器行为。以下是一个追踪上下文切换延迟的 BPF 程序示例:

// scheduler_trace.bpf.c
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u32);    /* pid */
    __type(value, u64);  /* timestamp */
} start SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_HISTOGRAM);
    __uint(max_entries, 64);
} delay SEC(".maps");

SEC("tp_btf/sched_switch")
int BPF_PROG(trace_sched_switch, bool preempt,
             struct task_struct *prev,
             struct task_struct *next)
{
    u32 pid = prev->pid;
    u64 *tsp, delta;
    
    tsp = bpf_map_lookup_elem(&start, &pid);
    if (tsp) {
        delta = bpf_ktime_get_ns() - *tsp;
        bpf_hist2_log(delay, delta);  /* 记录到直方图 */
        bpf_map_delete_elem(&start, &pid);
    }
    
    pid = next->pid;
    tsp = bpf_map_lookup_elem(&start, &pid);
    if (tsp)
        *tsp = bpf_ktime_get_ns();
    return 0;
}

char _license[] SEC("license") = "GPL";

8.2 perf sched 调度分析工具

perf sched 提供了丰富的调度分析功能:

# 记录调度事件(持续10秒)
perf sched record -- sleep 10

# 查看调度延迟统计
perf sched latency

# 输出示例:
#  Task                  |   Average wait ms |   Maximum wait ms |   Count |
#  -----------------------|-------------------|-------------------|---------|
#  firefox(1234)   |   12.5  |   45.2 |  342 |
#  Xorg(5678)  |    3.2  |    8.7 |  512 |
#  make(9012)  |   25.1  |  120.5 |   89 |

# 映射视图 - 显示各 CPU 上的调度情况
perf sched map

# 显示调度时间线
perf sched timeline

# 查看调度器统计
cat /proc/schedstat
version 15
timestamp 1234567890
cpu0 0 0 12345 6789 234 56 1234 5678 2345 0 0
#   nr_running, nr_switches, LOAD_AVG, idle_wait, 
#   sched_goidle, sched_idle, rq_cpu_time, avg_period, ...

8.3 /proc/[pid]/sched 运行时信息

/proc 文件系统中的 sched 文件提供了每个进程的调度统计:

$ cat /proc/self/sched
my_process (12345, #threads: 1)
--------------------------------------------------------------
se.exec_start                      :       1234567890.123
se.vruntime                        :            5678.901
se.sum_exec_runtime                :            12345.678
se.nr_migrations                   :                42      /* 核间迁移次数 */
nr_switches                        :              1234      /* 总上下文切换 */
nr_voluntary_switches              :              1200      /* 自愿切换 */
nr_involuntary_switches           :                34      /* 非自愿切换(被抢占) */
se.avg.util_avg                    :               512      /* 平均利用率 */
policy                             :                 0      /* SCHED_NORMAL */
prio                               :               120      /* Nice 0 对应 prio 120 */
clock-delta                        :                73

# 解读:
# vruntime 增长慢 → 获得较多 CPU
# migrations 高 → 在多个 CPU 间频繁迁移
# involuntary_switches 高 → 经常被抢占(可能是调度延迟高或时间片不足)

九、调度器性能调优实战

9.1 低延迟桌面/工作站优化

对于桌面环境,交互式进程的响应速度是关键。推荐的 sysctl 配置:

/etc/sysctl.d/99-scheduler-tuning.conf

# 缩短调度周期,提高交互响应速度
kernel.sched_latency_ns = 6000000        /* 6ms(默认24ms) */
kernel.sched_min_granularity_ns = 750000 /* 0.75ms */
kernel.sched_wakeup_granularity_ns = 1000000 /* 1ms */

# 启用自动组调度(将同一会话的进程归为一组)
kernel.sched_autogroup_enabled = 1

# MILLFORCE 特性:降低高负载时的延迟
kernel.sched_migration_cost_ns = 500000  /* 切换缓存热度阈值 */

# 启用核心调度(安全)
kernel.sched_core = 1

9.2 高吞吐量服务器优化

对于 Web 服务器、数据库等场景,最大化吞吐量更重要:

# 增大调度周期,减少上下文切换开销
kernel.sched_latency_ns = 48000000       /* 48ms */
kernel.sched_min_granularity_ns = 6000000 /* 6ms */
kernel.sched_wakeup_granularity_ns = 8000000

# 启用 NUMA 平衡
kernel.numa_balancing = 1
kernel.numa_balancing_scan_delay_ms = 1000
kernel.numa_balancing_scan_period_min_ms = 2000
kernel.numa_balancing_scan_period_max_ms = 60000

# 调整迁移成本(减少不必要迁移)
kernel.sched_migration_cost_ns = 5000000 /* 5ms */

9.3 实时系统优化

实时系统需要最严格的延迟保证:

# CPU 隔离(isolcpus),将指定核心留给实时任务
# GRUB: isolcpus=2,3 nohz_full=2,3 rcu_nocbs=2,3

# 禁用不必要的中断
echo 2,3 > /sys/devices/system/cpu/isolated

# 设置实时任务优先级
chrt -f 99 my_realtime_task    /* SCHED_FIFO 最高优先 */

# 锁定内存,避免 page fault
mlockall(MCL_CURRENT | MCL_FUTURE)

# CPU 绑核
taskset -c 2 my_realtime_task  /* 只在 CPU2 上运行 */

# 查看系统最大调度延迟
cyclictest -t5 -p 80 -i 200 -n -a 0-4
# 显示线程数、优先级、间隔,测量实际延迟

十、sched_ext:可扩展调度器框架

Linux 6.12 引入了 sched_ext(Scheduler Extensibility),允许用户空间通过 BPF 自定义调度策略,无需修改内核代码:

/* 查看 sched_ext 状态 */
cat /sys/kernel/sched_ext/root/ops
/* 显示当前活跃的扩展调度器名称,如 "scx_simple", "scx_rusty" 等 */

/* 加载自定义调度器 */
sched_ext_loader scx_custom

/* 退出,恢复默认调度器 */
echo none > /sys/kernel/sched_ext/root/ops

sched_ext 的工作原理是通过一组 BPF 程序实现调度器的核心操作(enqueue、dequeue、dispatch、tick 等),用户只需实现自己的排序和选择逻辑即可。这使得研究人员和运维人员可以快速实验新的调度策略,比如:

  • 针对特定游戏引擎的帧同步调度器
  • 数据库工作负载感知的 NUMA 局部性调度器
  • 基于 AI 预测的自适应调度器
  • 能耗优先的电池感知调度器

总结

Linux 内核调度器经过三十多年的发展,已经成为工业界最成熟、最高效的调度器实现之一。从 CFS 的红黑树 vruntime 公平性模型,到 EEVDF 的延迟确定性保证,再到 sched_ext 的可扩展框架,每一代演进都建立在前一代的理论基础之上。

理解调度器的核心原理——vruntime 计算、调度类层次、NUMA 拓扑感知、cgroups 资源控制——不仅有助于解决生产环境中的性能问题,也是深入 Linux 内核开发的重要基石。对于性能工程师来说,掌握 perf sched、BPF 追踪和 /proc 文件系统调优技术,是不可或缺的核心技能。

建议的深入学习路径:从阅读内核源码 kernel/sched/ 目录开始,重点关注 core.c、fair.c、rt.c 和 deadline.c 四个文件,结合 BPF 工具进行实际观测,逐步建立对调度器运行机制的直觉。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论