引言

完全公平调度器(Completely Fair Scheduler, CFS)是 Linux 内核自 2.6.23 版本引入的默认进程调度器,它的设计理念颠覆了传统基于时间片轮转的调度方式 —— 不再关注"每个进程运行多长时间",而是关注"每个进程的 CPU 时间分配是否公平"。

本文将从 CFS 的核心概念 vruntime(虚拟运行时间)出发,深入剖析红黑树调度数据结构、组调度(cgroup)集成、NUMA 多核负载均衡,并结合 eBPF 工具进行实战观测与调优。

1. CFS 核心理念 —— vruntime

CFS 的目标是让所有可运行进程的 vruntime 尽可能相等。vruntime 的计算公式为:

vruntime += delta_exec * (NICE_0_LOAD / weight)

其中:

  • delta_exec:进程实际运行的物理时间
  • NICE_0_LOAD:nice 值 0 对应的权重基准常量 (1024)
  • weight:根据 nice 值查表得到的优先级权重

这意味着低优先级进程(nice > 0)的 vruntime 增长更慢,获得更少的 CPU 时间;高优先级进程(nice < 0)的 vruntime 增长更快,但因为权重更高,实际获得的物理时间仍然更多。

2. 红黑树数据结构

CFS 使用红黑树(Red-Black Tree)来组织所有可运行进程,以 vruntime 作为排序键值。该数据结构提供了 O(log n) 的插入和删除效率,以及 O(1) 获取最左侧节点(最小 vruntime)的能力。

// 内核源码: kernel/sched/fair.c
struct cfs_rq {
    struct rb_root_cached tasks_timeline; // 红黑树根节点
    struct sched_entity *curr, *next, *last;
    unsigned int h_nr_running;           // 可运行任务数
    u64 min_vruntime;                     // 最小 vruntime 基准
    ...
};

关键设计要点:

  • 左节点优先:红黑树最左侧节点永远是需要调度的下一个进程
  • min_vruntime 基准:新进程的 vruntime 被初始化为当前 cfs_rq->min_vruntime,防止新进程饥饿老进程
  • key 偏移:sched_entity->vruntime - cfs_rq->min_vruntime 作为红黑树的实际键值

3. 调度时机与抢占

CFS 在以下场景触发调度决策:

  1. 时间片耗尽:scheduler_tick() 通过 HZ 周期性中断检查当前进程是否超过 sched_latency / nr_running 的配额
  2. 阻塞唤醒:进程从睡眠态唤醒时,check_preempt_curr() 对比唤醒进程与当前进程的 vruntime
  3. 新进程 fork:通过 task_fork_fair() 设置合理的初始 vruntime

Linux 引入了 GENTLE_FAIR_SLEEPERS 特性:对于长时间阻塞后被唤醒的进程,内核会适当减少其 vruntime,但不会低于 min_vruntime - sysctl_sched_latency,以避免频繁唤醒的进程获得不公平优势。

4. 组调度与 CGroup 集成

CFS 通过组调度(CONFIG_FAIR_CGROUP)支持层级资源分配。每个 cgroup 维护独立的 cfs_rq,父进程选择组内哪个 cfs_rq 获得时间片。

层级结构示例:
/system.slice/
├── nginx.service (shares=1024)
└── postgresql.service (shares=2048)

组调度的权重分配遵循比例原则:在相同层级下,shares 值越大,获得的 CPU 比例越高。cpu.shares 默认值为 1024。

两个重要的 cgroup 参数:

  • cpu.cfs_period_us:调度周期长度(默认 100ms)
  • cpu.cfs_quota_us:组在周期内的最大 CPU 时间(默认 -1 无限制)

5. NUMA 感知调度

在 NUMA(非统一内存访问)架构中,CFS 与内核调度域(sched_domain)协同实现负载均衡和 NUMA 感知。

关键机制:

  • idle_balance():CPU 空闲时从其他 CPU 拉取任务
  • load_balance():周期性在调度域内迁移任务,保持 CPU 利用率均衡
  • NUMA balancing(AutoNUMA):自内核 3.8 引入,将进程迁移到靠近其内存页面的 NUMA 节点
 NUMA 节点拓扑示例:
 ┌──────────────────┐    ┌──────────────────┐
 │    Node 0        │    │    Node 1        │
 │  CPU 0-7         │    │  CPU 8-15        │
 │  Local Memory    │    │  Local Memory    │
 └────────┬─────────┘    └────────┬─────────┘
          │     QPI/UPI Link      │
          └───────────────────────┘

