4. CFS 运行队列结构:cfs_rq 数据结构详解

CFS 调度器的核心数据结构是 cfs_rq(CFS Run Queue),它代表一个 CPU 上所有可运行的 CFS 调度类进程的集合。理解 cfs_rq 的内部结构,是掌握 CFS 调度行为的基础。

4.1 cfs_rq 核心字段解析

cfs_rq 结构体定义在 kernel/sched/sched.h 中,其关键字段如下表所示:

字段名 类型 作用描述
nr_running unsigned int 当前队列中可运行的调度实体数量(包括 task 和 group)
load struct load_weight 队列的总负载权重,用于负载均衡计算
min_vruntime u64 红黑树中最小的虚拟运行时间,是调度公平性的基准
tasks_timeline struct rb_root_cached 红黑树的根节点,用于按 vruntime 排序调度实体
curr struct sched_entity * 指向当前正在 CPU 上执行的调度实体
next struct sched_entity * 指向下一个被选中执行的调度实体(抢占优化)
last struct sched_entity * 指向上一个被调度执行的实体
tg struct task_group * 指向所属的任务组(用于 cgroup 层级)
关键理解: min_vruntime 是 CFS 公平性的“锚点”。新进程或唤醒进程的 vruntime 会被 clamp 到 min_vruntime 附近,防止它们因 vruntime 过小或过大而获得不公平的 CPU 时间。

4.2 红黑树与 min_vruntime 维护

CFS 使用红黑树(Red-Black Tree)来组织调度实体,以 vruntime 作为键值。红黑树保证了插入、删除和查找操作的时间复杂度为 O(log N)。

min_vruntime 的更新机制:

  • 时钟 tick 更新: 每次时钟中断时,如果当前进程是 CFS 调度类,会调用 update_curr() 更新当前进程的 vruntime,并尝试更新 cfs_rq->min_vruntime。
  • 入队/出队更新: 当进程入队或出队时,会检查红黑树最左节点(即 vruntime 最小的节点)的 vruntime,并更新 min_vruntime。
  • 防止回退: min_vruntime 是单调递增的,即使所有进程都出队,它也不会回退,而是会基于当前时间向前推进。
核心算法: min_vruntime = max(min_vruntime, tree_leftmost->vruntime)。这保证了 min_vruntime 始终是红黑树中所有实体 vruntime 的最小值,且不会因为进程退出而变小。

下图展示了红黑树与 min_vruntime 的关系:

CFS 红黑树与 min_vruntime 关系图 CFS 红黑树结构及 min_vruntime 锚点 vruntime=150 vruntime=100 vruntime=200 vruntime=50 vruntime=120 min_vruntime = 50 红黑树最左节点 vruntime 最小 min_vruntime 指向该节点

4.3 调度实体入队操作:enqueue_task_fair

当一个进程变为可运行状态(如被唤醒、fork 后首次运行)时,会触发入队操作。核心流程如下:

  1. 更新负载: 调用 update_load_add() 将进程的权重加到 cfs_rq->load 上。
  2. 更新统计: 调用 update_curr() 更新当前进程的 vruntime,并维护 cfs_rq->min_vruntime。
  3. 设置 vruntime: 对于新进程,其 vruntime 会被设置为 max(min_vruntime, se->vruntime),防止“插队”行为。
  4. 插入红黑树: 调用 __enqueue_entity() 将调度实体插入红黑树,键值为 se->vruntime。
  5. 更新 min_vruntime: 如果新插入的实体位于红黑树最左端,则更新 min_vruntime。
  6. 检查抢占: 如果新入队的实体 vruntime 小于当前运行实体的 vruntime,则设置 TIF_NEED_RESCHED 标志,触发调度。
注意: 在 cgroup 层级中,入队操作是递归的。一个 task 入队到其所在的 group 的 cfs_rq 后,该 group 的调度实体也需要入队到父 group 的 cfs_rq,直到根 cfs_rq。

4.4 调度实体出队操作:dequeue_task_fair

当一个进程不再可运行(如主动睡眠、被杀死)时,会触发以下出队流程:

  1. 更新负载: 调用 update_load_sub() 从 cfs_rq->load 中减去进程权重。
  2. 更新统计: 调用 update_curr() 记录进程的最后一次运行时间。
  3. 从红黑树移除: 调用 __dequeue_entity() 从红黑树中删除该调度实体。
  4. 更新 min_vruntime: 如果被移除的实体是红黑树最左节点,则需要重新查找新的最左节点并更新 min_vruntime。
  5. 处理 cgroup 层级: 如果该实体是一个 task group,还需要递归地从父 cfs_rq 中出队。

下表总结了入队与出队操作的关键差异:

操作 触发条件 对红黑树的影响 对 min_vruntime 的影响
入队 (enqueue) 进程变为可运行 插入新节点 可能增大(如果新节点在最左)
出队 (dequeue) 进程变为不可运行 删除已有节点 可能增大(如果删除的是最左节点)

4.5 入队/出队操作流程图

下图展示了 CFS 调度实体入队和出队的完整流程:

CFS 调度实体入队/出队流程图 CFS 调度实体入队与出队流程 入队流程 (enqueue_task_fair) 1. 更新负载 (load) 2. 更新统计 (update_curr) 3. 设置 vruntime 4. 插入红黑树 5. 更新 min_vruntime 6. 检查抢占 出队流程 (dequeue_task_fair) 1. 更新负载 (load) 2. 更新统计 (update_curr) 3. 从红黑树移除 4. 更新 min_vruntime 5. 处理 cgroup 层级

4.6 关键代码片段

以下是 __enqueue_entity() 和 __dequeue_entity() 的核心逻辑:

// 内核源码: kernel/sched/fair.c

static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    struct rb_node **link = &cfs_rq->tasks_timeline.rb_root.rb_node;
    struct rb_node *parent = NULL;
    struct sched_entity *entry;
    int leftmost = 1;

    // 红黑树查找插入位置
    while (*link) {
        parent = *link;
        entry = rb_entry(parent, struct sched_entity, run_node);
        if (entity_before(se, entry)) {
            link = &parent->rb_left;
        } else {
            link = &parent->rb_right;
            leftmost = 0;
        }
    }

    // 插入节点
    rb_link_node(&se->run_node, parent, link);
    rb_insert_color(&se->run_node, &cfs_rq->tasks_timeline.rb_root);

    // 更新最左节点缓存
    if (leftmost)
        cfs_rq->tasks_timeline.rb_leftmost = &se->run_node;
}

static void __dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
    // 如果删除的是最左节点,需要更新缓存
    if (cfs_rq->tasks_timeline.rb_leftmost == &se->run_node) {
        struct rb_node *next_node;

        next_node = rb_next(&se->run_node);
        cfs_rq->tasks_timeline.rb_leftmost = next_node;
    }

    // 从红黑树中删除
    rb_erase(&se->run_node, &cfs_rq->tasks_timeline.rb_root);
}
性能优化: rb_root_cached 结构缓存了红黑树的最左节点指针,使得获取最小 vruntime 实体的操作从 O(log N) 降为 O(1),这对调度器频繁的“选择下一个任务”操作至关重要。