Linux 调度器负责维护可运行任务、选择下一个任务,并完成 CPU 上下文切换。sched_ext 并不是一套脱离 Linux 调度核心的独立机制,它仍然需要复用任务状态、Runqueue、唤醒、抢占和上下文切换等内核基础设施,只把部分调度决策交给 BPF struct sched_ext_ops 回调。
本文先介绍 Linux 内部一次 CPU 调度经过的主要环节和关键函数,再说明 sched_ext 如何接入这条主流程。本文不讨论具体业务策略。
在掌握基础流程后,可以继续阅读《sched_ext 与 scx_lavd 用户态前端解析》,了解 Rust 用户态程序如何管理 BPF 调度器。
一、Linux 调度器管理什么
task_struct 与任务状态
Linux 调度的基本对象是线程。每个线程在内核中由一个 struct task_struct 表示,其中与调度相关的成员包括:
| 成员 | 含义 |
|---|---|
__state |
睡眠、可中断等待等任务状态 |
on_rq |
任务是否位于 Runqueue |
on_cpu |
任务是否正在某个 CPU 上执行 |
policy |
SCHED_NORMAL、SCHED_FIFO、SCHED_EXT 等策略 |
prio、static_prio |
动态优先级和静态优先级 |
cpus_ptr |
任务允许运行的 CPU 集合 |
sched_class |
任务所属调度类 |
从调度视角看,可以先把任务分成三种主要情况:
- 正在运行:当前占用某个 CPU。
- 可运行:具备运行条件,但正在队列中等待 CPU。
- 不可运行:正在等待 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()、等待队列唤醒函数等包装。
唤醒流程主要完成:
- 检查任务当前状态是否允许本次唤醒。
- 对任务状态和 CPU 归属进行同步。
- 为任务选择目标 CPU。
- 把任务加入目标 CPU 的可运行队列。
- 必要时通知目标 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_class 的 enqueue_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() 的主线可以概括为:
- 取得当前 CPU 的
rq和当前任务prev。 - 关闭或控制抢占,锁住 Runqueue。
- 如果
prev不再可运行,把它从 Runqueue 移除。 - 更新 Runqueue 时钟和任务运行时间。
- 调用
pick_next_task()选择next。 - 更新
rq->curr。 - 如果
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 上下文切换
当 prev 与 next 不同,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 的 WAIT 和 WAKE 正好可以对应这条路径:等待线程在竞争时阻塞并离开 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 调度器通常包括两部分:
- BPF 调度程序:加载到内核中,实现
struct sched_ext_ops中的回调,参与 CPU 选择、入队和 dispatch。 - 用户态加载程序:负责打开 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_task 和 exit_task 面向单个任务,init 和 exit 面向整个调度器,两组概念不要混淆。
任务变为可运行
任务创建、被唤醒或者重新获得运行条件时,会进入 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 调度器源码的主线。
用户态程序的生命周期
用户态加载程序通常按照以下顺序管理调度器:
- 打开 BPF object 或 skeleton。
- 设置只读配置和初始数据。
- load BPF 程序,经过 verifier 检查。
- attach
struct_ops,使调度器开始工作。 - 读取统计信息或 Ring Buffer 事件。
- 收到退出信号后释放 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 管理的生命周期 |