一、为什么需要 CFS?

在操作系统的发展历史中,调度器曾长期陷入"时间片"的窠臼——每个进程获得固定时间片,用完到期切换。O(1)调度器在这一时期占据主导地位,但它的弊端也显而易见:交互式进程与批处理进程被一视同仁,不同类型的工作负载不能匹配不同的调度策略。2007年,Ingo Molnar 将完全公平调度器(CFS)引入 Linux 内核 2.6.23,彻底改变了这一局面。

CFS的设计理念反其道而行之:它不关心什么是最高优先级,哪个负载需要独占一方的"一碗水端平",而是梦想在"理想公平处理器"上各个进程都获得均等的 CPU 时间。两个进程各获50%;三个进程各获33%。这种相对公平能够自然而然地解决交互式进程与批处理进程的矛盾。

二、核心武器:虚拟运行时间 vruntime

CFS最大的创举是将物理运行时间虚拟化成 vruntime,它记录了一个进程在理想公平处理器上应该运行的时间。每个进程都有一个 vruntime 计数器,每次系统定时器中断(tick)触发时,当前运行的进程的 vruntime 就会被更新:

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->vruntime += calc_delta_fair(delta_exec, curr);
    update_min_vruntime(cfs_rq);
}

关键点在于 vruntime 的递增速率与进程的权重(nice value)成反比。低优先级的进程(nice 值越负)vruntime 递增得越慢,也就是说在真实时间 1 秒内,它的 vruntime 只加了少量值(nice 越负进程 vruntime 增长越慢)。因此,当调度器在红黑树中选择下个运行的进程时,显而易见,越小 runtime 的低优先级进程获得了更多的实际的 CPU 时间。

nice 到权重的转换由内核预定义的数组决定:sched_prio_to_weight[40],其中 nice=0 对应权重 1024,nice=-20 对应 88761(约 8.6 倍),nice=19 对应 15(不到 1/68)。这意味着 nice 值每降低 1 级,CPU 分配比率变化约 12.5%。

三、数据结构:红黑树

与传统调度器不同,CFS 没有队列概念,反而维护一棵以 vruntime 为 key 的红黑树。红黑树的最左端节点值为最小的节点(vruntime 最小),也就是随时可能被选中的运行进程。

内核optimize了这一数据结构:

  • rb_leftmost 缓存:直接缓存红黑树的最左节点,可在 O(1) 内获取下个运行进程
  • rb_root_cached:将树的根与最左节点缓存一起,进一步减少内存访问
  • __pick_first_entity():直接返回最左节点代表的就是待运行的进程

红黑树的插入与删除都是 O(log n),而选择下个运行进程优化到 O(1),这对于百万级进程场景的启动delay优化是巨大的。

四、调度时机:什么时候切换进程?

CFS体系的另一个核心问题在于什么时候切换。以下四个时机触发调度决策:

1. 自愿放弃

进程调用 sched_yield(),放弃 CPU,立即被插入红黑树的最右翻位置(因为 vruntime 再次采样时就在最右侧)。

2. 新进程创建

fork() 时,子进程的 vruntime 初始化为母进程的 vruntime,免得新进程狗屎良久占据 CPU。这一优化在服务器场景中尤为重要。

3. 处于或等待的进程醒来

当进程从阻塞状态停滞变为可运行时,它被插入红黑树。如果这只进程的 vruntime 显着小于当前运行的进程的 vruntime(低于一个阈值sysctl_sched_min_granularity),则立刻触发抢占(preemption)。

4. 定时器中断触发的 tick 调度

static void task_tick_fair(struct rq *rq, struct task_struct *task, int queued)
{
    struct cfs_rq *cfs_rq;
    struct sched_entity *se = &task->se;

    for_each_sched_entity(se) {
        cfs_rq = cfs_rq_of(se);
        entity_tick(cfs_rq, se, queued);
    }

    if (num_online_cpus() > 1)
        update_mowl_load_avg(se);

    if (sched_feat(DOUBLE_TICK))
        hrtick_start_fair(rq, task, 0);
}

每个 tick 中断都会检查当前进程是否超时了调度周期(sched_period),若要切换就设TIF_NEED_RESCHED标记,等到下一个安全时刻被切换。

五、调度周期与带宽控制

CFS 不是永远选择 vruntime 最小的进程运行,而是控制在每个调度周期内每个进程获得接近的运行时间。调度周期的定义为:

sched_period = sysctl_sched_min_granularity * nr_running

限:如果 nr_running 过多,sched_period 不小于 sysctl_sched_latency 的一个下限范围,使得调度周期不能无限变大。

进程的最大运行时间(ideal_runtime)由以下公式计算:

ideal_runtime = sched_period * (se_weight / cfs_rq_weight)

若进程运行时间超过 ideal_runtime,则标记需要调度,等待下个 tick 切换。

