Linux CFS 调度器深度解析:完全公平调度的实现原理与工程实践

引言

进程调度是操作系统的核心组件之一,它决定了哪个进程在何时获得 CPU 时间。在 Linux 内核的发展历程中,调度器的设计经历了多次重大变革。2.6.23 内核引入的 CFS(Completely Fair Scheduler,完全公平调度器)标志着调度设计理念的一次飞跃——从追求复杂的启发式算法转向简洁的数学公平模型。

本文将从调度器设计哲学出发,深入剖析 CFS 的核心数据结构、调度算法、组调度机制、负载均衡策略,并介绍 6.6 内核引入的 EEVDF(Earliest Eligible Virtual Deadline First)调度器,帮助读者全面理解 Linux 进程调度的工程本质。

一、从 O(1) 调度器到 CFS 的设计演化

1.1 O(1) 调度器的局限

在 2.6.23 之前,Linux 使用 O(1) 调度器。虽然它能在常数时间内完成调度决策,但存在以下核心问题:

  • 启发式规则复杂:通过 interactive/非交互式的启发式判断赋予进程不同的优先级和奖励,代码维护困难
  • 公平性难以保证:优先级粒度粗,不同 nice 值之间的 CPU 分配比例非线性
  • 扩展性差:活动/过期数组的设计在多核场景下难以优雅扩展

1.2 CFS 的设计哲学

CFS 由 Ingo Molnár 提出,核心思想极其简洁:

"CFS 模拟了一个完全公平的多任务 CPU 下的调度行为——每个任务在极短的时间片内都能获得等量的 CPU 时间。"

这一哲学的关键推论是:CFS 不维护传统的时间片概念,而是通过虚拟运行时间(vruntime)来追踪每个进程的"应得"CPU 量。

二、CFS 核心数据结构

2.1 红黑树(rbtree)

CFS 使用红黑树来组织所有可运行进程,以 vruntime 作为排序键。红黑树提供了 O(log n) 的插入、删除和查找操作效率:

struct cfs_rq {
    struct load_load load;        // CFS 运行队列的负载权重
    unsigned long runnable_weight;
    unsigned int nr_running;     // 可运行任务数量
    unsigned int h_nr_running;   // 包含组调度的总数
    
    u64 exec_clock;              // 执行时钟
    u64 min_vruntime;            // 最小虚拟运行时间(红黑树最左端)
    struct rb_root_cached tasks_timeline; // 红黑树根节点(带缓存最左端)
    struct rb_node *curr;        // 当前运行任务
    struct rb_node *next;        // 下一个要运行的任务(用于抢占提示)
    struct rb_node *last;        // 刚刚完成的任务
    struct rb_node *skip;        // 需要跳过的任务(如设置了 SKIP)
    // ... 其他字段
};

2.2 调度实体(sched_entity)

每个进程(或调度组)在 CFS 红黑树中由一个调度实体表示:

struct sched_entity {
    struct load_weight load;     // 负载权重(基于 nice 值)
    unsigned long runnable_weight;
    struct rb_node run_node;     // 红黑树节点
    struct list_head group_node; // 组调度链表节点
    unsigned int on_rq;          // 是否在运行队列上
    
    u64 exec_start;              // 本次开始执行的时间
    u64 sum_exec_runtime;        // 累计实际执行时间
    u64 vruntime;                // 虚拟运行时间
    u64 prev_sum_exec_runtime;   // 上次切换时的 sum_exec_runtime
    
    u64 migratios;               // 迁移次数统计
    // ... 其他字段
};

2.3 虚拟运行时间的计算

vruntime 的计算公式是 CFS 公平性的数学基石:

delta_vruntime = (delta_exec * NICE_0_LOAD) / weight

其中:
- delta_exec: 实际执行时间(纳秒)
- NICE_0_LOAD: nice 0 的权重基准值(1024)
- weight: 进程的调度权重(基于 nice 值查表得到)

权重越高的进程,每次实际执行积累的 vruntime 越慢,从而更频繁地被调度。

2.4 nice 值与权重的映射

Linux 内核使用预定义的权重表将 -20 到 19 的 nice 值映射到具体权重:


static const int 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 值(优先级提高),权重增加约 25%,这意味着高优先级进程获得约 1.25 倍的 CPU 时间。

三、CFS 调度算法详解

3.1 调度入口:pick_next_task_fair

当 CPU 需要选择下一个运行的进程时,CFS 调用 pick_next_task_fair():


