从 fork 到 exit:Linux 调度器全解Linux 6.12.110

0.1 地图、平台与 demo 程序

系列 0 · 导读+1 场景视图逻辑视图进程视图开发视图物理视图

调度器的知识很容易学得东一块西一块:今天看 EEVDF,明天看 cpu_switch_to,后天看大小核,彼此接不上。这一篇先立三样“坐标系”——一张 4+1 地图、一块示例板子、一个示例程序。后面 24 篇文章都在这三样东西上展开,每篇开头都会告诉你它站在地图的哪一格。

1. 为什么用 4+1 视图来学调度器

4+1 是 Philippe Kruchten 1995 年提出的架构描述方法。它的核心想法是:一个复杂系统,只画一张图是讲不清的。从不同角色的关注点出发,要画不同的“视图”,最后用一组“场景”把这些视图串起来、互相验证。

把它套到调度器上,正好对应我们学调度器时总会碰到的五类问题:

视图 回答的问题 拿 demo 举一个具体问题 在哪个系列集中讲
+1 场景 某件事发生时,调度器按什么顺序做了什么? fork() 返回后,子进程是什么时候、在哪个 CPU 上第一次跑起来的? A(主线)
逻辑 有哪些抽象?数据结构长什么样?它们之间什么关系? 子进程的 vruntimedeadline 初值是怎么算出来的? B
进程 多个 CPU 同时动同一份数据时,怎么保证正确? 父进程在 CPU4 上把子进程塞进 CPU2 的队列,CPU2 这时正在睡觉,怎么通知它? C
开发 代码在哪?哪些 Kconfig 会改变行为?怎么观测? sched_fork 在哪个文件哪一行?怎么用 ftrace 看到它? E
物理 映射到 ARM64 硬件是什么样? 切换任务时到底换了哪几个寄存器?TTBR 和 ASID 怎么变? D
图 0-1 调度器的 4+1 视图地图
图 0-1 调度器的 4+1 视图地图

“+1”之所以是主线,是因为场景最容易验证:一个场景跑一遍,四个视图各自的说法对不对,立刻就能对上号。举个例子:

例:同一件事,五个视图各看到什么

事件:demo 父进程在 CPU4 上调用 fork(),子进程最终在 CPU2 上第一次运行。

  • 场景svc #0kernel_clonecopy_processsched_forkwake_up_new_task → CPU2 的 __scheduleret_from_forkeret
  • 逻辑:子进程的 sched_entityplace_entity() 放在 CPU2 的 cfs_rq 上,vruntime = Vdeadline = V + slice/2
  • 进程:CPU4 先拿子进程的 pi_lock,再远程拿 CPU2 的 rq->lock;CPU2 当时在 wfi 里,只能靠 IPI 叫醒。
  • 开发kernel/fork.c:2379sched_forkkernel/fork.c:2854wake_up_new_task;tracepoint 是 sched_process_forksched_wakeup_new
  • 物理:GIC 发 SGI 把 CPU2 从 WFI 叫醒;子进程第一次切入时分到一个新 ASID,写进 TTBR1_EL1;最后 eret 回到 EL0。

五句话讲的是同一件事,但少了任何一句,你都会在某个地方“卡住”。

2. 示例平台「X9」

为了让“选哪个 CPU”“算力多少”这类问题有具体的数字可算,全系列固定使用一块示例平台 X9。它参照当前旗舰级 Arm 多核 SoC 的典型形态设计。注意:capacity 等数字是为了讲解而设定的示意值,不对应任何具体产品。

图 0-2 示例平台 X9 的拓扑(物理视图)
图 0-2 示例平台 X9 的拓扑(物理视图)
项目 取值 对调度的影响
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 3perf sched timehist -p <pid>
看一个任务的调度统计 cat /proc/<tid>/schedse.vruntimese.avg.util_avgnr_migrations…)
临时插桩内核函数 bpftrace -e 'kprobe:wake_up_new_task { printf("%s -> cpu%d\n", comm, cpu); }'
调度特性开关 cat /sys/kernel/debug/sched/featuresPLACE_LAG RUN_TO_PARITY …

4. demo 的一生:状态机总图

图 0-3 任务状态机 × 章节映射(场景视图)
图 0-3 任务状态机 × 章节映射(场景视图)

场景系列 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 上的定时器到期,要唤醒它:

  1. CPU0:__state = TASK_INTERRUPTIBLE__schedule()on_rq = 0(出队)→ 正在 cpu_switch_to……
  2. CPU5:try_to_wake_up(io) 看到 on_rq == 0,知道要重新入队;但它不能立刻把 io 放到 CPU5 上运行,因为此时 on_cpu 还是 1,io 的寄存器还没在 CPU0 上保存完。
  3. 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-4 系列路线图
图 0-4 系列路线图

7. 自测

  1. 在一颗 6 核(全部同构)的 Cortex-A78 SoC 上,base_slice_ns 是多少?
  2. 为什么 X9 只有一层有效的调度域(MC),而不是按三个丛集分成三组?
  3. io 线程在 usleep() 里,__stateon_rqon_cpu 三者分别是什么值?被唤醒、但还没轮到它运行时,又分别是什么?
参考答案 1. factor = 1 + ilog2(6) = 1 + 2 = 3,所以 base_slice = 0.7ms × 3 = 2.1ms。 2. 调度域是按“共享什么”来划分的:SMT(共享核)、CLS(共享 L2)、MC(共享 LLC)、PKG。X9 的 12 个核共享同一个 L3,拓扑上属于同一个 DynamIQ cluster,所以只剩 MC 一层。大小核之间的差异不靠划分调度域来表达,而是靠 **capacity 不对称**(`SD_ASYM_CPUCAPACITY_FULL`)来表达。 3. 睡眠时:`TASK_INTERRUPTIBLE` / 0 / 0。已被唤醒、在队列里等待时:`TASK_RUNNING` / 1 / 0。