引言:为什么理解 CFS 是后端工程师的必备能力

在 Linux 服务器上运行的每一个进程——从 Nginx worker 到数据库连接池,从消息队列 broker 到分布式协调节点——都在 CFS(Completely Fair Scheduler,完全公平调度器)的管控之下。作为 2.6.23 内核以来 Linux 默认的 CPU 调度器,CFS 凭借其"虚拟运行时间"(vruntime) 的精妙设计,在数千并发线程间实现了 O(log n) 精度的公平调度。

理解 CFS 不仅是内核爱好者的追求,更是后端工程师诊断延迟抖动、优化 P99 吞吐量、理解容器 CPU 限制效果的必备基础。本文将从第一性原理到完整工程实现,带你深入 CFS 的红黑树时间记账体系、时钟滴答决策逻辑、NUMA 负载均衡策略,并最终落地为一个可操作的调度器行为观测工具。

第一性原理:从"绝对公平"到"vruntime 时间记账"

传统调度器(如 O(1) 调度器)采用固定时间片 + 优先级数组的策略。这种方案有几个致命缺陷:

  1. 时间片粒度难以选择:100ms 对数据库事务太长,1ms 对科学计算太短
  2. 优先级反转难处理:高优先级进程耗尽时间片后突然降级,行为不可预测
  3. 交互检测 ad-hoc:通过睡眠/运行比猜测"交互进程",不准确

CFS 的革命性思路是:不去直接分配时间片,而是追踪每个进程欠 CPU 的"债务"。这个债务就叫做 vruntime (virtual runtime)。核心公式:

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

一个 NICE 0 的权重是 1024,NICE +5 的权重约 335,NICE -5 的权重约 3121。这意味着低 nice 值的进程每跑 1ms,vruntime 只增加 1×(1024/3121) ≈ 0.328ms,相对的,高 nice 值的进程跑 1ms,vruntime 会增加 1×(1024/335) ≈ 3.057ms。

调度器的决策极其简洁:选择 vruntime 最小的进程运行。如果所有进程的 vruntime 相同,它们就得到了"完全公平"的 CPU 分配。

红黑树调度器:O(log n) 的选择与插入

CFS 使用自平衡红黑树(rbtree) 来组织所有可运行进程(sched_entity),节点键值就是 vruntime。这个设计的精妙之处在于:

  • 最左节点(最小 vruntime)就是下一个要运行的进程——O(1) 获取
  • 选择下一个进程——删除最左节点再插入红黑树:O(log n)
  • 新增唤醒进程——插入红黑树:O(log n)

内核源码中典型的调度路径:

// kernel/sched/fair.c
static struct sched_entity *__pick_next_entity(struct cfs_rq *cfs_rq)
{
    struct rb_node *left = cfs_rq->rb_leftmost;
    return rb_entry(left, struct sched_entity, run_node);
}

// schedule() → 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 = pick_next_entity(cfs_rq);
    // ... 处理组调度返回最左 sched_entity
    return task_of(se);
}

为了优化频繁的进程切换,CFS 引入了 curr 缓存机制——当前运行的进程不从红黑树中移除,直接在其 sched_entity 上更新 vruntime。只有当它被抢占或主动让出时,才会重新入树。

时钟滴答决策:sched_period 与 min_granularity

CFS 不自设时间片,而是在滴答中断中检查当前进程是否该被抢占。关键参数:

参数默认值(典型)含义
sysctl_sched_min_granularity0.75ms进程最少运行时间(防止过度切换)
sysctl_sched_wakeup_granularity1ms唤醒抢占容忍延迟
sched_latency6ms目标调度延迟(此时间内所有可运行进程至少运行一次)

实际调度周期的计算:

// kernel/sched/fair.c
static u64 sched_period(int nr_running)
{
    // sched_latency / nr_running, 但不低于 min_granularity
    u64 period = sysctl_sched_latency;
    if (nr_running > (sysctl_sched_latency / sysctl_sched_min_granularity))
        period = nr_running * sysctl_sched_min_granularity;
    return period;
}