static struct task_struct *pick_next_task_fair(struct rq *rq)
{
    struct cfs_rq *cfs_rq = &rq->cfs;
    struct sched_entity *se;
    
    // 如果当前进程仍在运行队列且可运行,先将其放回
    if (prev_sched_entity)
        put_prev_task(rq, prev);
    
    // 选择红黑树最左端(vruntime 最小)的调度实体
    se = pick_next_entity(cfs_rq, NULL);
    set_next_entity(cfs_rq, se);
    
    return task_of(se);
}

3.2 选择下一个实体:pick_next_entity

CFS 默认选择 vruntime 最小的进程,但也会使用 "next" 指针进行优化:


static struct sched_entity *pick_next_entity(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
    struct sched_entity *left = __pick_first_entity(cfs_rq);
    struct sched_entity *se;
    
    // 如果最左端就是当前进程,考虑使用 next/best 优化
    if (curr && (!left || entity_before(curr, left)))
        left = curr;
    
    se = left; // most left entity
    if (cfs_rq->next && wakeup_preempt_entity(cfs_rq->next, left) < 1)
        se = cfs_rq->next;
    else if (cfs_rq->last && wakeup_preempt_entity(cfs_rq->last, left) < 1)
        se = cfs_rq->last;
    
    clear_buddies(cfs_rq, se);
    return se;
}

3.3 入队与出队操作

当进程状态变化时,需要在红黑树中进行插入或删除:

/* 将进程加入运行队列 */
static void enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    bool renorm = !(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_MIGRATED);
    bool curr = cfs_rq->curr == se;
    
    // 更新负载统计
    update_load_avg(cfs_rq, se, UPDATE_TG | DO_ATTACH);
    se_update_runnable(se);
    update_cfs_group(se);
    
    // 确保 vruntime 不小于 min_vruntime(防止新进程饥饿)
    if (!curr)
        __enqueue_entity(cfs_rq, se);
    se->on_rq = 1;
}

/* 将进程移出运行队列 */
static void dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
    update_curr(cfs_rq);
    update_load_avg(cfs_rq, se, 0);
    se_update_runnable(se);
    update_cfs_group(se);
    
    if (se != cfs_rq->curr)
        __dequeue_entity(cfs_rq, se);
    se->on_rq = 0;
    account_entity_dequeue(cfs_rq, se);
}

3.4 唤醒抢占策略

当一个进程被唤醒(如从 I/O 阻塞恢复)时,CFS 会检查是否应该抢占当前进程:

static void check_preempt_curr(struct rq *rq, struct task_struct *p, int flags)
{
    struct task_struct *curr = rq->curr;
    struct sched_entity *se = &curr->se, *pse = &p->se;
    
    // 如果新唤醒的进程 vruntime 足够小,则允许抢占
    if (wakeup_preempt_entity(se, pse) == 1) {
        // 抢占当前进程
        resched_curr(rq);
    }
}

四、组调度(CGroup SCHED)机制

4.1 任务组层次结构

CFS 支持通过 CGroup 实现分层调度。每个 CPU 上维护一个 CFS 运行队列,运行队列中可以包含任务或任务组:

struct task_group {
    struct cgroup_subsys_state css;    // CGroup 子系统状态
    struct sched_entity **se;          // 每个 CPU 上的调度实体数组
    struct cfs_rq **cfs_rq;            // 每个 CPU 上的 CFS 运行队列数组
    unsigned long shares;              // 该任务组的 CPU 份额权重
    atomic_long_t load_avg;            // 负载平均值
    // ...
};

4.2 带宽控制(CFS Bandwidth Control)

Linux 通过 CFS bandwidth control 限制一个 CGroup 在指定周期内的 CPU 使用量:

struct cfs_bandwidth {
    ktime_t period;                    // 周期长度(默认 100ms)
    u64 quota;                         // 周期内可用的 CPU 时间
    u64 runtime;                       // 剩余可运行时间(可借入/借出)
    struct hlist_head throttled_cfs_rq; // 被限流的运行队列
    
    // 定时器,用于补充 runtime
    struct hrtimer period_timer;
    struct hrtimer slack_timer;
};

// 使用方法(Docker/K8s 等场景):
// echo 100000 > /sys/fs/cgroup/cpu/mygroup/cpu.cfs_period_us
// echo 50000 > /sys/fs/cgroup/cpu/mygroup/cpu.cfs_quota_us  // 限制为 0.5 核心

