Linux内核调度器CFS完全公平调度算法深度解析

引言

完全公平调度器(Completely Fair Scheduler,CFS)是Linux内核中用于调度普通进程(SCHED_NORMAL)的核心调度类。自Linux 2.6.23版本引入以来,CFS彻底取代了之前O(1)调度器,成为了Linux桌面和服务器系统中的默认调度器。CFS的设计哲学是"尽可能公平地分配CPU时间给所有可运行进程",其核心思想不是通过时间片轮转,而是通过红黑树追踪每个进程的虚拟运行时间(vruntime)来实现理想化的精确公平。

本文将深入解析CFS调度器的核心数据结构、算法原理、NUMA负载均衡策略以及与实时调度器的交互机制,帮助用户全面理解Linux内核进程调度的底层逻辑。

1. CFS的核心设计理念

1.1 从O(1)调度器到CFS的演进

在CFS之前,Linux内核使用的是O(1)调度器。O(1)调度器通过运行队列中的活跃/过期数组来实现常数时间的调度决策,但这种设计存在几个根本性问题:

  • 时间片固定粒度:O(1)调度器为每个进程分配固定大小的时间片,导致交互式进程的响应时间不稳定。
  • 优先级调整算法复杂:为提升交互式进程体验,O(1)调度器引入了"睡眠/运行"启发式计算来动态调整优先级,该算法复杂且不准。
  • 公平性难以保证:随着进程数量增长,不同优先级进程间的时间片差异导致不公平性放大。

CFS的提出者是Ingo Molnar,他的核心思想借鉴了"虚拟时钟"概念,提出了"谁在CPU上运行的时间最少,谁就最该运行"的原则。

1.2 CFS的基本原理

CFS不再使用时间片的概念,而是为每个进程维护一个虚拟运行时间(vruntime)。vruntime表示进程在CPU上运行的加权时间。调度时,CFS选择vruntime最小的进程优先运行。

这种设计的美妙之处在于:当进程数量趋于无穷时,任何进程获得的CPU时间比率趋近于1/N,实现了理论上的完全公平。

2. 核心数据结构

2.1 调度实体 sched_entity

在内核中,每个可调度的实体都内嵌一个sched_entity结构体:

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_runtime; // 上一次切换时的总运行时间
    // ...
};

关键运算关系如下:


// 虚拟运行时间增量 = 实际运行时间 * (NICE_0_LOAD / 进程权重)
delta_vruntime = delta_exec * (NICE_0_LOAD / se->load.weight)

权重越大的进程(优先级越高),vruntime增长越慢,从而获得更多的实际CPU时间。

2.2 运行队列 cfs_rq

每个CPU的运行队列中都有一个CFS私有的cfs_rq:

struct cfs_rq {
    struct load_weight  load;        // 队列总权重
    unsigned long       nr_running;  // 可运行进程数
    u64                 min_vruntime; // 队列中最小的vruntime(关键)

    struct rb_root      tasks_timeline;  // 红黑树根节点
    struct rb_node      *rb_leftmost;    // 最左侧节点(最快调度的进程)

    struct sched_entity *curr;  // 当前正在运行的调度实体
    // ...
};

2.3 红黑树:CFS的核心数据结构

CFS使用红黑树(rbtree)来组织所有可运行的调度实体,以vruntime作为排序键值。这种设计的优势在于:

  • 查找最快进程 O(1):通过rb_leftmost指针直接获取vruntime最小的节点。
  • 插入新进程 O(log N):红黑树的自平衡特性保证了插入效率。
  • 删除进程 O(log N):进程阻塞或退出时的删除操作高效。

// 将调度实体加入红黑树
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    struct rb_node **link = &cfs_rq->tasks_timeline.rb_node;
    struct rb_node *parent = NULL;
    struct sched_entity *entry;
    u64 vruntime = cfs_rq->min_vruntime;

    // 选择合适的位置插入
    while (*link) {
        parent = *link;
        entry = rb_entry(parent, struct sched_entity, run_node);
        if (entity_before(se, entry))
            link = &parent->rb_left;
        else {
            link = &parent->rb_right;
            leftmost = false;
        }
    }

    rb_link_node(&se->run_node, parent, link);
    rb_insert_color(&se->run_node, &cfs_rq->tasks_timeline);
}

3. 虚拟运行时间(vruntime)机制

3.1 vruntime的计算公式

vruntime的计算是CFS的核心,它将进程的实际运行时间根据优先级进行加权:


// 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->sum_exec_runtime += delta_exec;
    
    // 关键:计算虚拟运行时间增量
    curr->vruntime += calc_delta_fair(delta_exec, curr);
    update_min_vruntime(cfs_rq);
}

calc_delta_fair的计算逻辑:


