Linux 调度器负责维护可运行任务、选择下一个任务,并完成 CPU 上下文切换。sched_ext 并不是一套脱离 Linux 调度核心的独立机制,它仍然需要复用任务状态、Runqueue、唤醒、抢占和上下文切换等内核基础设施,只把部分调度决策交给 BPF struct sched_ext_ops 回调。

本文先介绍 Linux 内部一次 CPU 调度经过的主要环节和关键函数,再说明 sched_ext 如何接入这条主流程。本文不讨论具体业务策略。

在掌握基础流程后,可以继续阅读《sched_ext 与 scx_lavd 用户态前端解析》,了解 Rust 用户态程序如何管理 BPF 调度器。

本文中的函数名用于帮助阅读 Linux 调度代码。内核内部函数并不是稳定 ABI,名称、参数和调用层次可能随版本变化,分析具体代码时应以目标内核源码为准。

一、Linux 调度器管理什么

task_struct 与任务状态

Linux 调度的基本对象是线程。每个线程在内核中由一个 struct task_struct 表示,其中与调度相关的成员包括:

成员 含义
__state 睡眠、可中断等待等任务状态
on_rq 任务是否位于 Runqueue
on_cpu 任务是否正在某个 CPU 上执行
policy SCHED_NORMALSCHED_FIFOSCHED_EXT 等策略
priostatic_prio 动态优先级和静态优先级
cpus_ptr 任务允许运行的 CPU 集合
sched_class 任务所属调度类

从调度视角看,可以先把任务分成三种主要情况:

  1. 正在运行:当前占用某个 CPU。
  2. 可运行:具备运行条件,但正在队列中等待 CPU。
  3. 不可运行:正在等待 I/O、定时器、Futex 或其他事件。

“可运行”不等于“正在运行”。调度器的核心工作就是在正在运行与等待运行的任务之间进行选择。

每 CPU Runqueue

每个 CPU 都有一个 struct rq,即该 CPU 的 Runqueue。它保存当前运行任务、idle 任务、可运行任务数量以及各调度类自己的队列状态。

struct rq {
    raw_spinlock_t __lock;
    unsigned int nr_running;
    struct task_struct *curr;
    struct task_struct *idle;
    struct cfs_rq cfs;
    struct rt_rq rt;
    struct dl_rq dl;
    /* ... */
};

Runqueue 是 per-CPU 数据结构。修改任务是否入队、当前任务或调度类队列时,需要在正确的锁和中断上下文下进行。

调度类

Linux 使用 struct sched_class 抽象不同调度类。常见类别包括 deadline、real-time、fair、ext 和 idle。调度核心通过统一接口调用各调度类,而具体队列组织和任务选择由调度类实现。

典型接口包括:

struct sched_class {
    void (*enqueue_task)(struct rq *, struct task_struct *, int flags);
    bool (*dequeue_task)(struct rq *, struct task_struct *, int flags);
    void (*wakeup_preempt)(struct rq *, struct task_struct *, int flags);
    struct task_struct *(*pick_task)(struct rq *);
    void (*task_tick)(struct rq *, struct task_struct *, int queued);
    /* ... */
};

sched_ext 最终也以一个调度类接入 Linux 调度核心,只是它会把一部分工作继续转交给 BPF 回调。

二、任务唤醒、选择 CPU 与进入 Runqueue

任务等待 I/O、锁或定时器时通常处于不可运行状态。当等待条件满足,内核需要把它重新变成可运行任务。

try_to_wake_up:从睡眠变为可运行

常见唤醒入口是 try_to_wake_up(),上层还提供 wake_up_process()、等待队列唤醒函数等包装。

唤醒流程主要完成:

  1. 检查任务当前状态是否允许本次唤醒。
  2. 对任务状态和 CPU 归属进行同步。
  3. 为任务选择目标 CPU。
  4. 把任务加入目标 CPU 的可运行队列。
  5. 必要时通知目标 CPU 重新调度。

可以概括为:

事件完成 / 锁被释放 / 定时器到期
                │
                ▼
        try_to_wake_up()
                │
                ▼
        select_task_rq()
        选择目标 CPU
                │
                ▼
        ttwu_queue()
        放入本地或远端唤醒队列
                │
                ▼
        activate_task()
        enqueue_task()
                │
                ▼
        check_preempt_curr()

select_task_rq:选择目标 CPU

select_task_rq() 根据任务调度类、CPU affinity 和 CPU 状态选择目标 CPU,并调用该调度类对应的 CPU 选择函数。

这一步回答的是“被唤醒任务应该进入哪个 CPU 的队列”,尚未发生上下文切换。

ttwu_queue 与远端唤醒

如果唤醒者与目标任务位于不同 CPU,唤醒过程可能先把任务放入目标 CPU 的 wake list,再通过 IPI 等方式通知目标 CPU处理。这样可以让目标 CPU 在合适的上下文中完成入队。

具体路径会受内核配置和唤醒条件影响,常见相关函数包括 ttwu_queue()ttwu_queue_wakelist()sched_ttwu_pending()

任务入队与出队

activate_task()enqueue_task()

activate_task() 用于激活任务,内部调用 enqueue_task(),再由任务所属 sched_classenqueue_task 完成实际入队。

activate_task(rq, p, flags)
        │
        └── enqueue_task(rq, p, flags)
                │
                └── p->sched_class->enqueue_task(...)

入队后,p->on_rq、Runqueue 中的可运行任务数量和调度类内部状态都会相应变化。

deactivate_task()dequeue_task()

任务阻塞、退出或需要从当前队列移除时,内核通过 deactivate_task()dequeue_task() 完成相反过程。

出队不一定代表任务结束。例如任务等待 Futex 时会暂时离开可运行队列,之后被唤醒还会重新入队。

请求重新调度与抢占检查

新任务入队后,内核需要判断它是否应该让当前任务尽快让出 CPU。调度核心调用 check_preempt_curr(),再进入当前调度类的唤醒抢占判断。

如果需要重新调度,内核通过 resched_curr() 等函数给当前任务设置 TIF_NEED_RESCHED。对于远端 CPU,还可能发送 reschedule IPI。

新任务入队
    │
    ▼
check_preempt_curr()
    │
    ├─ 当前任务可以继续 ─► 返回
    │
    └─ 需要重新调度 ─────► resched_curr()
                              │
                              └─ 设置 NEED_RESCHED

设置重新调度标记并不等于立刻在任意位置切换任务。内核会在允许调度的时机进入调度流程,例如中断返回、系统调用返回或内核显式调用 schedule()

三、选择下一个任务并完成上下文切换

进入 schedule

任务主动阻塞时会调用 schedule();当前任务需要被抢占时,也会在合适的抢占点进入调度器。schedule() 做外围循环和状态处理,核心选择发生在 __schedule()

__schedule 的主要工作

__schedule() 的主线可以概括为:

  1. 取得当前 CPU 的 rq 和当前任务 prev
  2. 关闭或控制抢占,锁住 Runqueue。
  3. 如果 prev 不再可运行,把它从 Runqueue 移除。
  4. 更新 Runqueue 时钟和任务运行时间。
  5. 调用 pick_next_task() 选择 next
  6. 更新 rq->curr
  7. 如果 prev != next,调用 context_switch()
schedule()
    │
    ▼
__schedule()
    │
    ├─ 更新 rq 时钟和 prev 状态
    ├─ 必要时 deactivate_task(prev)
    ├─ pick_next_task(rq, prev, ...)
    ├─ rq->curr = next
    └─ context_switch(rq, prev, next)

选择下一个任务

pick_next_task() 根据调度类顺序和各类队列状态寻找下一个任务。调度核心并不直接理解每种策略的所有细节,而是调用对应 sched_class 的任务选择接口。

如果没有普通可运行任务,最终会选择每 CPU 的 idle 任务。idle 任务保证 CPU 在没有其他工作时仍有一个合法的当前任务。