static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    // slice = period * se.weight / cfs_rq.weight
    return __sched_period(cfs_rq->nr_running + !se->on_rq)
           * se->load.weight >> cfs_rq->load.inv_weight;
}

例如:当 4 个 NICE 0 进程竞争 CPU 时,sched_slice = 6ms × 1024/(4×1024) = 1.5ms 每个进程。当 20 个进程时,sched_slice = 20×0.75ms × 1024/(20×1024) = 0.75ms(但受限于 min_granularity)。

工程实战:CFS 行为观测工具

基于 perf_event_open + tracepoint,我们可以实时观测进程的 vruntime 变化和调度决策过程。以下是核心代码框架:

#define _GNU_SOURCE
#include <linux/sched.h>
#include <linux/perf_event.h>
#include <sys/syscall.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

// 打开 perf event
static int open_perf_event(int cpu, int pid) {
    struct perf_event_attr pe = {
        .type = PERF_TYPE_TRACEPOINT,
        .config = 225,  // sched:sched_switch
        .sample_type = PERF_SAMPLE_RAW | PERF_SAMPLE_TIME,
        .sample_period = 1,
        .wakeup_events = 1,
    };
    return syscall(__NR_perf_event_open, &pe, pid, cpu, -1, 0);
}

// 通过 /proc 读取进程 vruntime
static u64 read_vruntime(pid_t pid) {
    char path[64], buf[256];
    snprintf(path, sizeof(path), "/proc/%d/sched", pid);
    FILE *f = fopen(path, "r");
    if (!f) return -1;

    while (fgets(buf, sizeof(buf), f)) {
        if (strstr(buf, "se.vruntime")) {
            u64 vruntime;
            if (sscanf(buf, "%*s %*s %lu", &vruntime) == 1) {
                fclose(f);
                return vruntime;
            }
        }
    }
    fclose(f);
    return 0;
}

int main(int argc, char **argv) {
    if (argc < 2) {
        fprintf(stderr, "用法: %s <pid> [seconds]\n", argv[0]);
        return 1;
    }

    pid_t target = atoi(argv[1]);
    int duration = argc > 2 ? atoi(argv[2]) : 10;
    u64 last_vruntime = read_vruntime(target);
    time_t last_time = time(NULL);

    printf("追踪 PID %d 的 CFS vruntime 变化\n\n", target);
    printf("%-12s | %-18s | %-12s | %-10s\n",
           "时间(s)", "vruntime(ns)", "增量(ms)", "运行状");
    printf("-------------+--------------------+------------+--------\n");

    for (int i = 0; i < duration; i++) {
        sleep(1);
        time_t now = time(NULL);
        u64 vruntime = read_vruntime(target);
        double elapsed = difftime(now, last_time);
        double delta_ms = (vruntime - last_vruntime) / 1e6;

        // 判断进程是否在运行:增量接近 wall time 说明在跑
        char state = (delta_ms > elapsed * 800.0) ? 'R' : 'S';

        printf("%-12lu | %-18lu | %-12.2f | %c\n",
               (unsigned long)elapsed, vruntime, delta_ms, state);

        last_vruntime = vruntime;
        last_time = now;
    }
    return 0;
}

使用方法:

$ gcc -o cfs_trace cfs_trace.c
$ taskset -c 0 $(pgrep my_worker) &  # 绑定单核激发竞争
$ ./cfs_trace $(pgrep my_worker) 10

输出示例:

追踪 PID 2847 的 CFS  vruntime 变化

时间(s)      | vruntime(ns)        | 增量(ms)     | 运行状
-------------+--------------------+------------+--------
1            | 8472958342          | 215.40      | R
2            | 8473178921          | 751.20      | R
3            | 8473934512          | 0.30        # 被抢占,vruntime 几乎没增
4            | 8473936788          | 1.90        # 进入睡眠
5            | 8473940123          | 748.20      # 重新被调度

NUMA 感知负载均衡:CFS 在分布式内存架构下的进化

