0.1 地图、平台与 demo 程序
调度器的知识很容易学得东一块西一块:今天看 EEVDF,明天看 cpu_switch_to,后天看大小核,彼此接不上。这一篇先立三样“坐标系”——一张 4+1 地图、一块示例板子、一个示例程序。后面 24 篇文章都在这三样东西上展开,每篇开头都会告诉你它站在地图的哪一格。
1. 为什么用 4+1 视图来学调度器
4+1 是 Philippe Kruchten 1995 年提出的架构描述方法。它的核心想法是:一个复杂系统,只画一张图是讲不清的。从不同角色的关注点出发,要画不同的“视图”,最后用一组“场景”把这些视图串起来、互相验证。
把它套到调度器上,正好对应我们学调度器时总会碰到的五类问题:
| 视图 | 回答的问题 | 拿 demo 举一个具体问题 | 在哪个系列集中讲 |
|---|---|---|---|
| +1 场景 | 某件事发生时,调度器按什么顺序做了什么? | fork() 返回后,子进程是什么时候、在哪个 CPU 上第一次跑起来的? |
A(主线) |
| 逻辑 | 有哪些抽象?数据结构长什么样?它们之间什么关系? | 子进程的 vruntime 和 deadline 初值是怎么算出来的? |
B |
| 进程 | 多个 CPU 同时动同一份数据时,怎么保证正确? | 父进程在 CPU4 上把子进程塞进 CPU2 的队列,CPU2 这时正在睡觉,怎么通知它? | C |
| 开发 | 代码在哪?哪些 Kconfig 会改变行为?怎么观测? | sched_fork 在哪个文件哪一行?怎么用 ftrace 看到它? |
E |
| 物理 | 映射到 ARM64 硬件是什么样? | 切换任务时到底换了哪几个寄存器?TTBR 和 ASID 怎么变? | D |
“+1”之所以是主线,是因为场景最容易验证:一个场景跑一遍,四个视图各自的说法对不对,立刻就能对上号。举个例子:
例:同一件事,五个视图各看到什么
事件:demo 父进程在 CPU4 上调用 fork(),子进程最终在 CPU2 上第一次运行。
- 场景:
svc #0→kernel_clone→copy_process→sched_fork→wake_up_new_task→ CPU2 的__schedule→ret_from_fork→eret。 - 逻辑:子进程的
sched_entity被place_entity()放在 CPU2 的cfs_rq上,vruntime = V,deadline = V + slice/2。 - 进程:CPU4 先拿子进程的
pi_lock,再远程拿 CPU2 的rq->lock;CPU2 当时在wfi里,只能靠 IPI 叫醒。 - 开发:
kernel/fork.c:2379调sched_fork,kernel/fork.c:2854调wake_up_new_task;tracepoint 是sched_process_fork和sched_wakeup_new。 - 物理:GIC 发 SGI 把 CPU2 从 WFI 叫醒;子进程第一次切入时分到一个新 ASID,写进
TTBR1_EL1;最后eret回到 EL0。
五句话讲的是同一件事,但少了任何一句,你都会在某个地方“卡住”。
2. 示例平台「X9」
为了让“选哪个 CPU”“算力多少”这类问题有具体的数字可算,全系列固定使用一块示例平台 X9。它参照当前旗舰级 Arm 多核 SoC 的典型形态设计。注意:capacity 等数字是为了讲解而设定的示意值,不对应任何具体产品。
| 项目 | 取值 | 对调度的影响 |
|---|---|---|
| CPU | 4×Cortex-A520(CPU0-3)+ 6×Cortex-A725(CPU4-9)+ 2×Cortex-X925(CPU10-11) | 三种算力 → 内核打开 sched_asym_cpucapacity,启用容量感知与 EAS |
| capacity | 280 / 760 / 1024(示意) | 决定任务“装不装得下”(task_fits_cpu)和 misfit 迁移 |
| 缓存 | DynamIQ DSU,12 核共享 L3 | 只有一个 LLC → 只有一层有效的 MC 调度域,内含 12 个单 CPU 组 |
| SMT | 无 | EAS 的必要条件之一(kernel/sched/topology.c:237) |
| 中断控制器 | GICv3(GIC-700) | IPI 用 SGI 实现;tick 来自每核 arch timer 的 PPI |
| ASID | 16 bit | 65536 个 ASID;开 KPTI 时成对使用,减半 |
| 调频/休眠 | SCMI,每簇一个 cpufreq policy;PSCI idle | schedutil + EAS;深睡的唤醒延迟会进入 A6/A7 的延迟分析 |
2.1 一个马上会用到的数:base_slice = 2.8ms
EEVDF 的默认时间片 sysctl_sched_base_slice 在源码里写的是 0.7ms(kernel/sched/fair.c:76),但启动时会按 CPU 数放大(kernel/sched/fair.c:191):
unsigned int cpus = min_t(unsigned int, num_online_cpus(), 8); // X9:min(12, 8) = 8
factor = 1 + ilog2(cpus); // 1 + 3 = 4
// sysctl_sched_base_slice = 0.7ms × 4 = 2.8ms
所以在 X9 上,cat /sys/kernel/debug/sched/base_slice_ns 会读到 2800000。后面算 deadline、算“子进程最多等多久”时都会用到这个数。
例:换一块板子,这个数会怎么变
| 平台 | 在线 CPU | factor | base_slice |
|---|---|---|---|
| 单核 MCU 级 Cortex-A | 1 | 1 + ilog2(1) = 1 | 0.7ms |
| 4 核 A55 | 4 | 1 + ilog2(4) = 3 | 2.1ms |
| X9(12 核) | 12 → 按 8 算 | 4 | 2.8ms |
| 128 核 Neoverse 服务器 | 128 → 按 8 算 | 4 | 2.8ms |
超过 8 核之后就不再放大,这是为了避免大机器上时间片太长、交互延迟变差。
2.2 在真板子上核对这些参数
cat /sys/devices/system/cpu/cpu*/cpu_capacity # X9 上应为 280×4, 760×6, 1024×2
cat /sys/devices/system/cpu/cpu0/topology/cluster_cpus_list
ls /sys/kernel/debug/sched/domains/cpu0/ # 只有 domain0,名字是 MC
cat /sys/kernel/debug/sched/domains/cpu0/domain0/flags # 应含 SD_ASYM_CPUCAPACITY_FULL、SD_BALANCE_FORK
dmesg | grep -i "EAS\|energy" # sched_energy_set: starting EAS
3. demo 程序
demo 只有 170 行(完整源码在仓库 examples/demo.c)。它的结构是:
demo(父进程)
└─ fork() ───────────────────────────── A1 / A2 / A3
└─ 子进程 child_main()
├─ worker SCHED_NORMAL 死循环计算 + 周期性拿 g_lock → A4 运行中、A8 misfit 迁移
├─ io SCHED_NORMAL 读 /proc/self/stat + usleep(5ms) → A5 睡眠、A6 唤醒
└─ ctrl SCHED_FIFO 80 1ms 周期 clock_nanosleep + 拿 g_lock → A7 抢占、C4 优先级继承
└─ 返回 → exit ─────────────────────── A9
最关键的几段:
/* 父进程:一次调用,两次返回(A1) */
fflush(stdout); /* 不刷的话,缓冲区会被 fork 复制,重定向时同一行会打两次 */
pid = fork();
if (pid == 0)
return child_main(seconds);
/* g_lock 用 PRIO_INHERIT:在内核里对应 rt_mutex,ctrl 等锁时会把 worker 临时提到 FIFO 80(C4) */
pthread_mutexattr_setprotocol(&ma, PTHREAD_PRIO_INHERIT);
/* ctrl:显式设置 SCHED_FIFO 80,否则 pthread 默认继承创建者的策略 */
pthread_attr_setinheritsched(&ra, PTHREAD_EXPLICIT_SCHED);
pthread_attr_setschedpolicy(&ra, SCHED_FIFO);
3.1 编译与运行
aarch64-linux-gnu-gcc -O2 -Wall -pthread -o demo examples/demo.c
scp demo board:/tmp/ && ssh board sudo /tmp/demo 5
在 X9 上的一次典型输出如下(示意;CPU 编号每次都可能不同,后面几篇会解释为什么):
[parent] pid=1234 running on cpu=4
[child ] pid=1235 first ran on cpu=2 ← A1 选核选到了小核 CPU2,A3 在 CPU2 上第一次运行
[parent] fork() = 1235, still on cpu=4
[worker] tid=1236 cpu=7
[io ] tid=1237 cpu=0
[ctrl ] tid=1238 cpu=10
[ctrl ] worst wakeup latency = 18 us ← RT 内核上的量级;非 RT 内核常见几百 us(A7 解释)
例:同一个程序在 x86 服务器上跑出来的样子
我们在一台 x86 服务器(非 RT 内核、普通用户、无 CAP_SYS_NICE)上实际跑了一次:
[parent] pid=791854 running on cpu=3
SCHED_FIFO 需要 CAP_SYS_NICE,ctrl 退回普通优先级
[child ] pid=791856 first ran on cpu=0
[worker] tid=791857 cpu=9
[io ] tid=791858 cpu=12
[ctrl ] tid=791860 cpu=14
[ctrl ] worst wakeup latency = 107 us
[parent] fork() = 791856, still on cpu=3
有两点可以留意:
- 子进程的第一行输出比父进程的
fork() = …那行先打印。说明子进程在另一个空闲 CPU 上很快跑起来了,而父进程还在kernel_clone的收尾阶段。这正是 A1 讲的“fork 选核挑空闲 CPU”的结果。 - ctrl 没有实时优先级,最坏唤醒延迟到了 107us。等它变成 FIFO 80、又跑在 RT 内核上时,这个数会怎么变,A7 会拆开讲。
3.2 观测工具:每篇都会用到
| 目的 | 命令 |
|---|---|
| 看 fork/唤醒/切换/迁移事件 | trace-cmd record -e sched:sched_process_fork -e sched:sched_wakeup_new -e sched:sched_switch -e sched:sched_migrate_task ./demo 2,然后 trace-cmd report |
| 看每个线程的调度时间线 | perf sched record -a -- sleep 3 后 perf sched timehist -p <pid> |
| 看一个任务的调度统计 | cat /proc/<tid>/sched(se.vruntime、se.avg.util_avg、nr_migrations…) |
| 临时插桩内核函数 | bpftrace -e 'kprobe:wake_up_new_task { printf("%s -> cpu%d\n", comm, cpu); }' |
| 调度特性开关 | cat /sys/kernel/debug/sched/features(PLACE_LAG RUN_TO_PARITY …) |
4. demo 的一生:状态机总图
场景系列 A 的 9 篇文章,就是沿着这张图的箭头逐条讲。图里反复出现三个字段,这里先说清楚它们的区别,后面几乎每篇都会用到:
| 字段 | 含义 | 谁来改 | 例子 |
|---|---|---|---|
p->__state |
任务想处于什么状态 | 任务自己(set_current_state),或唤醒者(ttwu) |
io 线程调 usleep 前被设成 TASK_INTERRUPTIBLE |
p->on_rq |
任务是否在某个运行队列上 | 持有该 rq 锁的一方(activate_task/block_task) |
io 线程在 __schedule 里出队时 on_rq 变成 0 |
p->on_cpu |
任务的寄存器现场是否还在某个 CPU 上 | prepare_task / finish_task |
io 已经出队,但 on_cpu 还是 1,直到 cpu_switch_to 完成、finish_task 执行 |
例:为什么需要三个字段,而不是一个
设想 io 线程在 CPU0 上调用 usleep() 进入睡眠,恰好这时 CPU5 上的定时器到期,要唤醒它:
- CPU0:
__state = TASK_INTERRUPTIBLE→__schedule()→on_rq = 0(出队)→ 正在cpu_switch_to…… - CPU5:
try_to_wake_up(io)看到on_rq == 0,知道要重新入队;但它不能立刻把io放到 CPU5 上运行,因为此时on_cpu还是 1,io的寄存器还没在 CPU0 上保存完。 - CPU5 只能
smp_cond_load_acquire(&p->on_cpu, !VAL)等 CPU0 执行完finish_task()。
如果只有一个“状态”字段,就无法区分“已经出队但栈还在用”这个中间态。这类问题 A6 和 C2 会详细展开。
5. 搭一个实验环境
没有 X9 真板子也能跟着做。用 QEMU 的 virt 机器模拟 12 核,再给设备树补上 capacity-dmips-mhz,就能得到一个“假的”大小核系统。调度器只看设备树给出的 capacity,并不关心 CPU 实际有多快。
# 1. 编 RT 内核(在 linux-6.12.y 源码树里)
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- defconfig
scripts/config -e EXPERT -e PREEMPT_RT -e SCHED_DEBUG -e FTRACE -e FUNCTION_TRACER \
-e SCHEDSTATS -e ENERGY_MODEL -e CPU_FREQ_GOV_SCHEDUTIL
make ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- olddefconfig Image -j$(nproc)
# 2. 导出 QEMU 自动生成的设备树,给 cpu@0..11 加上 capacity-dmips-mhz
qemu-system-aarch64 -M virt,dumpdtb=virt.dtb -cpu max -smp 12 -m 4G
dtc -I dtb -O dts virt.dtb > virt.dts
# 在 cpu@0..3 加 capacity-dmips-mhz = <280>; cpu@4..9 加 <760>; cpu@10..11 加 <1024>;
dtc -I dts -O dtb virt.dts > x9.dtb
# 3. 启动
qemu-system-aarch64 -M virt,gic-version=3 -cpu max -smp 12 -m 4G -dtb x9.dtb \
-kernel arch/arm64/boot/Image -append "console=ttyAMA0 root=/dev/vda rw" \
-drive file=rootfs.ext4,format=raw,if=virtio -nographic
QEMU 的局限
QEMU 只能模拟“调度器眼里的”大小核,capacity、调度域、misfit 这些逻辑都能正确复现。但是延迟数字没有参考价值:QEMU 的 vCPU 本身就是宿主机上的线程,会被宿主机调度打断。A7 里的延迟测量要在真板子上做。
6. 系列路线
- 全面路线:按 0 → A → B → C → D → E 的顺序读。A 系列会给出每个问题的第一印象,B 到 E 再把各个视图集中讲透。
- 实时路线:只关心 PREEMPT_RT 的话,按 0.1 → A1 → A3 → A6 → A7 → C3 → C4 → D1 的顺序读。
7. 自测
- 在一颗 6 核(全部同构)的 Cortex-A78 SoC 上,
base_slice_ns是多少? - 为什么 X9 只有一层有效的调度域(MC),而不是按三个丛集分成三组?
io线程在usleep()里,__state、on_rq、on_cpu三者分别是什么值?被唤醒、但还没轮到它运行时,又分别是什么?