sched_ext 管理的任务会在 ext 调度类对应的选择路径中被处理,DSQ 中的任务也在这一阶段进入 CPU 可执行的位置。

context_switch 上下文切换

prevnext 不同,context_switch() 完成从旧任务到新任务的切换。其工作主要分成地址空间和 CPU 寄存器上下文两部分。

地址空间切换:

如果切换到不同进程,通常需要切换 mm_struct 和页表相关状态。内核线程没有独立用户地址空间,会借用先前任务的 active mm,相关处理由 switch_mm_irqs_off()enter_lazy_tlb() 等架构相关路径完成。

寄存器与内核栈切换:

switch_to() 最终进入体系结构相关实现,保存 prev 的寄存器和栈状态,恢复 next 的状态。切换完成后,CPU 已经运行在新任务的内核上下文中。

context_switch()
    │
    ├─ switch_mm_irqs_off() / enter_lazy_tlb()
    ├─ prepare_task_switch()
    ├─ switch_to(prev, next, last)
    └─ finish_task_switch(prev)

调度时钟与时间片

周期性时钟中断会进入调度 tick 路径。scheduler_tick() 更新 Runqueue 时钟,并调用当前任务调度类的 task_tick()

时钟中断
    │
    ▼
scheduler_tick()
    │
    ├─ update_rq_clock()
    └─ curr->sched_class->task_tick()

task_tick() 可以更新当前任务的运行统计和调度类状态,并在需要时请求重新调度。无 tick CPU、动态 tick 和高精度定时器会使具体触发方式更复杂,但“更新时间并判断是否需要换任务”是理解该路径的基础。

四、任务阻塞、迁移与 Linux 调度全流程

任务等待 I/O、Futex 或条件变量时,会先设置睡眠状态,再调用 schedule()__schedule() 发现当前任务不再处于可运行状态后,将其出队并选择其他任务。

任务设置睡眠状态
        │
        ▼
     schedule()
        │
        ▼
deactivate_task(prev)
        │
        ▼
运行其他任务
        │
等待条件满足
        │
        ▼
try_to_wake_up(prev)
        │
        ▼
重新选择 CPU 并入队

Futex 的 WAITWAKE 正好可以对应这条路径:等待线程在竞争时阻塞并离开 Runqueue,唤醒者使其重新成为可运行任务。

任务迁移与 CPU 约束

任务并不能在任意 CPU 上运行。cpus_ptr、cpuset、CPU online 状态以及迁移限制共同构成 CPU 约束。

任务迁移意味着从一个 CPU 的 Runqueue 移除,再加入另一个 CPU 的 Runqueue。迁移过程必须正确处理两个 Runqueue 的锁、任务 on_cpu/on_rq 状态和并发唤醒。

调度代码中常见的相关函数包括 set_task_cpu()move_queued_task()migrate_task_to() 和 CPU stop 线程相关迁移路径。不同调度类还可能实现自己的负载均衡接口。

Linux 调度主流程总览

任务因事件完成而被唤醒
        │
        ▼
try_to_wake_up()
        │
        ▼
select_task_rq() ─── CPU affinity / online CPU
        │
        ▼
ttwu_queue() / activate_task()
        │
        ▼
enqueue_task() ───── 调度类内部队列
        │
        ▼
check_preempt_curr()
        │
        └─ resched_curr() 设置 NEED_RESCHED
                    │
                    ▼
                schedule()
                    │
                    ▼
               __schedule()
                    │
          ┌─────────┴─────────┐
          ▼                   ▼
deactivate_task(prev)   pick_next_task()
                              │
                              ▼
                    context_switch()
                              │
                              ▼
                       next 开始运行

后续阅读 sched_ext 时,可以把它看成嵌入这条主流程的一种调度类实现,而不是另一套平行的 CPU 切换机制。

五、sched_ext 如何接入 Linux 调度

一套 sched_ext 调度器通常包括两部分:

  1. BPF 调度程序:加载到内核中,实现 struct sched_ext_ops 中的回调,参与 CPU 选择、入队和 dispatch。
  2. 用户态加载程序:负责打开 BPF 对象、设置配置、加载并 attach struct_ops,同时读取统计信息和处理退出。