在 NUMA 架构中(如 AMD EPYC 9654,12 CCD / 96 核),跨节点内存访问延迟是局部的 2-3 倍。CFS 的负载均衡器(load_balance)负责将进程迁移到"离家近"的 CPU 上。其决策链:

  1. IDLE 均衡:CPU 空闲时,从最忙的调度组偷取任务
  2. 周期性均衡:每个 tick 检查本节点是否需要拉取任务
  3. NUMA 均衡:发现进程的 memory 在远端节点时,唤醒 numabalancing_migrate

关键源码路径:

// kernel/sched/fair.c: 9500+ lines
static int load_balance(int this_cpu, struct rq *this_rq,
                        struct sched_domain *sd, enum cpu_idle_type idle)
{
    // 1. 找到最忙的调度组
    struct sched_group *group = find_busiest_group(sd, this_cpu, ...);
    // 2. 计算不平衡量
    env->imbalance = move_tasks(&env, ...);
    // 3. 执行进程迁移
    detach_tasks(&env);
    attach_tasks(&env);
}

// numabalancing: 主动扫描页访问模式
// mm/page_venv.c: task_numa_placement()
static void task_numa_placement(struct task_struct *p)
{
    // 比较访问远端 vs 本地内存的代价
    // 如果 diff > 0,触发页面迁移或进程迁移
}

工程上可以通过 numactl --cpunodebind=N --membind=N 手动控制,但现代内核的自动 NUMA 均衡在数据库 workload(MySQL、Redis)上表现也越来越好。

容器场景:CPU Throttling 的真相与调优

在 Kubernetes 中,cpu: "2" 的 limit 通过 CFS 的 band width control 实现:

// 伪代码:CFS bandwidth 控制
if (cfs_rq->runtime_expires < now) {
    // 组内总 cfs_runtime_remaining <= 0
    // → 所有 throttle,即使系统有空闲 CPU
    throttle_cfs_rq(cfs_rq);
}

这对延迟敏感型服务的影响可能非常诡异——container_cpu_cfs_throttled_seconds_total 指标上升时,即使宿主 CPU 利用率只有 50%,pod 仍可能被 throttle。解决方案:

  • 合理设置 requests = limits(Burstable → Guaranteed)
  • K8s 1.27+ 启用 cpuManagerPolicy: static 独占物理核
  • 高 QPS 服务考虑 kernel 5.14+ 的 core scheduling + SMT 隔离

调试工具箱:从问题到根因

问题现象排查工具CFS 相关线索
P99 延迟抖动perf sched latencyvruntime 变化不规则
CPU 争抢严重pidstat -wu 1%wait 高,vruntime 增量小
NUMA 远程访问numastat -p <pid>node1 内存占比高但进程绑 node0
Cgroup throttle<cgroup>/cpu.statnr_throttled > 0
锁竞争perf c2c recordHITM 事件发现跨核缓存争夺

总结与展望

CFS 的设计哲学是"计量而非分配"——通过 vruntime 的精准记账天然实现公平调度。从工程师角度需要记住的核心要点:

  1. vruntime 是 CFS 的硬通货:所有调度决策都围绕最小化 vruntime
  2. 红黑树保证 O(log n) 效率:即使 10k+ 并发进程也不会拖垮调度器
  3. min_granularity 是防止碎片化的闸门:太小→切换开销大,太大→响应延迟
  4. NUMA 均衡是高并发服务的隐藏变量:绑核策略直接影响内存延迟尾部分布
  5. CFS bandwidth control 是容器 CPU 节流的底层机制:理解它才能正确设置资源限制

随着内核 6.x 对 SCHED_BATCH、SCHED_IDLE 的持续优化,以及 sched_ext(外挂 BPF 调度器框架)的出现,CFS 的"一个调度器统治所有"的格局正在被补充。但对于理解现代操作系统的 CPU 资源管理,CFS 仍然是最重要的一课。

点赞(0) 打赏

评论列表 共有 0 条评论

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

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部