static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se)
{
    // NICE_0_LOAD = 1024 (nice值0对应的权重)
    if (unlikely(se->load.weight != NICE_0_LOAD))
        delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
    return delta;
}

核心公式:vruntime增量 = 实际运行时间 × (1024 / 进程权重)

3.2 优先级与权重的映射

内核使用prio_to_weight数组将nice值(-20到19)映射为权重:

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值每降低1级(优先级升高),CPU时间获取比率约增加10%。两个进程的CPU时间比率近似为 1.25^|nice差值|。

3.3 min_vruntime的妙用

每个cfs_rq维护一个min_vruntime字段,它记录了该运行队列中所有进程的最小vruntime。这个字段有两个关键作用:

  • 新进程的vruntime校准:新创建的进程或唤醒的进程的vruntime不会从零开始,而是设为min_vruntime,防止"饥饿"新进程。
  • 跨CPU迁移:进程在不同CPU间迁移时,通过min_vruntime保持公平性。
static void update_min_vruntime(struct cfs_rq *cfs_rq)
{
    struct sched_entity *curr = cfs_rq->curr;
    struct rb_node *leftmost = rb_first(&cfs_rq->tasks_timeline);

    u64 vruntime = cfs_rq->min_vruntime;
    if (curr) {
        if (curr->on_rq)
            vruntime = curr->vruntime;
        else
            curr = NULL;
    }
    if (leftmost) {
        struct sched_entity *se = rb_entry(leftmost, ...);
        if (!curr)
            vruntime = se->vruntime;
        else
            vruntime = min(vruntime, se->vruntime);
    }
    cfs_rq->min_vruntime = max(vruntime, cfs_rq->min_vruntime);
}

4. 调度流程详解

4.1 主调度函数 schedule()

当系统需要重新调度时(显式调用schedule()或抢占点触发),内核会执行以下流程:


asmlinkage __visible void __sched schedule(void)
{
    struct task_struct *tsk = current;
    schedule_preempt_disabled();
    for (;;) {
        // 关闭中断,防止并发
        raw_spin_lock_irq(&rq->lock);
        
        // 从最高优先级调度类中选择下一个要运行的进程
        next = pick_next_task(rq);
        
        // 如果选出的进程与当前不同,执行上下文切换
        if (likely(prev != next)) {
            context_switch(rq, prev, next);
        }
        raw_spin_unlock_irq(&rq->lock);
    }
}

4.2 CFS的pick_next_task

pick_next_task_fair是CFS选择下一个运行进程的核心函数:

static struct task_struct *pick_next_task_fair(struct rq *rq)
{
    struct cfs_rq *cfs_rq = &rq->cfs;
    struct sched_entity *se;

    // 更新当前进程的vruntime
    update_curr(cfs_rq);

    // 如果当前进程仍在运行队列,则先将其放回红黑树
    if (cfs_rq->curr && cfs_rq->curr->on_rq)
        __enqueue_entity(cfs_rq, cfs_rq->curr);

    // 取出红黑树最左侧节点(vruntime最小 = 最该运行的进程)
    if (first = rb_first(...))
        se = rb_entry(first, struct sched_entity, run_node);
    
    // 设置下一个运行进程
    set_next_task_fair(rq, p);
    return p;
}

时间复杂度为 O(log N) 来自红黑树操作。若CPU支持,rb_leftmost缓存可提供 O(1) 的最左节点访问。

4.3 进程入队与出队

enqueue_task_fair — 进程变为可运行状态时调用:

static void enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
    struct cfs_rq *cfs_rq;
    struct sched_entity *se = &p->se;

    // 遍历rq层级结构的每个cfs_rq
    for_each_sched_entity(se) {
        cfs_rq = cfs_rq_of(se);
        // 更新vruntime统计
        update_curr(cfs_rq);
        // 将se加入红黑树
        __enqueue_entity(cfs_rq, se);
        // 更新cfs_rq统计
        update_load_avg(cfs_rq, se, UPDATE_TG);
        // 将se加入nr_running
        cfs_rq->nr_running++;
    }
    // 更新rq的总权重和h_nr_running
    add_nr_running(rq, 1);
    // 检查是否需要抢占当前进程
    if (flags & ENQUEUE_WAKEUP)
        check_preempt_curr(rq, p, flags);
}

dequeue_task_fair — 进程被阻塞时调用,操作的对称逆过程。

4.4 抢占机制 check_preempt_curr

当新唤醒的进程vruntime显著小于当前进程时,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;
    struct cfs_rq *cfs_rq = task_cfs_rq(curr);

    // 如果新进程的vruntime - 当前进程的vruntime < 抢占粒度,则不抢占
    // 默认sched_wakeup_granularity_ns = 4ms,防止过于频繁的抢占

    // 否则,标记需要重新调度
    resched_curr(rq);
}