六、cgroup 与资源隔离

不仅仅仅允许进程层面的调度,还需要 组/用户/容器层面的资源隔离。CFS 通过调度对象sched_entity的嵌套结构支持这一需求: 每个调度组(cgroup)有自己的 cfs_rq,组内的进程先在组内的红黑树中的选,组中选出的代表(cfs_rq->rq->cfs 的 sched_entity)再在母 cfs_rq 的红黑树中的选择。

这使得容器的 CPU 限制(如 cgroup v2 cpu.max)成为可能:组内的会议时间被严格限制,无论组内有多少进程竞争。

七、多核调度

在 SMP 系统上,每个 CPU 都有一个 runqueue(rq),包含一个 CFS 红黑树。各个 CPU 独立地选择各自 cfs_rq 中的最佳进程。负载均衡(load balance)的职责将进程从忙迁到空闲的 CPU,这就要涉及到红黑树的切换操作。

与鸮小,由于进程的 wake_affine 策略(优先播到上那个核,从而利用内存缓存),我们可以无疑问非常guge 地提升缓存利用率。

新的调度策略如 SIS(SMT idle spread)和 STEERN(Shared LLC)进一步优化了多核场景下的缓存友好性与功耗平衡。

八、CFS 与实时进程

CFS 装新了"全都",反倒是一个半。对于对延迟敏感的实时进程,内核提供了两种 scheduling class:

  • SCHED_FIFO 和 SCHED_RR:基于优先级的实时调度类,高优先级的实时进程可以抢占 CFS 进程
  • SCHED_DEADLINE:基于 EDF(Earliest Deadline First) 的调度类,适用于有严格时限依赖的任务
  • SCHED_BATCH:针对批处理、长运行、无交互需求的进程,会减少唤醒抢占,适合用于编译等场景
  • SCHED_IDLE:仅在系统完全空闲时运行,最低优先级
  • 这些实时类的存在示意:"全都公"只能是对不恰其时的进程而言;对于真正的硬实时任务,必须入马策略来保障间歇的可控性。

    九、工程实践:如何观察调度行为

    1. perf 工具

    perf record -e sched:sched_switch -a sleep 10
    perf report --stdio

    2. ftrace 跟踪

    echo sched_switch > /sys/kernel/debug/tracing/set_event
    echo 1 > /sys/kernel/debug/tracing/tracing_on
    cat /sys/kernel/debug/tracing/trace_pipe

    3. bpftrace (现代方法)

    bpftrace -e 'tracepoint:sched:sched_switch {
      printf("%d -> %d on CPU %d\n",
        args->prev_pid, args->next_pid, args->target_cpu);
    }'

    4. /proc 接口查看

    cat /proc/[pid]/sched       # 查看特定进程的调度信息
    cat /proc/sched_debug       # 查看所有 CPU 运行队列的调度状态

    十、常见陷阱与最佳实践

    1. "公平"的相对性

    CFS 的公平是相对于内存,有时候长期跟踪的 VR 负载会导致 vruntime 极度损益偏長。将所有不相放的cgroup 中放算,能够有效闭关注这一问题。

    2. nice 值的不相误用

    不知道社最长的 CFS "bug" 是某人事定一只 "renice -n -5" 后所有进程都被挤占的故事。真实情况是 'nice值的效果对应权重表的虚实际、实绑的 CPU 分配比例距有巨大变化。保持教室为 "n 级的互换",谈论: |n =-20|:CFS 给予的 CPU 时间比 |n = 19|:可能获得不了1%的 CPU"

    3. CPU 密集型负载的平滑

    显而在 SMP 系统上,红黑树的同一个长期在一只 CPU 上运行的 CPU 密集型进程宠感到小量到。方法:放心手pit,利用taskset 或 cpusets.cpus 达到行箝式用的行。

    十一、结语:从处理器的角度看一体化

    回顾前文,十分显著:既能没有"真正"的"对同点",也成理"水分到每个进程"。CFS的核心是:维护 vruntime 的相相等造,用红黑树构建选型机制,在古老队列与重新来到虚拟时间。

    由CFS可见一斑:一个优秀的调度器,将通过简单的故事实现复杂的需求。与 Rust 所有权系统一样,CFS 也是由中单级的概念——特别是"努力虚拟举",在红黑树的租行中实现"无处可被占用,然后同被利用"的虚拟原则。这是 CFS 的重大意义:它带来系统特性与割度。

    思考题:你能否将 CFS 的"公平"概念延展到分布式微服务调度中?调度与本地的"红黑树"如何映射到跨节点的资源分发?欢迎留言讨论。

    点赞(0) 打赏

    评论列表 共有 0 条评论

    暂无评论
    立即
    投稿

    微信公众账号

    微信扫一扫加关注

    发表
    评论
    返回
    顶部