引言:为什么 CFS 统治了 Linux 调度

Linux 2.6.23 版本引入的 Completely Fair Scheduler (CFS) 彻底取代了之前的 O(1) 调度器,成为 Linux 内核默认的进程调度器。CFS 的设计哲学简单而优雅:让每个可运行进程在一段时间内获得"完全公平"的 CPU 时间份额。它不维护传统的时间片概念,而是通过红黑树跟踪每个进程的虚拟运行时(vruntime)来决定下一个执行谁。十余年的演进,CFS 从最初的桌面场景扩展到服务器、容器、实时系统,成为现代计算基础设施的核心基石。本文将深入 CFS 的底层数据结构、选取算法、NUMA 感知机制以及 sched autogroup 等进阶特性,结合实战案例和性能分析,帮助读者彻底掌握这一关键子系统。

1. 核心数据结构:调度实体与红黑树

CFS 的核心是一个以 vruntime 为键的红黑树(struct cfs_rq 中的 tasks_timeline)。每一个可运行进程都被封装为一个 struct sched_entity,其中关键字段包括:

  • vruntime:虚拟运行时间,进程实际运行时间按权重归一化的值。值越小,越"亏待",越该被调度。
  • load_weight:基于静态优先级(nice 值)转换的权重。nice 每变化 1,权重变化约 10%。
  • min_vruntime:整个 cfs_rq 中最小的 vruntime,用作基准偏移,防止新进程饥饿老进程。
struct sched_entity {
    struct load_weight  load;       /* 权重,由 nice 值转换 */
    struct rb_node      run_node;   /* 红黑树节点 */
    u64                 vruntime;   /* 虚拟运行时间 */
    u64                 exec_start; /* 本次开始执行的时间 */
    u64                 sum_exec_runtime; /* 累计实际运行时间 */
    u64                 prev_sum_exec_runtime;
    u64                 nr_migrations;
    /* cgroup 相关、统计字段等... */
};

红黑树的最左节点(__pick_first_entity())始终保持当前最值得执行的进程,取下一个任务的时间复杂度仅为 O(log(N)),其中 N 为可运行进程数。对比 O(1) 调度器中维护 140 个优先级数组的设计,CFS 在大量进程场景下依然高效。

2. vruntime 计算:权重的魔法

vruntime 的计算公式是理解 CFS 公平性的核心:

delta_vruntime = delta_exec * (NICE_0_LOAD / se->load.weight);

其中 NICE_0_LOAD 是 nice=0 时权重(1024)。解读这个公式:

  • 高优先级进程(权重更大)→ 乘数小于 1 → vruntime 增长更慢 → 更频繁被选中 → 获得更多 CPU 份额。
  • 低优先级进程(权重更小)→ 乘数大于 1 → vruntime 增长更快 → 更晚被选中。
  • nice 值每变化 1,权重变化约 10%,因此 CPU 份额也变化约 10%。

举例:两个进程 A(nice=0, 权重1024) 和 B(nice=5, 权重约335)。初始 vruntime 都为 0。B 执行 10ms,其 vruntime 增加 10ms * (1024/335) ≈ 30.5ms;A 执行 10ms,vruntime 增加 10ms * (1024/1024) = 10ms。下一次调度器选 A,因为 A 的 vruntime 更小。在长时间运行下,A 和 B 的 CPU 时间比趋近于 1024:335 ≈ 3:1 — 这正是 CFS 的"完全公平"体现。

3. 调度选取流程:从时钟中断到上下文切换

CFS 的调度执行可分为三个层次的分析:

3.1 时钟节拍更新(scheduler_tick)

每个 tick 时钟中断触发时,task_tick_fair() 执行。它调用 entity_tick() 完成:

  1. 更新当前进程的 sum_exec_runtime 和 vruntime。
  2. 将当前进程重新插入红黑树(因为 vruntime 变了)。
  3. 通过 check_preempt_tick() 判断是否设置了 TIF_NEED_RESCHED:如果当前进程的累计执行时间已超过 sched_latency / 可运行进程数(即"理想时间片"),则标记需要重新调度。
static void check_preempt_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr)
{
    unsigned long ideal_runtime, delta_exec;
    struct sched_entity *se;
    s64 delta;

    ideal_runtime = sched_slice(cfs_rq, curr); /* 本次应分配的 slice */
    delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
    if (delta_exec > ideal_runtime) {
        resched_curr(rq_of(cfs_rq));
        return;
    }
    /* 处理新加入进程的抢占饥饿问题... */
}

3.2 pick_next_task_fair

在上下文切换时,pick_next_task_fair() 从红黑树最左端取出下一个 sched_entity。如果所有进程都被调度过一轮,可能直接选择当前进程继续执行(curr update 后保留在树上)。

3.3 延迟带宽保证(sched_period 与 sched_slice)

CFS 不是严格每个 tick 都抢占,而是引入两个关键参数:

  • sysctl_sched_latency:默认 6ms(所有进程至少轮转一次的总时间)。
  • sysctl_sched_min_granularity:默认 0.75ms(一个进程最少连续执行的时间)。

进程的理想时间片 = sched_latency / nr_running(但不小于 min_granularity)。这意味着当可运行进程超过 ~8 个时,每个进程的保证时间片会逐渐缩小,但不会低于 0.75ms。

4. 唤醒抢占:不要让新来者等待太久