这种"懒惰抢占"策略在保证公平性的同时,避免了上下文切换开销过大。

5. NUMA感知与负载均衡

5.1 NUMA调度域层级结构

现代多核/多插槽系统中,CFS与SMP负载均衡深度结合:

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;
    // ...
};

Linux内核构建了分层的调度域(sched_domain):

┌──────────────────────────┐
│      DIE Domain          │  - 同一物理封装(socket)
│  ┌─────────────────────┐ │
│  │    MC Domain        │ │  - 同一NUMA节点(多核共享L3)
│  │  ┌─────────────────┐│ │
│  │  │ SMT Domain      ││ │  - 同一物理核的超线程
│  │  │ ┌─────────────┐ ││ │
│  │  │ │ Single CPU  │ ││ │  - 单个逻辑CPU
│  │  │ └─────────────┘ ││ │
│  │  └─────────────────┘│ │
│  └─────────────────────┘ │
└──────────────────────────┘

5.2 负载均衡流程

负载均衡以周期性方式运行:


 // 触发条件:
 // 1. 定时器中断(tick_load_balance)
 // 2. CPU空闲时(idle_balance)
 // 3. fork/exec时(newidle_balance)

load_balance(int this_cpu, struct rq *this_rq,
             struct sched_domain *sd, enum cpu_idle_type idle)
{
    // 1. 找出调度域中最忙的组
    // 2. 在组内找出最忙的CPU(源CPU)
    // 3. 如果源的负载 > 本地负载 + 阈值,则执行迁移
    // 4. 选择可迁移的进程(考虑缓存亲和性、NUMA亲和等)
    // 5. 调用move_tasks()实际迁移}

5.3 NUMA balancing(自动页迁移)

Linux 3.13引入的automatic NUMA balancing与CFS紧密配合:

  • 页故障(page fault)采样,识别跨NUMA节点访问热点进程
  • 将进程迁移到离其内存更近的CPU
  • 同时迁移进程的物理页(migrate_on_fault)
  • 通过numastat / proc/[pid]/numa_maps查看NUMA分布

6. 组调度(Group Scheduling)

6.1 cgroups与CPU控制器

Linux通过cgroups实现资源分组管理,CFS通过调度组(task_group/cgroup_sched_domain)来支持组级份额分配:


// 查看cgroup的CPU分配$ cat /sys/fs/cgroup/cpu/mygroup/cpu.shares1024  // 默认权重
$ cat /sys/fs/cgroup/cpu/mygroup/cpu.cfs_quota_us
50000  // 每周期最多使用50ms(50% CPU)
$ cat /sys/fs/cgroup/cpu/mygroup/cpu.cfs_period_us100000 // 周期100ms

6.2 两层调度:组间+组内

组调度的实现非常巧妙——在同一棵红黑树上同时维护两种实体:

  • 任务级调度实体:普通task_struct内嵌的se
  • 组级调度实体:task_group包含的se,它也拥有独立的cfs_rq和vruntime

这种统一的数据结构使得组间和组内的调度逻辑完全一致,代码复用度极高。


// 组调度的核心循环
do {
    // 1. 在当前层级中选择vruntime最小的实体
    se = pick_next_entity(cfs_rq);
        // 2. 如果选中的是组级实体,深入该组的子cfs_rq
    if (se->my_q) {
        cfs_rq = se->my_q;
        // 递归选择
        continue;
    }    // 3. 如果选中的是任务级实体,返回    break;} while (1);

6.3 CFS Bandwidth Control(带宽控制)

CFS Bandwidth Control实现了对每个cgroup的CPU使用硬限制:

  • 周期(period):通常为100ms,定义了一个时间窗口。
  • 配额(quota):在该周期内cgroup允许使用的最大CPU时间。
  • 限制机制:当cgroup用尽配额,其中的进程被节流(throttle),直到下一个周期刷新。

// 节流实现
static int assign_cfs_rq_runtime(struct cfs_rq *cfs_rq)
{
    struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg);
    // 尝试从全局带宽池分配时间
    if (cfs_rq->runtime_remaining <= 0) {
        // 向全局池申请        if (!__assign_cfs_rq_runtime(cfs_b, cfs_rq, ...))
            throttle_cfs_rq(cfs_rq);  // 节流
        return 0;
    }
    return 1;
}

这是Docker/Kubernetes CPU限制(--cpus=2)的底层实现。

7. 实时调度器与CFS的交互

7.1 调度类的优先级

Linux内核定义了多个调度类,CFS处理的SCHED_NORMAL(SCHED_OTHER)的优先级低于实时类:

