Linux CFS 进程调度器深度剖析:从完全公平算法到生产环境实战

一、引言:为什么需要理解 CFS

进程调度是操作系统内核最核心的组件之一。它决定了哪个进程在何时获得 CPU 时间,直接影响系统的吞吐量、响应时间和公平性。在 Linux 2.6.23 内核之前,O(1) 调度器主宰着内核。但 O(1) 调度器在交互式进程处理和公平性方面存在固有缺陷。

2007 年,Ingo Molnár 提出了 CFS(Completely Fair Scheduler,完全公平调度器),从 2.6.23 版本开始成为 Linux 默认的进程调度器。CFS 的核心哲学不是"分配时间片",而是"维护虚拟运行时间的公平"。

理解 CFS,不仅帮助你理解 Redis、Nginx 等高性能服务的进程行为,更是排查 CPU 争用、负载抖动、容器性能下降等生产问题的关键基础。

二、CFS 核心思想:虚拟运行时间(vruntime)

2.1 抛弃时间片的概念

传统调度器以"时间片"为核心:每个进程分配一个固定的 CPU 时间配额,用完即被抢占。这种模型面临几个难题:

  • 时间片太小 → 上下文切换开销增大
  • 时间片太大 → 交互响应变差
  • nice 值到时间片的映射曲线难以调优

CFS 彻底摒弃了固定时间片。它引入了一个革命性的概念:虚拟运行时间(vruntime)。每个进程维护自己的 vruntime,调度器总是选择 vruntime 最小的进程运行。

2.2 vruntime 的计算公式

vruntime 的计算公式如下:

vruntime += 实际运行时间 × (NICE_0_LOAD / 进程权重)

其中关键参数:

  • NICE_0_LOAD = 1024 — nice 值为 0 时的基准权重
  • 进程权重 — 由 nice 值决定,权重越大,vruntime 增长越慢
  • nice 值越小(优先级越高),权重越大 → vruntime 增长越慢 → 更频繁被调度

具体权重对照表(内核中 sched_prio_to_weight 数组):

nice 值权重相对速度
-208876186×
-112771.25×
010241×(基准)
17650.75×
101510.15×
19110.01×

这意味着 nice -20 的进程获得的 CPU 时间约为 nice 19 进程的 约 8000 倍(88761/11)。

三、红黑树:CFS 的数据结构基石

3.1 为什么选择红黑树

CFS 使用红黑树(Red-Black Tree)来组织所有可运行进程,以 vruntime 作为 key。选择红黑树的原因:

  • O(log n) 查找最小值:左端节点即 vruntime 最小的进程,调度决策时间恒定
  • O(log n) 插入/删除:进程唤醒或阻塞时的维护开销低
  • 自平衡:保证树高稳定,不会退化成链表
  • 无内存碎片:相比链表,节点的局部性更好

3.2 cfs_rq 结构

每个 CPU 运行队列上的 CFS 数据由 cfs_rq 结构管理:

struct cfs_rq {
    struct load_weight load;        // 总权重
    unsigned long runnable_weight;   // 可运行权重和
    unsigned int nr_running;         // 可运行进程数
    u64 min_vruntime;               // 最小 vruntime(单调递增)
    struct rb_root_cached runqueue; // 红黑树根节点
    struct sched_entity *curr;      // 当前运行的调度实体
    // ...
};

3.3 sched_entity 与 vruntime

每个进程的调度实体内嵌在 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_runtime;  // 上次切换时的总运行时间
    // ...
};

min_vruntime 是整个红黑树中所有进程 vruntime 的下界。新创建进程的 vruntime 不是从 0 开始,而是设为当前 min_vruntime,避免新进程"饥饿"老进程。

四、CFS 调度流程全流程追踪

4.1 调度入口

CFS 调度的核心入口函数调用链:

schedule()
  → pick_next_task()
    → pick_next_task_fair()       // CFS 选择下一个进程
      → __pick_next_task_fair()   // 从红黑树选最左端节点
        → entity_of(rb_first())   // 获取 sched_entity
  → context_switch()              // 上下文切换
    → switch_mm()                 // 切换地址空间
    → switch_to()                 // 切换寄存器/栈

4.2 时钟中断与抢占检查

每个 tick(通常 1ms 到 4ms,由 CONFIG_HZ 决定)触发 scheduler_tick:

scheduler_tick()
  → task_tick_fair()
    → entity_tick()
      → update_curr()            // 更新 vruntime 统计
      → check_preempt_tick()     // 检查是否需要抢占
        → resched_curr()         // 设置 TIF_NEED_RESCHED 标志

check_preempt_tick 的核心逻辑:

// 当前进程已运行时间 vs 理想运行时间的差值
if (delta_exec > ideal_runtime)
    resched_curr(rq);  // 标记需要调度