当一个阻塞进程被唤醒(如 I/O 完成),check_preempt_curr() 会调用 check_preempt_wakeup()。关键检查:

  1. 如果被唤醒进程的 vruntime 比当前进程的 vruntime 小 特定阈值(sysctl_sched_wakeup_granularity,默认 1ms),则立即抢占当前进程。
  2. 阈值机制防止频繁切换:只有"足够公平"的抢占才被执行,避免缓存抖动。

典型场景:一个 I/O 密集型进程完成磁盘读取后 vruntime 很小,立即抢占当前 CPU 密集型进程,保证交互响应速度。这是 CFS 能够兼顾吞吐和延迟的关键设计。

5. 组调度(CGroup)与带宽控制

CFS 的 task_group 调度实体天然支持层级带宽分配。每个 cgroup 拥有自己的 cfs_rq 和运行配额(cfs_bandwidth):

  • cpu.cfs_period_us:周期长度,默认 100ms。
  • cpu.cfs_quota_us:该周期内可用的 CPU 时间。
  • 权重模式:通过 cpu.shares 在 cgroup 间分配比例份额。

这直接对应 K8s 的 resources.requests.cpu 和 resources.limits.cpu。当一个 cgroup 的用满配额后,其进程会被节流(throttled),直到下个周期恢复。CFS 的组调度是容器化时代资源隔离的基础。

6. NUMA 感知调度:在 CPU 拓扑中追求公平

多 NUMA 节点架构下,跨节点内存访问延迟可能是本地节点的 2-3 倍。CFS 通过以下策略优化 NUMA 亲和性:

  1. NUMA balancing(AutoNUMA):内核周期性扫描进程的内存页访问位,跨节点比例过高时,将进程迁移到数据所在的 NUMA 节点。
  2. IDLE CPU 选择:select_task_rq_fair() 优先选择进程之前运行的 CPU,如果不可用则在同一 NUMA 节点内寻找空闲 CPU。
  3. sched_numa_balance:通过 numactl 和 sysctl numa_balancing 控制自动迁移行为。

对于数据库(如 PostgreSQL、Redis)、JVM 大内存应用,合理配置 NUMA 策略可带来 20%+ 的性能提升。numactl --interleave=all 和 --cpunodebind 是常用运维手段。

7. 高级特性与实践场景

7.1 Sched Autogroup:桌面交互体验黑科技

启用 CONFIG_SCHED_AUTOGROUP 时,setsid() 调用会创建隐式任务组。这使得浏览器标签页、终端分屏等场景获得组级公平性——即使一个标签页 fork 了大量进程,它与其他标签页仍为 1:1 的份额关系。在桌面系统上推荐开启,服务器环境可关闭以追求极致调度速度。

7.2 SCHED_BATCH 与 SCHED_IDLE

CFS 还提供两个特殊的调度策略:

  • SCHED_BATCH:批处理任务,vruntime 计算使用较长延迟,减少唤醒抢占,适合后台编译等。
  • SCHED_IDLE:极低的权重(3),只有系统完全空闲时才执行。适合 updatedb、makewhatis 等后台任务,完全不干扰前台应用。

7.3 sched_feat 开关

CFS 内部有一组功能开关可以通过 /sys/kernel/debug/sched_features 调整,比如 NO_NEXT_BUDDY、LAST_BUDDY、CACHE_HOT_BUDDY 等。这些开关允许细粒度控制缓存亲和性和 scheduler 行为调试。

8. 性能分析与调优工具

工具/接口用途
perf sched record/report记录调度延迟热力图,可视化上下文切换开销
cat /proc/<pid>/sched查看进程的 vruntime、nr_switches、exec_start 等统计
trace-cmd record -e sched_switch低开销跟踪调度事件序列
/proc/sys/kernel/sched_*调整 latency、granularity、migration_cost 等参数
schedstat (/proc/schedstat)查看 per-CPU 的运行队列统计

实战示例:一个 Web P99 延迟飙升的排查中,perf sched latency 显示某 CPU 上平均等待时间超过 200ms,schedstat 显示运行队列长度正常,最终发现是 sched_migration_cost 设置过短导致频繁跨核迁移破坏了 L2 缓存热度。将 sysctl_sched_migration_cost 从 500μs 调整到 5ms 后,P99 下降 40%。

9. CFS 的边界与演进方向

CFS 并非万能——它在极端实时场景下仍不如 SCHED_FIFO/SCHED_RR。内核开发社区正在几个方向上推进改进:

  • Latency Nice:在 vruntime 计算中引入显式的延迟偏好维度,独立于 CPU 份额。
  • 在 cgroup v2 中优化带宽分配:解决 DELTA 带宽分配偏差问题。
  • 异构 CPU 支持:如 ARM big.LITTLE 和 Intel 混合架构,大小核间的公平调度策略仍待完善。
  • eBPF 调度扩展:内核正在探索通过 eBPF 程序在安全沙箱中实现自定义调度逻辑。

总结

CFS 用一行公式定义了"公平",用红黑树高效地实现了它。理解 vruntime 的权重计算、调度选取流程、唤醒抢占条件和 NUMA 策略,是 Linux 系统性能调优和内核开发的基础功。无论是容器编排、数据库调优还是嵌入式实时性需求,对 CFS 的深入理解都能让你在性能分析和资源规划中更加游刃有余。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部