五、负载均衡策略

5.1 调度域(Sched Domain)

Linux 使用调度域的概念来描述 CPU 间的拓扑关系,从底层 SMT 线程到 NUMA 节点形成层次结构:

struct sched_domain {
    struct sched_domain *parent;   // 上层调度域
    struct sched_domain *child;    // 下层调度域
    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; // 缓存友好尝试次数
    int flags;                     // SD_LOAD_BALANCE 等标志
    // ...
    unsigned long last_balance;    // 上次均衡时间
    unsigned int balance_interval; // 当前均衡间隔
    unsigned int nr_balance_failed;// 均衡失败次数
    // ...
};

5.2 负载均衡触发时机

负载均衡在以下时机被触发:

  • IDLE 负载均衡:当 CPU 即将空闲时(schedule() 中进入 idle 前)
  • 周期性负载均衡:通过 SCHED_SOFTIRQ 软中断定期检查
  • 唤醒负载均衡:新进程唤醒时选择最合适的 CPU
  • NUMA 负载均衡:在 NUMA 架构下将进程迁移到内存所在节点

5.3 迁移决策

load_balance() 函数决定是否需要迁移以及迁移哪些任务:

static int load_balance(int this_cpu, struct rq *this_rq,
                       struct sched_domain *sd, enum cpu_idle_type idle,
                       int *continue_balancing)
{
    // 1. 找到最忙的调度组
    // 2. 从该组中选择合适的 CPU
    // 3. 从最忙的 CPU 上选择可迁移的任务
    // 4. 检查迁移后是否改善不均衡
    
    struct lb_env env = {
        .dst_cpu   = this_cpu,
        .dst_rq    = this_rq,
        .sd        = sd,
        .idle      = idle,
        .tasks     = LIST_HEAD_INIT(env.tasks),
    };
    
    // 计算当前 CPU 的负载
    env.src_cpu = group_balance_cpu(&sds);
    
    // 如果负载差异小于阈值,无需迁移
    if (!idle && continue_balancing && !should_balance(...))
        return 0;
    
    // 选择要迁移的任务并执行迁移
    detach_tasks(&env);
    attach_tasks(&env);
    
    return env.nr_moved;
}

六、EEVDF 调度器:CFS 的继任者

6.1 EEVDF 的诞生背景

虽然 CFS 在大多数场景下表现良好,但在以下场景存在性能瓶颈:

  • 大量任务竞争 CPU 时,vruntime 的全局一致性导致频繁缓存失效
  • 延迟敏感型任务的唤醒抢占延迟不够精确
  • 云计算场景中,"延迟承诺"(需要保证最小执行时间间隔)难以表达

2023 年,Linux 社区提出 EEVDF(Earliest Eligible Virtual Deadline First)作为 CFS 的替代方案,最终在 6.6 主线内核中合并。

6.2 EEVDF 核心概念

EEVDF 引入了三个关键概念来替代 vruntime:

1. 虚拟时间(Virtual Time, VT): 进程开始服务时的基准时间
2. 虚拟截止时间(Virtual Deadline, VD): VT + 每单位权重对应的延迟
3. 合格时间(Eligible Time, ET): 进程至少等到此时间后才能被调度

6.3 ELIGIBLE 条件与延迟承诺

EEVDF 的核心创新是引入延迟概念。每个进程声明一个最小请求运行时间(q),计算得到:

VT_ELIGIBLE = (当前最小请求时间)  // 基准
VD = VT + (q / weight)           // 截止时间 = VT + 请求量/权重

条件:当且仅当 VT >= 当前全局虚拟时间 时,进程才是"有资格的"(eligible)

这样可以避免一个进程在获得一小段时间后又被另一个进程抢占,从而保证延迟平滑性。

6.4 EEVDF 的数据结构

EEVDF 同样使用红黑树,但排序键是虚拟截止时间而非 vruntime:

struct sched_entity {
    // ... 继承自 CFS 的字段
    
    // EEVDF 新增/变更字段
    u64 deadline;           // 虚拟截止时间(红黑树排序键)
    u64 vruntime;           // 仍用于负载计算
    u64 min_vruntime;       // 用于保持时间单调性
    
    // 延迟相关
    u64 slice;              // 时间片长度
    u64 min_slice;          // 最小时间片
    u64 vlag;               // 虚拟延迟累积
};