因此,“用户态调度器”并不意味着每次调度都要切换到用户态。实际调度关键路径仍然在内核中执行 BPF 程序,用户态程序主要管理生命周期和配置。

用户态加载程序
    │ open / load / attach
    ▼
BPF struct_ops 调度程序
    │ select_cpu / enqueue / dispatch / running / stopping
    ▼
内核 sched_ext 核心与 CPU

任务进入 sched_ext 调度体系

任务采用 SCHED_EXT 调度策略后,才由当前加载的 sched_ext 调度器管理。调度器注册完成后,内核会调用初始化相关回调,让 BPF 程序为全局状态、CPU 和任务准备所需数据。

常见的生命周期回调包括:

回调 含义
init 调度器整体初始化
init_task 初始化某个任务的调度状态
enable 任务开始受该调度器管理
disable 任务不再由该调度器管理
exit_task 清理任务相关状态
exit 调度器退出

init_taskexit_task 面向单个任务,initexit 面向整个调度器,两组概念不要混淆。

任务变为可运行

任务创建、被唤醒或者重新获得运行条件时,会进入 runnable 状态。此时调度器需要决定它应该接近哪个 CPU,并把它加入可供后续选择的队列。

select_cpu:选择候选 CPU

select_cpu 在任务被唤醒时调用,用于返回一个候选 CPU。返回值会影响任务后续入队位置,但它不等于任务已经立即在该 CPU 上运行。

s32 BPF_STRUCT_OPS(scheduler_select_cpu,
                   struct task_struct *p,
                   s32 prev_cpu,
                   u64 wake_flags)
{
    return prev_cpu;
}

prev_cpu 是任务此前运行的 CPU,wake_flags 描述本次唤醒的部分属性。最终选择还要受到 CPU online 状态和任务 CPU affinity 等约束。

enqueue:任务入队

当任务需要加入调度队列时,内核调用 enqueue。BPF 调度器可以在此保存任务状态,并将任务插入一个 Dispatch Queue。

void BPF_STRUCT_OPS(scheduler_enqueue,
                    struct task_struct *p,
                    u64 enq_flags)
{
    scx_bpf_dsq_insert(p, SCX_DSQ_GLOBAL,
                       SCX_SLICE_DFL, enq_flags);
}

不同内核版本中 DSQ 插入 helper 的名字可能变化,阅读代码时应以目标内核的 sched-ext.rst 和 BPF kfunc 定义为准。

DSQ 如何保存可运行任务

DSQ 是 Dispatch Queue 的缩写,是 sched_ext 用来组织待运行任务的队列抽象。

内置 DSQ:

  • SCX_DSQ_GLOBAL:所有 CPU 都可以访问的全局队列。
  • SCX_DSQ_LOCAL:当前 CPU 对应的本地队列。

自定义 DSQ:

BPF 调度器也可以创建自己的 DSQ,并使用一个 64 位 ID 标识。任务先被插入 DSQ,后续再由 dispatch 阶段送往某个 CPU 的本地 DSQ。

                    ┌──────────────┐
任务 enqueue ──────►│ Global DSQ   │
                    └──────┬───────┘
                           │ move/dispatch
          ┌────────────────┼────────────────┐
          ▼                ▼                ▼
     CPU0 Local DSQ   CPU1 Local DSQ   CPU2 Local DSQ

DSQ 描述的是调度任务如何排队,并不是用户态和内核态之间传递事件的 Ring Buffer。二者用途不同。

CPU 请求下一个任务

当 CPU 需要寻找下一个任务时,内核首先查看其本地 DSQ。如果本地 DSQ 没有可运行任务,再检查全局 DSQ;仍然没有任务时,会调用 BPF 调度器的 dispatch 回调。

dispatch 的职责是把可运行任务提供给 CPU。任务可能来自自定义 DSQ,也可能在回调中被直接插入目标队列。

