第 02 课 · 进程与调度:CPU 空出来时,内核选谁?
本课环境
- 源码:
D:\Linux\rk3568_linux_sdk\kernel(Linux 5.10.160,RK3568 / ARM64,4 × Cortex-A55)- 主线文件:
kernel/sched/core.c、kernel/sched/fair.c、kernel/sched/sched.h、kernel/fork.c- 关键配置:
CONFIG_PREEMPT_NONE=y(内核不可抢占)- 阅读:约 35--45 分钟 | 实验:10 分钟,不需要板子
- 配套速查表:进程与调度速查表(字段索引 / 五类 /
__schedule五步 / CFS 常量)
承接上一课
你已经知道 PID 1 / PID 2 是怎么被创建的、initcall 是怎么被遍历的。
但"创建 task"只是把它放进系统。真正的问题是:
CPU 只有一个,下一条指令该执行谁的?
这一课把 task_struct、运行队列、调度类和 CFS 串成一条完整的决策链。全课只回答一个问题:下一个运行的 task,凭什么被选中?
本文速览
- 一个结构体两种身份 :
task_struct既是"资源柜子"又是"调度实体" - 三步入队 :
dup_task_struct → sched_fork → wake_up_new_task,"创建"不等于"运行" - 五个调度类 :优先级不靠数组,靠链接顺序 ;
for_each_class()从高地址往下问 __schedule()五步:更新时钟 → prev 出队 → 选 next → 切地址空间与寄存器 → 收尾- CFS 记账 :
vruntime += delta_exec × 1024 / weight,永远选最小者 - 三份真实实验输出:nice −5/0/5/10 的 68/22.3/7.3/2.4,以及睡眠者的"有限补偿"
- 本机特色 :这棵 SDK 内核带 69 处 Android RVH 厂商钩子 ,
sched_fork()里就有
一、两分钟热身:上一课的三个问题
先凭记忆回答,再展开。间隔检索比重新阅读更能形成长期记忆。
Q1:为什么 start_kernel() 里 mm_init() 必须早于 sched_init()?
因为调度器及后续初始化需要分配内存(页表、task 缓存、运行队列相关结构)。没有页分配器,任何
kmalloc都无从谈起。顺序表达的是"后者假设前者已经可用"。
Q2:内建驱动的 module_init() 等价于哪一级 initcall?
device_initcall,即 level 6。可加载模块则在insmod时执行init_module,级别差异消失。
Q3:"level 6 的驱动 initcall 执行完"是否等于"驱动 probe 完成"?
不等于。initcall 通常只注册驱动;probe 由驱动核心在设备与驱动匹配后调用,资源未就绪时还会
-EPROBE_DEFER延迟重试。
二、task_struct:一个结构体,两种身份
内核里没有"进程对象"和"线程对象"两套东西。调度器、文件系统、内存管理看到的都是同一个
struct task_struct(include/linux/sched.h:656,已核对)。
它可以被理解成一个"资源柜子 ",同时又是一个"可被调度的实体":
text
struct task_struct (sched.h:656)
┌──────────────────────────┬──────────────────────────┐
│ 身份 A:资源容器 │ 身份 B:调度实体 │
│ "我能看到什么、拥有什么" │ "我何时、在哪个 CPU 上跑" │
├──────────────────────────┼──────────────────────────┤
│ mm / active_mm 777/778│ state 665 │
│ fs 980│ on_rq 700 │
│ files 983│ prio/static/normal 702--704│
│ nsproxy 990│ rt_priority 705 │
│ signal / sighand 993/994│ sched_class 707 │
│ cred 953│ se / rt / dl 708/709/713│
│ thread 1397│ policy 741 │
│ │ cpus_mask 744 │
└──────────────────────────┴──────────────────────────┘
│
┌────────────┴────────────┐
▼ ▼
资源由 CLONE_* 决定共享 由 sched_class 决定归属
→ 进程 / 线程的分界 → 哪个调度类
2.1 身份 A:资源容器
| 字段 | 行号 | 含义 |
|---|---|---|
mm / active_mm |
777 / 778 | 用户地址空间;内核线程只有 active_mm |
fs |
980 | cwd、root |
files |
983 | 打开文件描述符表 |
nsproxy |
990 | namespace 集合 |
signal / sighand |
993 / 994 | 信号处理与共享状态 |
cred |
953 | uid/gid、capability |
thread |
1397 | 架构相关的寄存器/FPU 上下文 |
2.2 身份 B:调度实体
| 字段 | 行号 | 含义 |
|---|---|---|
state |
665 | 可运行 / 睡眠 / 停止 / 僵尸 |
on_rq |
700 | 是否在运行队列上 |
prio / static_prio / normal_prio |
702--704 | 动态 / 静态 / 归一化优先级 |
rt_priority |
705 | 实时优先级 |
sched_class |
707 | 指向所属调度类的 vtable |
se / rt / dl |
708 / 709 / 713 | 三种调度实体 |
policy |
741 | SCHED_NORMAL / FIFO / RR / DEADLINE... |
cpus_mask |
744 | 允许运行的 CPU 集合 |
💡 进程与线程的区别,不在调度器眼里
调度器只认识
task_struct。 "进程"和"线程"的差别,是创建时CLONE_*标志决定共享哪些资源:共享
mm、files、fs、sighand的 task 属于同一线程组,tgid相同。对调度器来说,它们都是一次调度决策的候选者。
2.3 state 不是"正在运行"
这是最容易被误读的字段之一。TASK_RUNNING(include/linux/sched.h:82)的准确含义是可运行:
text
┌─────────────────────────────────────────────┐
│ TASK_RUNNING = "可运行" │
│ ├── 正在 CPU 上跑 ← 由 rq->curr 表示 │
│ └── 在运行队列里排队 ← 由 on_rq 表示 │
└─────────────────────────────────────────────┘
真正的"正在运行"由 rq->curr 表示,不是由 state 表示。 其他常见状态:
| 状态 | 含义 |
|---|---|
TASK_INTERRUPTIBLE |
可被信号唤醒的睡眠 |
TASK_UNINTERRUPTIBLE |
不可打断的睡眠(D 状态,ps 里最难杀) |
TASK_STOPPED |
被 SIGSTOP 停住 |
EXIT_ZOMBIE |
已退出、等父进程收尸 |
EXIT_DEAD |
连收尸都完成了 |
三、从 fork() 到运行队列:三步
fork()、vfork()、clone() 最终都走 copy_process()(kernel/fork.c:1900,已核对)。它不直接"运行"新 task,而是完成三件事:
text
fork() / clone() / vfork()
│
▼
┌──────────────────────────────────────────────────────┐
│ ① dup_task_struct() kernel/fork.c:886 │
│ 复制 task_struct、分配内核栈、复制 thread_info │
│ → arch_dup_task_struct() fork.c:871 │
│ 此时新 task 状态 = TASK_NEW(不可运行) │
└──────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────┐
│ ② copy_process() kernel/fork.c:1900 │
│ 按 CLONE_* 复制/共享 mm、files、fs、sighand、 │
│ signal、nsproxy ... │
│ → sched_fork() fork.c:2133 → core.c:3469 │
└──────────────────────┬───────────────────────────────┘
▼
┌──────────────────────────────────────────────────────┐
│ ③ wake_up_new_task() kernel/sched/core.c:3594 │
│ state = TASK_RUNNING (:3602) │
│ 选 CPU (:3614) │
│ activate_task() 入队 (:3621) │
│ check_preempt_curr() 判断抢占 (:3623) │
│ → enqueue_task_fair() fair.c:5557 │
│ → enqueue_entity() fair.c:4259 │
│ → __enqueue_entity() fair.c:581 插入红黑树 │
└──────────────────────────────────────────────────────┘
▼
它才真正成为"调度候选者"
3.1 sched_fork() 决定了它属于哪个调度类
sched_fork()(3469 行)先调用 __sched_fork()(3307 行)清理调度实体,
然后设置 TASK_NEW(3479),继承父进程优先级(3484),再按优先级选类(3510 起):
c
/* kernel/sched/core.c:3510 */
if (dl_prio(p->prio))
return -EAGAIN; /* deadline:拒绝通过 fork 继承 */
else if (rt_prio(p->prio))
p->sched_class = &rt_sched_class;
else
p->sched_class = &fair_sched_class;
这三行解释了两件事:
- 普通进程默认落在 CFS(fair) ------因为绝大多数进程
prio落在普通区间; - deadline 任务不能靠 fork 继承 ------必须显式设置,否则
fork直接返回-EAGAIN。
⚠️ 注意:选择调度类 和真正被调度 是两件事。后者要等它入队并被
pick_next_task()选中。
3.2 一个"本机特色":Android RVH 钩子
如果你去翻这段源码,会看到一行不属于上游 5.10 的代码:
c
int sched_fork(unsigned long clone_flags, struct task_struct *p)
{
trace_android_rvh_sched_fork(p); /* :3471 ← 厂商钩子 */
...
}
这是 Rockchip/Android SDK 的 RVH(vendor hook)机制 。我统计了一下:kernel/sched/ 下共有 69 处 trace_android_rvh_* 调用。
为什么值得知道?
- 这些钩子是 SDK 厂商补丁 ,不在
kernel.org的 5.10 里------对照 Bootlin Elixir 时会对不上号; - 它们提供了"不改内核代码就能挂回调"的扩展点,Android 的性能/功耗调优大量依赖它;
- 排障时如果看到陌生的
android_rvh符号,先想到"这是厂商加料",不要怀疑自己读错了。
3.3 新任务不会"立刻"运行
wake_up_new_task() 只是让它可运行:
- 它可能立刻抢占当前 task(如果优先级更高);
- 也可能在队列里等到下一次调度点。
"创建"与"运行"之间,隔着一次调度决策。
四、运行队列与五个调度类
每个 CPU 有一个 struct rq(kernel/sched/sched.h:918),它是该 CPU 的"待办清单"。
CFS 任务放在 rq->cfs,这是一个 struct cfs_rq(:532):
| 字段 | 行号 | 作用 |
|---|---|---|
tasks_timeline |
544 | 红黑树根(按 vruntime 排序) |
min_vruntime |
539 | "虚拟时间的进度条",单调递增 |
text
struct rq (sched.h:918) ← 每 CPU 一个
┌──────────────────────┬──────────────────────┐
│ rq->cfs │ rq->rt / rq->dl │
│ struct cfs_rq :532 │ 实时 / deadline 队列 │
│ ┌────────────────┐ │ │
│ │ rb tree │ │ rq->curr ────────▶ 当前在跑的 task
│ │ (tasks_timeline│ │ rq->idle │
│ │ 按 vruntime) │ │ │
│ └────────────────┘ │ │
│ min_vruntime :539 │ │
└──────────────────────┴──────────────────────┘
4.1 sched_class 是调度器的"插槽"
struct sched_class(sched.h:1838)是一张函数指针表:
c
struct sched_class {
void (*enqueue_task)(struct rq *rq, struct task_struct *p, int flags);
void (*dequeue_task)(struct rq *rq, struct task_struct *p, int flags);
struct task_struct *(*pick_next_task)(struct rq *rq, ...);
void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);
...
};
每种调度策略实现同一套接口,内核按优先级依次询问。
4.2 五个调度类:从高到低
| 优先级 | 类 | 定义位置(已核对) | 段名 | 典型策略 |
|---|---|---|---|---|
| 最高 | stop_sched_class |
stop_task.c:112 |
__stop_sched_class |
停机/热插拔;普通任务永不进入 |
| 高 | dl_sched_class |
deadline.c:2521 |
__dl_sched_class |
SCHED_DEADLINE,按截止时间与运行预算 |
| 中 | rt_sched_class |
rt.c:2551 |
__rt_sched_class |
SCHED_FIFO / SCHED_RR,优先级 0--99 |
| 低 | fair_sched_class |
fair.c:11512 |
__fair_sched_class |
SCHED_NORMAL / BATCH / IDLE(本课重点) |
| 最低 | idle_sched_class |
idle.c:491 |
__idle_sched_class |
没别的任务可跑时运行 |
4.3 优先级不是数组,是链接顺序
这是内核里非常漂亮的一个设计。链接脚本把五个类按固定顺序放进同一个段
(include/asm-generic/vmlinux.lds.h:131--139,已核对):
c
/* vmlinux.lds.h:131 SCHED_DATA */
__begin_sched_classes = .;
*(__idle_sched_class) /* 地址最低 */
*(__fair_sched_class)
*(__rt_sched_class)
*(__dl_sched_class)
*(__stop_sched_class) /* 地址最高 */
__end_sched_classes = .;
于是:
c
/* kernel/sched/sched.h:1913 */
#define sched_class_highest (__end_sched_classes - 1)
/* sched.h:1919-1920 */
#define for_each_class(class) \
for_class_range(class, sched_class_highest, sched_class_lowest)
"地址更大"就等于"优先级更高" ,for_each_class() 从高地址往低地址遍历。
内核还在启动时做了一次自检(core.c:7476--7480):
c
BUG_ON(&idle_sched_class + 1 != &fair_sched_class ||
&fair_sched_class + 1 != &rt_sched_class ||
&rt_sched_class + 1 != &dl_sched_class);
BUG_ON(&dl_sched_class + 1 != &stop_sched_class);
如果哪天有人改了链接顺序,内核会在启动时直接 BUG------用一行断言把"布局假设"变成"硬约束"。
text
地址:低 ────────────────────────────────────▶ 高
idle fair rt dl stop
│ │ │ │ │
└───────┴────────┴───────┴────────┘
for_each_class 从右往左问
优先级:低 ◀──────────────────────────────── 高
4.4 pick_next_task():从高到低问一遍
c
/* kernel/sched/core.c:4606,省略快速路径 */
for_each_class(class) {
p = class->pick_next_task(rq);
if (p)
return p;
}
/* idle 类必须永远有任务可返回,否则内核 BUG */
BUG();
这段循环是调度器的"总开关":先问 stop,再问 dl、rt、fair,最后 idle。
只要高优先级类有可运行任务,低优先级类根本没有机会被问到。
💡 实时任务为什么会"饿死"普通任务?
因为
for_each_class()先问 RT。只要 RT 队列里一直有可运行任务,fair 类就没被问到。这不是 bug,而是优先级语义。防止饥饿要靠
sched_rt_runtime_us之类的带宽限制,而不是 CFS。
4.5 本课的范围
我们只展开 CFS 在单 CPU 上如何选下一个 task。
stop / dl / rt 的内部策略、SMP 负载均衡(load_balance())、cgroup 调度与带宽控制,
留到后续专题;exec、exit、wait4 的完整生命周期也会另开一课。
本课先把"从创建到入队,再到被选中"的决策链走通。
五、__schedule():决策与上下文切换
pick_next_task() 只负责"选"。真正完成切换的是 __schedule()(4685 行),由 schedule()(4875 行)等入口调用。
text
① update_rq_clock() 更新运行队列时钟
prev_state = prev->state (:4737)
│
② prev 不是 TASK_RUNNING?
→ deactivate_task() 把它移出运行队列 (:4761)
│
③ next = pick_next_task(rq, prev, &rf) (:4771)
清除 TIF_NEED_RESCHED
│
④ context_switch() (:3992)
处理 mm / lazy TLB
→ switch_to(prev, next, prev) (:4043)
│
⑤ finish_task_switch() (:3841)
解锁运行队列、mmdrop、task_work
5.1 ARM64 上,switch_to 到底切了什么?
通用宏在 include/asm-generic/switch_to.h:21,它调用架构的 __switch_to()。ARM64 分两层:
| 层 | 位置 | 干什么 |
|---|---|---|
| 架构状态 | arch/arm64/kernel/process.c:636 |
处理 FPU、MTE、SCTLR 等,然后调 cpu_switch_to() |
| 寄存器与栈 | arch/arm64/kernel/entry.S:1102 |
保存/恢复被调用者保存寄存器(x19--x28、x29/fp、sp、lr),并 msr sp_el0, x1(entry.S:1122) |
最后那条指令特别值得记:
在 ARM64 Linux 上,
current就是通过sp_el0获取的。
所以上下文切换不只是跳到一个函数:它同时切换了 寄存器、内核栈、地址空间(mm)和架构状态。
进程切换比线程切换贵,主要贵在地址空间和 TLB ------线程共享 mm,context_switch() 里可以跳过页表切换(lazy TLB 优化)。
5.2 本配置没有内核抢占
本机 .config 是 CONFIG_PREEMPT_NONE=y。这意味着:
- 内核代码不会因为时间片用完而被强制切走;
TIF_NEED_RESCHED只在返回用户态(kernel/entry/common.c:159)、显式schedule()、阻塞点或cond_resched()处被检查;preempt_enable()在这种配置下只减少抢占计数,不会触发调度(include/linux/preempt.h:207)。
text
PREEMPT_NONE(本机) PREEMPT(未启用)
───────────────────── ─────────────────────
内核代码一路跑到底 内核代码可在任意点被打断
只有"出内核"时才可能切换 调度延迟更小
调度延迟取决于最长内核路径 吞吐略降
这对嵌入式很重要:一个长时间不睡眠的内核线程(或驱动里的忙等)可能显著拉高其他任务的调度延迟 。
后面学中断和驱动时,我们会反复回到这一点。
六、CFS:用 vruntime 把 CPU 时间记账
CFS(Completely Fair Scheduler)的目标不是"每个任务运行同样长",而是按权重分配 CPU 时间 。
它用一个虚拟时钟 vruntime 来记账:
text
vruntime += delta_exec × NICE_0_LOAD / weight
NICE_0_LOAD = 1024(逻辑权重)
weight = sched_prio_to_weight[nice + 20]
delta_exec = 本次实际运行时间
核心规则只有一句:永远选 vruntime 最小的那个。
6.1 源码路径非常短
| 步骤 | 位置 |
|---|---|
update_curr() 在每次 tick / 调度点更新当前实体 |
kernel/sched/fair.c:862 |
| 执行累加 | fair.c:883 curr->vruntime += calc_delta_fair(delta_exec, curr) |
| 换算 | calc_delta_fair()(fair.c:679)→ __calc_delta()(fair.c:235) |
6.2 为什么公式里是 1024?
这是 nice 0 的逻辑 权重。64 位内核会把权重左移 SCHED_FIXEDPOINT_SHIFT(10 位)以提升精度,
所以实际的 NICE_0_LOAD = 1 << 20,se->load.weight 也是放大后的值。
分子分母同比例放大,比值不变------所以讲原理时用逻辑值 1024 就够了。
6.3 权重表:nice 改的是权重,不是"优先级数字"
sched_prio_to_weight[40](kernel/sched/core.c:8913)覆盖 nice −20...19:
c
/* -20 */ 88761, 71755, 56483, 46273, 36291,
/* -15 */ 29154, 23254, 18705, 14949, 11916,
/* -10 */ 9548, 7620, 6100, 4904, 3906,
/* -5 */ 3121, 2501, 1991, 1586, 1277,
/* 0 */ 1024, 820, 655, 526, 423,
/* 5 */ 335, 272, 215, 172, 137,
/* 10 */ 110, 87, 70, 56, 45,
/* 15 */ 36, 29, 23, 18, 15,
关键比值:
| nice | 权重 | 相对 nice 0 |
|---|---|---|
| −5 | 3121 | 3.05× |
| 0 | 1024 | 1.00× |
| 5 | 335 | 0.33× |
| 10 | 110 | 0.11× |
所以 nice −5 的任务长期大约拿到 nice 0 的 3 倍 CPU 时间。
⚠️ nice 越大,权重越小,优先级越低------和"数字越大越优先"的直觉正好相反。
6.4 时间片怎么算?
c
/* fair.c:42 */ sysctl_sched_latency = 6000000ULL; /* 6 ms */
/* fair.c:64 */ sysctl_sched_min_granularity = 750000ULL; /* 0.75 ms */
__sched_period()(fair.c:695):任务数 ≤ 8 时是 6 ms ;超过 8 个时拉长为nr_running × 0.75 ms,避免时间片过碎;sched_slice()(fair.c:709):slice = period × weight / total_weight,且不小于 0.75 ms。
6.5 选择:红黑树最左节点
text
cfs_rq (sched.h:532)
tasks_timeline (:544)
│
┌───────┴───────┐
vruntime=12 vruntime=40
┌───┴───┐ │
vr=8 vr=15 vr=55
▲
└── 最左节点 = vruntime 最小 = 下一个运行
__pick_first_entity() fair.c:618
pick_next_entity()(fair.c:4506)还会考虑 last / next / skip 这几个"buddy",
用于缓存亲和和唤醒抢占,而不是纯数学最小。
6.6 两个容易忽略的修正
修正一:tick 抢占。 check_preempt_tick()(fair.c:4420)比较当前任务已运行时间与 ideal_runtime;
超了就设置 TIF_NEED_RESCHED。
修正二:唤醒补偿。 place_entity()(fair.c:4172)给睡眠者最多**半个 latency(默认 3 ms)**的虚拟时间信用,
让它醒来后能"追赶"一点,但不会无限透支。这就是 GENTLE_FAIR_SLEEPERS。
⚠️ 不要把 vruntime 当成真实时间
vruntime是归一化后的虚拟时间。不同权重的任务,同样运行 1 ms,vruntime的增长量完全不同。这正是"按权重公平"的实现方式。
同样跑 1 ms,三种 nice 的记账差异:
text
nice 0 (weight 1024) → vruntime += 1.000 ms
nice -5 (weight 3121) → vruntime += 0.328 ms ← 增长慢,所以更容易被再次选中
nice 5 (weight 335) → vruntime += 3.058 ms ← 增长快,靠边站
七、动手实验:用真实权重表跑一遍 CFS
脚本 assets/cfs_sim.py 会从本机 kernel/sched/core.c 里解析真实的 sched_prio_to_weight[40],
然后用一个固定量子模型模拟"选最小 vruntime"的过程。
⚠️ 这是教学模型,不是内核本身
它忽略了:最小粒度、唤醒抢占、SMP 负载均衡、cgroup 调度、RT/DL 类。
它忠实还原的只有 CFS 的核心思想:选最小 vruntime,份额收敛到权重比。
脚本启动时会自己声明数据来源,这也是判断"它是不是真的读了你的源码"的依据:
text
Source: D:/Linux/rk3568_linux_sdk/kernel/kernel/sched/core.c
Parsed 40 weights (nice -20..19); nice 0 = 1024
Quantum: 1.0 ms, quanta: 2000, total: 2000 ms
任务 1:基线
bash
python -I assets/cfs_sim.py
本机实测输出:
text
Task nice weight runtime(ms) share weight-share
---------------------------------------------------------
A -5 3121 1360.0 68.00% 68.00%
B 0 1024 446.0 22.30% 22.31%
C 5 335 146.0 7.30% 7.30%
D 10 110 48.0 2.40% 2.40%
Runtime vs weight ratios (relative to the second task):
A/B: runtime 3.05x, weight 3.05x
C/B: runtime 0.33x, weight 0.33x
D/B: runtime 0.11x, weight 0.11x
First picks: A B C D A A A B A A
看三个地方:
share与weight-share几乎完全相同 (68.00% vs 68.00%)------这不是巧合,而是vruntime记账的必然结果;- A/B 的运行时间比 3.05×,权重比也是 3.05× ------CFS 的"公平"是按权重的公平,不是平均主义;
First picks里 A 连续跑了三拍 ------这是模型的简化痕迹:脚本每次只跑固定 1 ms 量子,而 A 的 vruntime 增长最慢,所以会被连续选中。真实 CFS 会按权重算时间片(sched_slice()),通常每个任务每轮只跑一次、权重大的时间片更长,但长期份额与模型一致。
任务 2:先预测,再验证
把 nice 改成 -10, 0, 10。先写下你的预测:A 会拿到多少份额?
bash
python -I assets/cfs_sim.py --nice=-10,0,10 --quanta 1200 --trace 0
本机实测输出:
text
Task nice weight runtime(ms) share weight-share
---------------------------------------------------------
A -10 9548 1072.0 89.33% 89.38%
B 0 1024 115.0 9.58% 9.59%
C 10 110 13.0 1.08% 1.03%
Runtime vs weight ratios (relative to the second task):
A/B: runtime 9.32x, weight 9.32x
C/B: runtime 0.11x, weight 0.11x
- A 约 89.3% ,B 约 9.6% ,C 约 1.1%;
- A/B 权重比是
9548/1024 ≈ 9.32,模拟运行时间比也是 9.32。
再想一个问题 :如果把 nice 改成 0, 0, 0,三者份额会怎样?为什么?
答案 :三者权重相同(都是 1024),
vruntime增长速度相同,长期份额各约 1/3 。CFS 在同权重下追求均分------这就是"完全公平"四个字的来源。
任务 3:睡眠者醒来
bash
python -I assets/cfs_sim.py --wakeup-demo --quanta 2000 --trace 0
本机实测输出:
text
Wakeup demo: the last task sleeps for the first half, then wakes with at most
half a latency of vruntime credit.
Task nice weight runtime(ms) share weight-share
---------------------------------------------------------
A -5 3121 1233.0 61.65% 55.59%
B 0 1024 405.0 20.25% 18.24%
C 5 335 133.0 6.65% 5.97%
D 10 110 44.0 2.20% 1.96%
E 0 1024 185.0 9.25% 18.24%
Runtime vs weight ratios (relative to the second task):
A/B: runtime 3.04x, weight 3.05x
E/B: runtime 0.46x, weight 1.00x ← E 明显没拿到它的权重份额
E 睡了前半程,所以整段时间 只拿到 9.25% (它的权重份额是 18.24%);
但在醒来后的后半程,它拿到了约 18.5%,与权重份额基本一致。
这就是 place_entity() 的"有限补偿":
text
时间轴 ──────────────────────────────────────────▶
E: [ 睡眠 1000 ms ] [ 运行 1000 ms ]
│
├── 醒来时获得 ≤ 3 ms(半 latency)的 vruntime 信用
│ → 能"追赶"一点,但补不回整段睡眠
▼
若补偿无限:E 一醒来就霸占 CPU,长期份额失真 ← 所以必须限制
为什么不能无限补偿?
如果内核给睡眠者无限补偿,它会一醒来就霸占 CPU,长期份额失真。
所以补偿被限制在半个 latency(默认 3 ms)。
任务 4:回到源码核对
bash
K=/d/Linux/rk3568_linux_sdk/kernel
grep -n "sched_prio_to_weight\[40\]" -A 10 $K/kernel/sched/core.c
grep -n "vruntime += calc_delta_fair" -A 2 $K/kernel/sched/fair.c
grep -n "sched_class_highest" -A 10 $K/kernel/sched/sched.h
grep -n "for_each_class(class)" -A 6 $K/kernel/sched/core.c | head -10
确认三件事:
- 权重表真实存在(
core.c:8913); vruntime更新公式与模拟器一致(fair.c:883);- 调度类顺序由链接布局决定(
sched.h:1913+vmlinux.lds.h:131)。
任务 5(可选,需要板子)
bash
ps -eo pid,ni,pri,psr,stat,comm | head -20
nice -n 10 sleep 30 &
taskset -c 0 yes > /dev/null &
# 实时任务需要 root:
chrt -f 50 sleep 5
ni是 nice,pri是内核看到的动态优先级(普通任务通常是 120 + nice ),psr是当前所在 CPU;- 用
chrt起一个 FIFO 任务,观察它是否压过普通任务------这是 4.4 节"先问 RT"的现场验证。
实验检查点
- 我能解释
vruntime公式中每个量的含义 - 我能说明 nice −5 为什么约等于 3 倍 CPU 份额
- 我能说清五个调度类的优先级与遍历顺序
- 我能区分"新任务入队"与"新任务开始运行"
- 我能解释睡眠者为什么只得到有限的 vruntime 补偿
- 我能说清
PREEMPT_NONE对调度延迟的影响
八、检索练习
先不要翻回上文。凭记忆作答。
Q1. task_struct 中哪个字段直接指向它所属的调度类?
A. state | B. policy | C. sched_class | D. on_rq
Q2. wake_up_new_task() 的关键动作是什么?
A. 把 task 从运行队列删除 | B. 把 task 插入运行队列并检查抢占 | C. 把 task 的 vruntime 清零 | D. 把 task 的地址空间复制一份
Q3. 五个调度类从高到低的正确顺序是?
A. stop → dl → rt → fair → idle | B. idle → fair → rt → dl → stop | C. rt → stop → dl → fair → idle | D. dl → stop → rt → idle → fair
Q4. CFS 中把一个任务的 nice 从 0 改为 −5,会发生什么?
A. vruntime 增长更快,CPU 份额更大 | B. vruntime 增长更快,CPU 份额更小 | C. vruntime 增长更慢,CPU 份额更小 | D. vruntime 增长更慢,CPU 份额更大
Q5. CFS 在同一调度类内选择下一个任务的主要依据是?
A. 最大 vruntime 的任务 | B. 最小 vruntime 的任务 | C. 优先级数字最大的任务 | D. 最近一次运行的任务
答案与解析
Q1 → C。
sched_class(include/linux/sched.h:707)直接指向调度类的函数表。
policy是用户态策略,最终会通过sched_setscheduler()反映到sched_class与优先级上。Q2 → B。
wake_up_new_task()(core.c:3594)把状态设为TASK_RUNNING,选择 CPU,调用activate_task()入队,再用check_preempt_curr()判断是否需要抢占当前任务。地址空间复制发生在更早的
copy_process()里。Q3 → A。 链接脚本按
idle → fair → rt → dl → stop放置(vmlinux.lds.h:131--139),
for_each_class()从高地址向低地址遍历(sched.h:1919--1920),所以优先级是stop → dl → rt → fair → idle。Q4 → D。 nice 从 0 降到 −5,权重从 1024 升到 3121。权重越大,
vruntime增长越慢,任务被选中得越频繁,CPU 份额越大。注意 nice 越小优先级越高,和普通"数字越大越优先"的直觉相反。
Q5 → B。 CFS 把可运行实体按
vruntime放进红黑树,取最左节点(__pick_first_entity(),fair.c:618),也就是虚拟时间最小的任务。真实实现还会用
last/next/skipbuddy 做缓存亲和与唤醒抢占修正。
九、四个常见误区
| 误区 | 真相 |
|---|---|
| "nice 值越大越优先。" | 反了。nice 越大,权重越小,优先级越低;普通任务的动态优先级通常是 120 + nice。 |
| "CFS 给每个任务相同时间片。" | 不是。时间片按权重切分(sched_slice());同权重才近似均分。 |
| "fork 完新任务马上运行。" | 它只是被放进运行队列;是否立即抢占,由 check_preempt_curr() 和调度点决定。 |
| "调度器随时能抢占内核代码。" | 本配置是 PREEMPT_NONE,只在返回用户态、阻塞点或显式调度点切换。 |
十、本课速查
10.1 一图流
text
task_struct ──┐
├─→ 按 CLONE_* 决定共享 → 进程 / 线程
│
└─→ sched_fork() 选类 → fair_sched_class
│
▼
rq->cfs (cfs_rq) 红黑树,按 vruntime 排序
│
▼
__schedule() → pick_next_task() → for_each_class()
│ stop→dl→rt→fair→idle
▼
context_switch() → switch_to()
│
▼
ARM64: __switch_to() → cpu_switch_to()
(msr sp_el0, next → 切换 current)
10.2 关键源码索引(逐条核对过)
| 位置 | 内容 |
|---|---|
include/linux/sched.h:656 |
struct task_struct |
include/linux/sched.h:707 / 708 |
sched_class / se |
kernel/fork.c:886 / 1900 |
dup_task_struct() / copy_process() |
kernel/sched/core.c:3307 / 3469 |
__sched_fork() / sched_fork() |
kernel/sched/core.c:3594 |
wake_up_new_task() |
kernel/sched/core.c:4606 / 4685 / 4875 |
pick_next_task() / __schedule() / schedule() |
kernel/sched/core.c:8913 |
sched_prio_to_weight[40] |
kernel/sched/sched.h:918 / 532 / 539 |
struct rq / struct cfs_rq / min_vruntime |
kernel/sched/sched.h:1913--1920 |
sched_class_highest / for_each_class |
kernel/sched/fair.c:862 / 883 / 679 |
update_curr() / vruntime 累加 / calc_delta_fair() |
kernel/sched/fair.c:42 / 64 / 695 / 709 |
latency 6 ms / min_granularity 0.75 ms / __sched_period() / sched_slice() |
kernel/sched/fair.c:581 / 618 / 4172 / 4420 / 4506 |
入树 / 取最左 / place_entity() / check_preempt_tick() / pick_next_entity() |
include/asm-generic/vmlinux.lds.h:131--139 |
SCHED_DATA(五类链接顺序) |
arch/arm64/kernel/process.c:636 |
__switch_to() |
arch/arm64/kernel/entry.S:1102 / 1122 |
cpu_switch_to / msr sp_el0 |
kernel/entry/common.c:159 |
返回用户态时检查 TIF_NEED_RESCHED |
10.3 常量与小工具
| 项 | 值 |
|---|---|
| nice 范围 | −20 ... 19 |
| nice 0 权重 | 1024(逻辑)/ 1 << 20(实际定点) |
| nice −5 / 5 / 10 权重 | 3121 / 335 / 110 |
SCHED_IDLE 权重 |
3(WEIGHT_IDLEPRIO,sched.h:1791) |
sysctl_sched_latency |
6 ms |
sysctl_sched_min_granularity |
0.75 ms |
| 睡眠者补偿上限 | 3 ms(latency / 2) |
结语
调度不是"谁更重要"的口号,而是一套记账系统:
CFS 用
vruntime记录每个 task 已经消耗的加权 CPU 时间,每次选择虚拟时间最小的那个。
nice 改的是权重,不是某个神秘的优先级数字。
这一课如果你只带走一句话,我希望是这句:
"创建"不等于"运行";"注册到类"不等于"被选中"。中间隔着一次调度决策。
下一篇:第 03 课《物理内存:一页内存从哪里来?》
从 memblock → 伙伴系统 → SLUB,回答三个问题:kmalloc 的一页内存到底从哪来?
为什么"空闲页够"仍可能分配失败?kmalloc(100) 落进哪个缓存?(剧透:kmalloc-128,因为本机 ARCH_DMA_MINALIGN=128。)
本课所有行号均已在本机 D:\Linux\rk3568_linux_sdk\kernel(Linux 5.10.160)源码上逐条核对;三份实验输出为 assets/cfs_sim.py 在本机的真实运行结果(权重表由脚本解析 kernel/sched/core.c:8913 得到)。本机内核为 RK3568 SDK 构建,带 69 处 Android RVH 厂商钩子,与上游 5.10 存在少量差异。