4.3 理想运行时计算

ideal_runtime = sched_period × (进程权重 / cfs_rq.总权重)

其中 sched_period(调度周期)默认为 6ms(sysctl_sched_latency)。若有 N 个进程,每个进程至少分到 6/N ms 的时间。

4.4 唤醒抢占(Wakeup Preemption)

当进程从睡眠状态被唤醒时,也可能抢占当前进程:

check_preempt_wakeup()
  → wakeup_preempt_entity()
    // 如果唤醒的进程 vruntime 远小于当前进程
    // 并且满足 granularity 条件,则允许抢占

五、CFS 组调度(Group Scheduling)

5.1 引入背景

默认情况下,CFS 在多核系统中的公平性是 per-CPU 的。假设系统有 2 个 CPU 和 10 个进程(A 组 9 个,B 组 1 个),A 组每个获得 CPU 的 1/10,B 获得 1/2。显然对 B 组管理员不公平。

为此 Linux 引入了 组调度(CONFIG_FAIR_GROUP_SCHED)。

5.2 双层调度模型

用户视角:上级调度器(CPU 级别)
        ↓
    组 A(权重 W_A) | 组 B(权重 W_B)
        ↓                    ↓
   下级调度器          下级调度器
(per-cgroup CFS)   (per-cgroup CFS)
   ↓ ↓ ↓               ↓
  PID1 PID2 PID3     PID4

上级调度决定组间 CPU 分配比例,组内调度决定组内进程的分配。

5.3 bandwidth 控制(cpu.cfs_quota_us)

cgroups v1 通过 cpu 子系统实现 CPU 带宽限制:

// 每 100ms 周期内,该 cgroup 最多使用 30ms CPU
echo 30000 > /sys/fs/cgroup/cpu/mygroup/cpu.cfs_quota_us
echo 100000 > /sys/fs/cgroup/cpu/mygroup/cpu.cfs_period_us

底层实现:rq->cfs_bthrottled_time 跟踪已用时间,超出时 throttling 该 cgroup。

六、CFS 在 NUMA 架构下的优化

6.1 NUMA 感知调度

在 NUMA 架构中,跨节点访问内存延迟可达本地访问的 2-3 倍。CFS 从 3.8 内核开始引入 NUMA 平衡(CONFIG_NUMA_BALANCING):

  • 页面在节点间迁移以靠近访问者
  • 通过 AutoNUMA 自动识别热点页面
  • 结合 NUMA balancing fault 统计,将进程迁移到页面所在节点

6.2 sched_numa_balancing

// 扫描范围由这两个参数控制
sysctl kernel.numa_balancing_scan_delay    // 初始延迟(默认 1000ms)
sysctl kernel.numa_balancing_scan_period   // 扫描周期

七、容器场景下 CFS 的行为与陷阱

7.1 Docker/K8s 的 CPU Limit

在容器中设置 cpuset-cpus 和 cpu-quota,底层正是通过 CFS cgroup 实现。常见陷阱:

陷阱 1:CPU Throttling 导致延迟抖动

当容器内累计使用 CPU 达到 quota,CFS 会 throttle 整个 cgroup。这导致即使只有一个进程需要 CPU,也会被强制等待到下一个 period。表现为:请求延迟 P99 异常升高。

陷阱 2:CFS Bandwidth 的 burst 行为

Linux 5.14 引入了 CFS burst(cpu.cfs_burst_us),允许在部分周期内"借用"额度,但累积不能超过 burst。谨慎使用。

7.2 容器 CPU 抢占与 scheduling latency

当宿主机有大量容器时,CFS 的红黑树中有大量 sched_entity。虽然 O(log n) 不变,但 cache miss 增加。在 K8s 集群中观察到:

  • 64 核机器运行 200+ 容器时,sched_nr_migrations(进程迁移)增加
  • 过高的负载导致 sysctl_sched_latency 被自动放大至 24ms+
  • 可通过 /proc/sys/kernel/sched_min_granularity_ns 微调

八、性能调优实战

8.1 关键 sysctl 参数

参数默认值作用
sched_latency_ns24000000 (24ms)目标调度延迟
sched_min_granularity_ns3000000 (3ms)最小抢占粒度
sched_wakeup_granularity_ns4000000 (4ms)唤醒抢占粒度
sched_migration_cost_ns500000 (0.5ms)迁移成本估算

8.2 延迟敏感场景调优

# 数据库 / 延迟敏感型应用
echo 10000000 > /proc/sys/kernel/sched_latency_ns  # 10ms
echo 1000000 > /proc/sys/kernel/sched_min_granularity_ns  # 1ms
echo 2000000 > /proc/sys/kernel/sched_wakeup_granularity_ns  # 2ms

8.3 CPU 密集型批处理场景