void BPF_STRUCT_OPS(scheduler_dispatch, s32 cpu,
                    struct task_struct *prev)
{
    /* 向 cpu 的本地 DSQ 提供任务 */
}

这一过程可以概括为:

CPU 需要任务
    │
    ├─ 本地 DSQ 非空 ──► 选择本地任务
    │
    ├─ 全局 DSQ 非空 ──► 移动到本地 DSQ
    │
    └─ 两者均为空 ─────► 调用 ops.dispatch()

六、从任务运行到调度器退出

任务被 CPU 选中后进入 running 状态,内核调用 running 回调。这里可以记录任务真正开始执行的时间和所在 CPU。

void BPF_STRUCT_OPS(scheduler_running, struct task_struct *p)
{
    /* p 已经开始在当前 CPU 上运行 */
}

任务的时间片由 p->scx.slice 等 sched_ext 状态表示。时间片耗尽、任务主动阻塞、被抢占或者退出 CPU 时,会离开 running 状态。

任务停止运行

任务停止占用 CPU 时,内核调用 stopping。该回调描述的是一次 CPU 执行阶段的结束,不代表任务生命周期结束。

void BPF_STRUCT_OPS(scheduler_stopping,
                    struct task_struct *p,
                    bool runnable)
{
    /* runnable=true 表示任务仍可运行 */
}
  • runnable = true:任务仍有运行条件,之后可能再次入队。
  • runnable = false:任务进入睡眠、等待 I/O、等待 Futex 或其他不可运行状态。

如果任务从可运行状态转为不可运行,调度器还可能收到 quiescent 回调。任务以后被再次唤醒时,会重新经过 CPU 选择和入队阶段。

一次 sched_ext 调度的完整顺序

将上述环节串起来,一次常见的任务调度过程如下:

任务创建或被唤醒
        │
        ▼
ops.select_cpu()
选择候选 CPU
        │
        ▼
ops.enqueue()
插入 Global / Local / Custom DSQ
        │
        ▼
CPU 检查 Local DSQ 和 Global DSQ
        │
        ├─ 没有任务 ─► ops.dispatch()
        │
        ▼
任务进入 CPU 本地 DSQ
        │
        ▼
ops.running()
任务开始执行
        │
        ▼
ops.stopping()
任务停止占用 CPU
        │
        ├─ 仍可运行 ─► 重新入队
        │
        └─ 不可运行 ─► quiescent,等待再次唤醒

这里的回调并非每种路径都会无条件全部出现,具体调用条件仍要结合内核文档和代码判断,但该顺序可以作为阅读 sched_ext 调度器源码的主线。

用户态程序的生命周期

用户态加载程序通常按照以下顺序管理调度器:

  1. 打开 BPF object 或 skeleton。
  2. 设置只读配置和初始数据。
  3. load BPF 程序,经过 verifier 检查。
  4. attach struct_ops,使调度器开始工作。
  5. 读取统计信息或 Ring Buffer 事件。
  6. 收到退出信号后释放 Link,解除 struct_ops

Rust 前端常把 Link 保存为 Option<Link>,退出时通过 take() 移出并释放:

impl Drop for Scheduler<'_> {
    fn drop(&mut self) {
        if let Some(struct_ops) = self.struct_ops.take() {
            drop(struct_ops);
        }
    }
}

当 BPF 调度器退出或发生错误时,sched_ext 会记录退出信息。用户态可以读取退出原因,决定结束进程或重新加载调度器。

容易混淆的概念

概念 含义
用户态加载程序 加载、配置和管理 BPF 调度器
BPF 调度程序 在内核中执行 sched_ext_ops 回调
DSQ 保存待运行任务的调度队列
Ring Buffer 把事件或统计信息传给用户态
select_cpu 返回任务的候选 CPU
dispatch 在 CPU 缺少任务时提供任务
running/stopping 描述任务一次 CPU 执行阶段的开始与结束
init_task/exit_task 描述任务受 sched_ext 管理的生命周期

参考资料


文章作者: 易百分
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 易百分 !
  目录