 // kernel/sched/sched.h
extern const struct sched_class stop_sched_class;     // 优先级最高
extern const struct sched_class dl_sched_class;      // SCHED_DEADLINEextern const struct sched_class rt_sched_class;       // SCHED_FIFO/RRextern const struct sched_class fair_sched_class;     // SCHED_NORMAL(CFS)
extern const struct sched_class idle_sched_class;     // 优先级最低

这种链式结构使得pick_next_task按优先级遍历调度类:先检查stop,再dl,再rt,再fair(CFS),最后idle。实时进程总是优先于普通进程被选中。

7.2 SCHED_DEADLINE与CFS的共存

SCHED_DEADLINE(EDF算法)的引入改变了CPU时间分配的逻辑:

  • DEADLINE任务执行期间不计入CFS配额
  • 当DL任务预算耗尽时,降级为CFS调度
  • CFS的min_vruntime计算会排除DEADLINE任务,保证它们的vruntime不会膨胀

8. CFS的调优与监控

8.1 关键sysctl参数

参数默认值说明
sched_latency_ns24ms调度周期,CFS尝试在周期内轮转所有进程
sched_min_granularity_ns3ms最小调度粒度,防止过度切换
sched_wakeup_granularity_ns4ms唤醒抢占粒度,大于此值才允许抢占
sched_migration_cost_ns500μs认为进程"热"的缓存阈值
sched_autogroup_enabled1(桌面)自动为会话创建调度组,提升交互式体验

8.2 /proc与调度器交互

# 查看进程的调度信息
$ cat /proc/[pid]/sched
cat (1234, #threads: 1)
-------------------------------------------------------------------
exec_start                                :       1234567890.123
sum_exec_runtime                          :             1234.567
vruntime                                  :          98765.432
nr_migrations                              :                  42
nr_voluntary_switches                      :                 567
nr_involuntary_switches                    :                  89
se.statistics.block_start                  :       1234567800.000
...# 查看运行队列状态
$ cat /proc/sched_debug | grep -A 5 "cfs_rq"
# 查看系统调度统计
$ cat /proc/schedstat
$ vmstat 1  # 关注cs(上下文切换)列

8.3 使用perf分析调度行为

# 记录调度事件
$ perf record -e sched:sched_switch -e sched:sched_wakeup -a sleep 10# 查看上下文切换数量
$ perf stat -e cs -a sleep 1# 生成调度时间线
$ perf script --trace=sched:sched_switch

9. CFS的演进与未来

9.1 EEVDF:CFS的继任者?

Linux 6.6引入了EEVDF(Earliest Eligible Virtual Deadline First)作为新的调度器框架选项。与CFS不同:

  • 引入了明确的eligible time(合格时间)概念,更精确地建模"欠债"程度。
  • 每个任务具有lag值 = vruntime - ideal_vruntime,更直接的公平度量。
  • 理论上对高分辨率定时器更加友好。
  • 目前是可选特性(CONFIG_SCHED_CLASS_EEVDF),CFS仍是默认。

9.2 持久化Intel P-state与schedutil

CFS通过PELT(Per-Entity Load Tracking)负载指标驱动CPU调频器schedutil:

  • PELT使用指数移动平均计算每个调度实体的负载。
    • schedutil以PELT负载作为CPU频率选择依据。
    • Intel的HWP(Hardware P-states)可以直接使用PELT信息,实现硬件自主调频。

9.3 eBPF在调度器中的应用

Linux 5.13+引入了sched_ext(Scheduler Extensibility),允许通过eBPF实现自定义调度策略:

// eBPF程序可以:// 1. 拦截调度决策,覆盖默认行为
// 2. 收集调度统计信息
// 3. 实现特定负载的定制调度策略例如:scx_rustland、scx_lavd

这标志着Linux内核调度器进入了可定制化的时代。

10. 总结

CFS是Linux内核中最重要、最复杂的子系统之一。它的设计优雅地将公平性、效率性和可扩展性融为一体:

  • 理论上的完全公平:通过vruntime的数学建模,极限情况下任何进程获得1/N的CPU。
  • 工程上的实用性:红黑树O(log N)的复杂度,在数万进程下仍高效运行。
  • 层次化的扩展性:通过cgroups、调度域、调度类,支持从嵌入式设备到大型服务器的全场景。
  • 智能化的感知能力:NUMA感知、负载均衡、能耗感知,持续演进。

理解CFS不仅是理解Linux内核的一个关键窗口,更是理解现代操作系统调度设计的最佳案例。随着EEVDF、sched_ext等新机制的引入,Linux调度器将持续演进,以满足云原生、AI推理、实时计算等新兴场景的需求。

参考资料

  • 《Understanding the Linux Kernel》,3rd Edition,Chapter 7
  • Linux内核源码:kernel/sched/fair.c、kernel/sched/core.c
  • Kernel Documentation: scheduler/ 目录下的文档
  • Ingo Molnar, "Modular Scheduler Core and Completely Fair Scheduler",2007
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部