# 批处理 / HPC 场景 — 增大粒度减少切换
echo 48000000 > /proc/sys/kernel/sched_latency_ns  # 48ms
echo 6000000 > /proc/sys/kernel/sched_min_granularity_ns  # 6ms

8.4 使用 taskset/cpuset 绑定 CPU

# 将进程绑定到特定 CPU 核
taskset -c 0,1 ./my_app

# cpuset cgroup 方式(更精细)
mkdir /sys/fs/cgroup/cpuset/app0
echo "0-3" > /sys/fs/cgroup/cpuset/app0/cpuset.cpus
echo "0" > /sys/fs/cgroup/cpuset/app0/cpuset.mems
echo $PID > /sys/fs/cgroup/cpuset/app0/cgroup.procs

九、CFS 在内核源码中的关键函数

9.1 核心函数映射

函数文件功能
enqueue_task_fair()kernel/sched/fair.c加入红黑树
dequeue_task_fair()kernel/sched/fair.c从红黑树移除
pick_next_task_fair()kernel/sched/fair.c选择下一个进程
entity_tick()kernel/sched/fair.ctick 处理+抢占检查
update_curr()kernel/sched/fair.c更新 vruntime + 统计
set_next_entity()kernel/sched/fair.c设置为当前运行
place_entity()kernel/sched/fair.c新进程/唤醒进程的 vruntime 初始化

9.2 update_curr 详解

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
    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)
{
    if (se->load.weight != NICE_0_LOAD)
        delta = __calc_delta(delta, NICE_0_LOAD, &se->load);
    return delta;
}

十、CFS 监控系统与工具

10.1 perf sched — 调度器分析利器

# 记录 30 秒调度事件
perf sched record -- sleep 30

# 查看调度延迟分布
perf sched latency

# 查看调度映射(哪些 CPU 跑了哪些进程)
perf sched map

# 重放调度场景
perf sched replay

10.2 BPF 追踪调度事件

# 追踪上下文切换
bpftrace -e 'tracepoint:sched:sched_switch { 
    printf("%s -> %s on CPU %d\n", args->prev_comm, args->next_comm, args->prev_cpu);
}'

# 追踪调度延迟(从 need_resched 到实际切换的时间)
funclatency:*update_load_avg*

10.3 查看进程调度统计

/proc/[PID]/sched          # 详细调度统计
/proc/[PID]/schedstat      # utime stime 的 ticks
/proc/schedstat            # CPU 级调度统计
/proc/[PID]/task/[TID]/sched  # 线程级调度信息

十一、CFS vs EEVDF:下一代调度器之争

2023 年,Linux 社区讨论用 EEVDF(Earliest Eligible Virtual Deadline First) 替代 CFS。EEVDF 的核心变化:

  • 引入明确的 deadline 概念,vruntime + 调度周期 = deadline
  • 每个进程有明确的"时间窗口"[eligible time, deadline]
  • 消除了 CFS 在特定场景下的不公平性问题(如长时间睡眠后醒来被过度补偿)

从 Linux 6.6 开始,EEVDF 作为默认调度器在部分场景启用。但 CFS 仍然在工作负载中广泛使用。

十二、CFS 最佳实践清单

架构层面:

  • 延迟敏感服务(数据库、Redis)独占 CPU 核心,避免与其他服务共享
  • 使用 cpuset 而非 quota 来保证关键进程的 CPU 访问
  • NUMA 架构下,确保进程与内存在同一节点

容器层面:

  • K8s 中 CPU request 设为核心需求,limit 慎用(避免 throttle)
  • Guaranteed QoS 类 Pod 设置 request = limit,避免超卖影响
  • 监控 container_cpu_cfs_throttled_seconds_total 指标

应用层面:

  • Worker 线程数 = CPU 核心数(CPU 密集型)或 2×核心数(IO 密集型)
  • Avoid 频繁短 sleep 的 busy-wait 模式 — 浪费调度开销
  • 使用 epoll/kqueue 减少阻塞唤醒次数

排查层面:

  • 高 load average + 低 CPU 利用率 → 可能 D 状态进程堆积
  • CPU steal 时间高 → 宿主机超卖,需迁移实例
  • 低负载时上下文切换仍高 → 可能进程过多或 busy-poll

十三、总结

CFS 的设计哲学非常优雅——没有时间片,没有动态优先级计算,没有复杂的启发式规则。一切归结为两个简单的原则:

  1. 总是选择 vruntime 最小的进程运行
  2. 让所有可运行进程的 vruntime 尽量相等

理解 CFS 的 vruntime、红黑树、调度周期、权重计算,你就掌握了 Linux 进程行为的底层逻辑。无论是优化 Kubernetes 集群性能、调试数据库延迟抖动,还是设计高性能网络框架,这些底层知识都是不可或缺的基石。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.362136s