NUMA balancing 的开销包括页迁移延迟和跨节点访存开销,可通过 /proc/sys/kernel/numa_balancing 开关控制。超算场景下通常手动绑核避免 NUMA 迁移。

6. eBPF 实战:观测 CFS 行为

利用现代 eBPF 工具可以零侵入地观察 CFS 调度行为。以下示例展示如何追踪进程上下文切换。

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

struct event {
    u32 prev_pid, next_pid;
    u64 vruntime_delta;
    char prev_comm[16], next_comm[16];
};

struct {
    __uint(type, BPF_MAP_TYPE_PERF_OUTPUT);
    __uint(key_size, sizeof(u32));
    __uint(value_size, sizeof(struct event));
} events SEC(".maps");

SEC("tp_btf/sched_switch")
int BPF_PROG(trace_sched_switch, bool preempt, struct task_struct *prev,
             struct task_struct *next) {
    struct event e = {};
    e.prev_pid = prev->pid;
    e.next_pid = next->pid;
    e.vruntime_delta = next->se.vruntime - prev->se.vruntime;
    bpf_probe_read_kernel_str(e.prev_comm, sizeof(e.prev_comm), prev->comm);
    bpf_probe_read_kernel_str(e.next_comm, sizeof(e.next_comm), next->comm);
    bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &e, sizeof(e));
    return 0;
}

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

调度延迟分析工具:

  • runqlat(BCC):统计进程就绪队列等待时间分布
  • runqlen(BCC):采样各 CPU 就绪队列长度
  • schedstat:读取 /proc/schedstat 获取调度域级统计数据
# 使用 runqlat 观察调度延迟
$ sudo runqlat-bpfcc 1 10
     usecs     : count     distribution
       0 -> 1  : 245      |**********              |
       2 -> 3  : 312      |*************           |
       4 -> 7  : 189      |*******                 |
       8 -> 15 : 67       |**                      |
      16 -> 31 : 23       |*                       |
      32 -> 63 : 8        |                        |
      64 -> 127: 3        |                        |

7. 性能调优实践

7.1 调度参数调优

# 调度周期长度,影响粒度与上下文切换开销
sysctl -w kernel.sched_latency_ns=6000000

# 最小调度粒度,小于此值不进行抢占
sysctl -w kernel.sched_min_granularity_ns=750000

# 唤醒抢占粒度,控制唤醒进程抢占当前进程的激进程度
sysctl -w kernel.sched_wakeup_granularity_ns=1000000

7.2 CPU 绑核与隔离

# 通过 taskset 将进程绑定到特定 CPU
taskset -c 2,3 ./high_perf_app

# 通过 cgroup CPU 配额限制容器资源
echo 50000 > /sys/fs/cgroup/cpu/myapp/cpu.cfs_quota_us
echo 100000 > /sys/fs/cgroup/cpu/myapp/cpu.cfs_period_us

7.3 实时性场景优化

对于延迟敏感型应用(金融交易、工业控制),需要结合 SCHED_FIFO 或 SCHED_DEADLINE:

chrt --fifo 99 ./trading_engine
chrt --deadline --sched-runtime=10000000 --sched-deadline=20000000 --sched-period=20000000 ./control_loop

8. CFS 未来发展趋势

CFS 仍在持续演进中,值得关注的几个方向:

  • Extensible Scheduler(sched_ext):Linux 6.12 引入的用户态调度器框架,允许通过 eBPF 程序自定义调度策略
  • Core Scheduling:在侧信道攻击威胁下,实现 SMT(超线程)级别的安全调度隔离
  • EAS 与 CFS 协同:Energy Aware Scheduling 与 CFS 在 Arm big.LITTLE 架构的深度融合
  • AI-driven 调度:利用机器学习预测进程行为,优化调度决策

总结

CFS 以其精妙的设计和卓越的自适应性,成为 Linux 内核中最为复杂的子系统之一。理解 vruntime、红黑树数据结构、组调度与 NUMA 感知机制,是每个系统程序员深入 Linux 内核的必经之路。结合 eBPF 等现代可观测工具,开发者能够在生产环境中精准定位调度瓶颈,实现极致性能优化。

深入理解 CFS,就是理解 Linux 如何在内核层面实现"公平"这一朴素而深刻的理念。

参考资料

  • Linux 内核源码:kernel/sched/fair.c
  • Documentation/scheduler/sched-design-CFS.rst
  • BCC 工具集:runqlat, runqlen, offcputime
  • Brendan Gregg — "Systems Performance" 2nd Edition
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部