struct rb_root_cached runqueue; // 按 deadline 排序的红黑树

6.5 EEVDF vs CFS 实测对比

根据社区和业界的基准测试数据:

场景CFSEEVDF改善
尾部延迟(P99)较高显著降低-40% ~ -60%
高负载公平性良好更精确≈15%
上下文切换开销中等优化红黑树≈-20%
吞吐量(轻载)高持平≈0%
RFC 延迟承诺不支持原生支持新增

七、工程实践与性能调优

7.1 调度策略选择指南

策略适用场景标志
SCHED_NORMAL/OTHER通用分时进程CFS 默认
SCHED_FIFO硬实时任务FIFO 执行直到主动让出
SCHED_RR软实时任务带时间片的轮转
SCHED_BATCH非交互后台批处理CFS 但不唤醒抢占
SCHED_IDLE极低优先级任务仅空闲时运行
SCHED_DEADLINE实时期限调度EDF 算法

7.2 关键 sysctl 参数


# CFS 调度粒度控制
kernel.sched_min_granularity_ns = 1000000    # 最小调度粒度 1ms
kernel.sched_latency_ns = 8000000            # 调度延迟 8ms
kernel.sched_wakeup_granularity_ns = 1000000 # 唤醒粒度

# 负载均衡
kernel.sched_migration_cost_ns = 500000     # 迁移成本阈值
kernel.sched_nr_migrate = 32               # 单次迁移最大任务数

# NUMA 调度
kernel.numa_balancing = 1                   # 启用 NUMA 自动平衡
kernel.numa_balancing_scan_delay_ms = 1000  # 首次扫描延迟

7.3 实时场景配置实例

以音频处理系统为例,演示如何配置 SCHED_DEADLINE 实现精确调度:

#include <linux/sched.h>

struct sched_attr attr = {
    .size = sizeof(attr),
    .sched_policy = SCHED_DEADLINE,
    .sched_runtime = 50 * 1000 * 1000,   // 50ms 运行时间
    .sched_deadline = 100 * 1000 * 1000, // 100ms 截止时间
    .sched_period = 100 * 1000 * 1000,   // 100ms 周期
};

// sched_setattr() 设置属性

7.4 BPF 与调度器交互

现代 BPF 提供了与调度器深度集成的钩子:

// BPF 程序示例:在进程被切换走时记录
SEC("tp_btf/sched_switch")
int BPF_PROG(trace_sched_switch, bool preempt,
             struct task_struct *prev, struct task_struct *next)
{
    u32 prev_pid = prev->pid;
    u32 next_pid = next->pid;
    u64 ts = bpf_ktime_get_ns();
    
    // 将上下文切换事件发送到用户态
    struct event e = { .prev = prev_pid, .next = next_pid, .ts = ts };
    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &e, sizeof(e));
    
    return 0;
}

7.5 性能监控与调试

排查调度相关性能问题的工具链:

# 查看进程的 vruntime 和调度统计
cat /proc/[pid]/sched

# 使用 perf 分析调度延迟
perf sched record -- sleep 1
perf sched latency

# 使用 ftrace 跟踪调度器决策
echo sched_switch > /sys/kernel/debug/tracing/set_tracer

# 使用 eBPF 工具 bcc 的 runqlat
/usr/share/bcc/tools/runqlat 1 10

# 查看调度组状态
cat /proc/sched_debug | grep "cfs_rq\|group"

八、总结与展望

Linux 调度器从 O(1) 到 CFS 再到 EEVDF 的演化,反映了一个持续追求的目标:在规模、公平性、实时性和能效之间取得最优平衡。

核心要点回顾:

  • CFS:通过 vruntime 和红黑树实现了数学上的完全公平,是目前最广泛使用的通用调度器
  • 组调度:通过层次化 CFS 运行队列支持容器化场景的 CPU 资源隔离
  • 负载均衡:基于调度域的多层拓扑感知策略,优化了多核和 NUMA 系统的任务分布
  • EEVDF:通过 deadline 排序和 eligible 条件,提供了更精确的延迟控制和延迟承诺能力

随着云计算和异构计算的发展,Linux 调度器仍在持续演进。未来的发展方向包括:异构 CPU 感知调度(大小核/TPU)、热插拔感知、能效优化、虚拟机场景的 host/guest 调度协同等。理解这些底层机制,将帮助开发者和系统管理员更好地驾驭 Linux 系统的性能表现。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.365637s