A1 fork:一次调用,两次返回
这一篇只盯一行代码:demo 父进程在 CPU4 上执行 pid = fork();。在它返回之前,内核要造出一个新的 task_struct、伪造一份“从没发生过的”切换现场、给它算好虚拟时间、在 12 个 CPU 里挑一个放进去,并且在那个 CPU 正在睡觉的情况下把它叫醒。我们按执行顺序走一遍,每一步都标出源码行号,并用 X9 上的具体数字推演。
0. 场景设定
此刻的 X9
- demo 父进程 P(nice 0,
SCHED_NORMAL)正在 CPU4(A725)上运行,刚刚执行到fork()。 - 其他 CPU:CPU0、CPU2、CPU7 空闲(在
wfi里),其余 CPU 都在跑别的任务。各 CPU 的 util 见 图 A1-4。 - 内核:6.12.110,
CONFIG_PREEMPT_RT=y,12 核,base_slice = 2.8ms。
我们要回答的问题:
- 子进程 C 的调度相关字段是怎么从父进程那里“复制 + 修正”出来的?
- C 为什么最后会落到 CPU2(一个小核) 上?
- C 的
vruntime、deadline初值是多少?它能不能立刻抢占别人? - CPU4 把 C 放进 CPU2 的队列时,CPU2 正在
wfi,它是怎么知道自己该干活的?
1. 全景:12 步
| # | 步骤 | 源码位置 | 这一步和调度器的关系 |
|---|---|---|---|
| ②③④ | svc #0 → el0t_64_sync → el0_svc → __arm64_sys_clone |
arch/arm64/kernel/entry-common.c:839、kernel/fork.c:2934 |
进入内核,父进程的用户寄存器存进 pt_regs |
| ⑤ | kernel_clone() |
kernel/fork.c:2782 |
总调度者 |
| ⑥ | copy_process() → dup_task_struct() |
kernel/fork.c:2157、:1119 |
子 task_struct 是父的整块拷贝,包括所有调度字段 |
| ⑦ | sched_fork() |
kernel/sched/core.c:4683 |
修正调度字段:状态、优先级、调度类、PELT |
| ⑧ | copy_files/fs/sighand/signal/mm |
kernel/fork.c:2397-2409 |
copy_mm 决定将来切换时要不要换页表 |
| ⑨ | copy_thread() |
arch/arm64/kernel/process.c:352 |
伪造首次切换现场(cpu_context) |
| ⑩ | sched_cgroup_fork() |
kernel/sched/core.c:4757 |
定 cgroup,临时放在当前 CPU |
| ⑪⑫ | wake_up_new_task() |
kernel/sched/core.c:4822 |
真正选核、入队、决定是否抢占 |
下面按顺序展开。
2. 入口:ARM64 上根本没有 fork 系统调用
先纠正一个常见的误解:ARM64 的系统调用表里没有 fork。arch/arm64/include/asm/unistd.h 只在 CONFIG_COMPAT(32 位兼容)下定义了 __ARCH_WANT_SYS_FORK,原生 64 位只有 clone(220 号)和 clone3(435 号)。glibc 的 fork() 在 ARM64 上实际执行的是:
$ strace -f -e trace=clone,clone3 ./demo 1
clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD,
child_tidptr=0xffff9a3c10f0) = 1235
有两个细节会在后面用到:
SIGCHLD放在 flags 的低 8 位(CSIGNAL),表示子进程退出时给父进程发的信号,A9 会用到它。CLONE_CHILD_SETTID:内核要把子进程的 TID 写到子进程自己地址空间里的child_tidptr。这一步要等子进程第一次运行时,在它自己的上下文里完成(schedule_tail→put_user),A3 会看到这一步。
ARM64 还选了 CONFIG_CLONE_BACKWARDS(arch/arm64/Kconfig:128),所以 clone 的参数顺序是 (flags, newsp, parent_tid, tls, child_tid),和 x86-64 的 (flags, newsp, parent_tid, child_tid, tls) 不一样。直接用 syscall(SYS_clone, …) 写跨架构代码时容易在这里出错。
进入内核的路径(物理视图):
EL0: svc #0 (x8 = 220)
→ 异常向量 el0t_64_sync:kernel_entry 0 —— 把 x0-x30、sp_el0、elr_el1、spsr_el1 存进内核栈顶的 pt_regs
—— 从 per-cpu 的 __entry_task 取出 current,写入 sp_el0
→ el0t_64_sync_handler:ESR_EL1.EC == SVC64 → el0_svc(regs)
→ do_el0_svc → invoke_syscall → __arm64_sys_clone → kernel_clone(&args)
这份 pt_regs 很重要:它是父进程“在用户态停下来那一刻”的完整现场。第 5 节 copy_thread 会把它原样复制给子进程,只改掉 x0。
3. dup_task_struct:先整块复制,再逐项修正
// kernel/fork.c:1119(节选)
static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
tsk = alloc_task_struct_node(node);
err = arch_dup_task_struct(tsk, orig); // ARM64: 先 fpsimd_preserve_current_state(),再 *dst = *src
err = alloc_thread_stack_node(tsk, node); // 16KB 内核栈(THREAD_SIZE)
...
}
*dst = *src 这一句决定了本篇后半部分的写法:子进程的 se.vruntime、se.avg、prio、sched_class、on_cpu、cpus_mask……全部是父进程此刻的值。所以 sched_fork 的任务不是“初始化”,而是“把不该继承的东西改掉”。
ARM64 的 arch_dup_task_struct(arch/arm64/kernel/process.c:297)在拷贝前多做了一件事:fpsimd_preserve_current_state()。父进程的浮点/SIMD 寄存器此刻可能还只在 CPU 的物理寄存器里(懒保存),没写回 task_struct。不先保存的话,子进程拷到的就是一份过期的 FP 状态。它还会把 sve_state 指针清空,因为 SVE 状态缓冲区是单独分配的,不能让父子共用同一块。
4. sched_fork:调度器第一次介入
// kernel/sched/core.c:4683
int sched_fork(unsigned long clone_flags, struct task_struct *p)
{
__sched_fork(clone_flags, p); // ① 清零运行统计
p->__state = TASK_NEW; // ② 谁也别想唤醒它
p->prio = current->normal_prio; // ③ 不继承 PI 提升
uclamp_fork(p);
if (unlikely(p->sched_reset_on_fork)) { ... } // ④ reset-on-fork
if (dl_prio(p->prio))
return -EAGAIN; // ⑤ DEADLINE 任务不能 fork
if (rt_prio(p->prio)) p->sched_class = &rt_sched_class; // ⑥ 选调度类
else if (task_should_scx()) p->sched_class = &ext_sched_class;
else p->sched_class = &fair_sched_class;
init_entity_runnable_average(&p->se); // ⑦ PELT 初值
p->on_cpu = 0;
init_task_preempt_count(p); // ⑧ FORK_PREEMPT_COUNT
...
}
4.1 ① __sched_fork:把“历史”清零
__sched_fork(kernel/sched/core.c:4445)把所有累计量清零:
| 字段 | 父进程此刻(例) | 子进程清零后 | 为什么 |
|---|---|---|---|
se.sum_exec_runtime |
3.2 s | 0 | 子进程还没跑过 |
se.vruntime |
101.0 ms(相对值) | 0 | 真正的值在入队时由 place_entity 重新计算 |
se.vlag |
-0.3 ms | 0 | 新任务不欠账也不被欠账,这决定了 7.3 节的放置结果 |
se.nr_migrations |
17 | 0 | 统计量 |
on_rq / se.on_rq |
1 | 0 | 还没入队 |
rt.time_slice |
— | sched_rr_timeslice(100ms) |
RR 任务的新时间片 |
4.2 ② TASK_NEW:一个“谁也碰不到”的状态
从这里开始,一直到 wake_up_new_task 之前,子进程都处于 TASK_NEW 状态。这段时间里,子进程已经挂到了 PID 哈希表上,外界是能看到它的。比如另一个进程这时对它 kill -CONT,或者 ptrace 去唤醒它,会发生什么?
try_to_wake_up() 只唤醒状态匹配的任务(TASK_NORMAL 等),TASK_NEW 不在任何唤醒掩码里,所以这些外部唤醒全部无效。这就保证了子进程只会被 wake_up_new_task 放进队列一次,不会出现“还没初始化完就被别人塞进运行队列”的情况。
4.3 ③ 不继承 PI 提升:一个 demo 里真实可能发生的例子
例:worker 被 PI 提升到 FIFO 80 时 fork
假设 demo 的 worker 线程(nice 0,normal_prio = 120)正持有 g_lock,这时 ctrl(FIFO 80)来抢锁。PI 机制把 worker 临时提升,worker->prio = 19(内核内部的 FIFO 80)。如果 worker 恰好在这时调用 fork():
- 子进程拷到的
prio本来是 19,也就是一个“实时”优先级; sched_fork把它改成current->normal_prio = 120,子进程仍然是普通的 nice 0 任务;- 如果不改,子进程会以 FIFO 80 身份运行,而它根本没持有任何锁。这是一次凭空的优先级提升,会把真正的实时任务挤掉。
4.4 ④⑤⑥ 策略与调度类:五个父进程,五种结果
| 父进程 | fork() 的结果 |
子进程调度类 | 说明 |
|---|---|---|---|
| demo 父进程(nice 0) | 成功 | fair | 最常见的情况 |
nice -n -5 的进程 |
成功,子进程也是 nice -5 | fair | static_prio 被原样继承 |
ctrl(FIFO 80)调用 fork() |
成功,子进程也是 FIFO 80 | rt | 实时策略会被继承!所以在 RT 线程里 fork 要小心 |
同上,但先 chrt --reset-on-fork -f 80 |
成功,子进程变回 NORMAL nice 0 | fair | ④ sched_reset_on_fork 生效 |
SCHED_DEADLINE 任务 |
失败,errno = EAGAIN | — | ⑤ DL 带宽是按任务预留的,不能凭空复制出一份。除非设置了 reset-on-fork |
例:亲手验证“DEADLINE 任务不能 fork”
$ sudo chrt -d --sched-runtime 1000000 --sched-deadline 10000000 --sched-period 10000000 0 \
bash -c 'ls >/dev/null; true'
bash: fork: retry: Resource temporarily unavailable
...
bash: fork: Resource temporarily unavailable
bash 自己就是 DL 任务,它要 fork 出 ls 时,sched_fork 返回 -EAGAIN,bash 重试几次之后放弃。命令末尾的 ; true 是为了阻止 bash 把最后一条命令直接 exec 掉、从而根本不 fork。
4.5 ⑦ PELT 初值:负载满、利用率 0
// kernel/sched/fair.c:1055
void init_entity_runnable_average(struct sched_entity *se)
{
memset(sa, 0, sizeof(*sa));
if (entity_is_task(se))
sa->load_avg = scale_load_down(se->load.weight); // nice 0 → 1024
}
这里有一个有意设计的不对称:
load_avg设成满值(1024):负载均衡把新任务当作“重”任务来看待,宁可高估。否则一连串 fork 出来的任务,每个都显得“很轻”,负载均衡会把它们全部堆在同一个 CPU 上。util_avg先设成 0:真正的初值要等到入队时,由post_init_entity_util_avg根据目标 CPU 的情况来推算(见 7.2 节)。
这个“先 0 后推算”的时间差,正是第 7 节里 C 会被放到小核的根本原因。
4.6 ⑧ FORK_PREEMPT_COUNT:给 A3 埋的伏笔
init_task_preempt_count(p) 把子进程的 preempt_count 设为 FORK_PREEMPT_COUNT = 2 × PREEMPT_DISABLE_OFFSET + PREEMPT_ENABLED(include/linux/preempt.h:76)。为什么新任务要带着“抢占已关两层”出生?因为它第一次运行时,是从 __schedule() 的中间“醒来”的,那里 rq 锁还没释放、抢占还关着。A3 的 图 A3-4 会把这笔账一项项对上。
5. copy_mm 与 copy_thread:决定“将来切换时要换什么”
5.1 copy_mm:进程与线程的分水岭
| 调用者 | clone flags | copy_mm 的结果 |
A3 切入时 |
|---|---|---|---|
fork() |
无 CLONE_VM |
dup_mm:复制页表,私有可写页全部改成只读(COW) |
prev->mm != next->mm → 切 TTBR0、分配 ASID |
pthread_create() |
CLONE_VM \| CLONE_THREAD \| … |
共享同一个 mm,mm_users++ |
同进程的线程之间切换 → 页表不动 |
例:fork 对父进程里的实时线程也有代价
dup_mm 会把父进程里所有私有可写页的 PTE 都改成只读,这样才能实现写时复制。代价是:demo 子进程 fork 出来之后,父进程里任何线程第一次写这些页,都会触发一次缺页并复制页面,这也包括父进程里的实时线程。
所以,实时程序的常见建议是:在创建 RT 线程之前完成所有 fork,或者改用 posix_spawn/vfork(CLONE_VM | CLONE_VFORK,不复制页表)。demo 里 ctrl 线程是在子进程内部、fork 之后才创建的,正好避开了这个问题。
5.2 copy_thread:伪造一份“刚被切走”的现场
arch/arm64/kernel/process.c:352 做了三件事:
*childregs = *current_pt_regs(); // 父进程 svc 时保存的现场,整份复制
childregs->regs[0] = 0; // ← 子进程的 fork() 返回值就是这么来的
...
p->thread.cpu_context.pc = (unsigned long)ret_from_fork;
p->thread.cpu_context.sp = (unsigned long)childregs;
这是整个 fork 里最巧妙的一步。为了理解它,先记住 A3 会详细讲的一个事实:cpu_switch_to 切换到任何任务时,都是从 next->thread.cpu_context 里恢复 x19-x29/sp/lr,然后执行 ret。
- 对于一个跑过的任务,
cpu_context是它上一次被切走时保存的,ret会回到它当时在__switch_to里的位置。 - 对于子进程,
copy_thread手工写好了一份:sp指向内核栈顶的pt_regs,lr(cpu_context.pc)指向ret_from_fork。第一次被切入时,ret就会跳到ret_from_fork,接着一路返回到用户态。
所以调度器不需要知道谁是新任务,同一套 context_switch 代码对新老任务都适用。这是一种很常见的“伪造栈帧”技巧,x86、RISC-V 也都这么做。
还有两个 ARM64 特有的点:
x19/x20是“内核线程标记”。 用户进程的x19 = 0;kthread_create创建的内核线程x19 = fn、x20 = fn_arg。ret_from_fork用cbz x19判断走哪条路(arch/arm64/kernel/entry.S:858)。- TLS。
*task_user_tls(p) = read_sysreg(tpidr_el0)直接读当前的TPIDR_EL0,因为用户态可能改过这个寄存器,而task_struct里保存的值还没来得及更新。如果带了CLONE_SETTLS(pthread_create会带),就改用参数里传进来的tls。
6. sched_cgroup_fork:先“临时”放在当前 CPU
// kernel/sched/core.c:4757
raw_spin_lock_irqsave(&p->pi_lock, flags);
tg = container_of(kargs->cset->subsys[cpu_cgrp_id], struct task_group, css);
p->sched_task_group = autogroup_task_group(p, tg);
__set_task_cpu(p, smp_processor_id()); // ← 先放在 CPU4(父进程所在 CPU)
if (p->sched_class->task_fork)
p->sched_class->task_fork(p); // fair: set_task_max_allowed_capacity(p)
raw_spin_unlock_irqrestore(&p->pi_lock, flags);
__set_task_cpu(p, 4)只是个占位。 这时还没有真正选核,但task_cpu(p)必须有一个合法值,否则后面的代码(比如 cgroup 迁移)没法计算task_rq(p)。真正的选核在第 7 节。task_fork_fair在 6.12 里只剩一件事:计算p->max_allowed_capacity,也就是在cpus_ptr允许的 CPU 中,最大的那个 capacity。
例:max_allowed_capacity 的作用
- 默认情况下,C 允许运行在 CPU0-11 上,所以
max_allowed_capacity = 1024。 - 如果父进程先执行了
taskset -c 0-3(只允许小核),子进程会继承这个亲和性,max_allowed_capacity = 280。 - A8 会讲到:只有当
max_allowed_capacity大于当前 CPU 的 capacity 时,任务才会被判定为 misfit。所以被绑在小核上的任务,util 涨得再高也不会触发“往大核搬”的动作。反正它也去不了大核,判了也没用。
7. wake_up_new_task:选核、入队、叫醒
// kernel/sched/core.c:4822
void wake_up_new_task(struct task_struct *p)
{
int wake_flags = WF_FORK;
raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
WRITE_ONCE(p->__state, TASK_RUNNING); // 7.0
p->recent_used_cpu = task_cpu(p);
__set_task_cpu(p, select_task_rq(p, task_cpu(p), &wake_flags)); // 7.1 选核
rq = __task_rq_lock(p, &rf);
update_rq_clock(rq);
post_init_entity_util_avg(p); // 7.2 推算 util
activate_task(rq, p, ENQUEUE_NOCLOCK | ENQUEUE_INITIAL); // 7.3 入队
trace_sched_wakeup_new(p);
wakeup_preempt(rq, p, wake_flags); // 7.4 要不要抢占
...
task_rq_unlock(rq, p, &rf);
}
注意这里的顺序:先选核(7.1),再推算 util(7.2)。这个顺序直接决定了下面的结果。
7.1 选核:为什么是小核 CPU2
select_task_rq(p, 4, WF_FORK) → select_task_rq_fair()(kernel/sched/fair.c:8687)。由于带的是 WF_FORK,而不是 WF_TTWU:
- 跳过 EAS:
find_energy_efficient_cpu只在WF_TTWU分支里调用,fork 不走 EAS。 - 跳过 wake_affine:
want_affine同样只在WF_TTWU下计算。 - 沿调度域从下往上找,第一个带
SD_BALANCE_FORK标志的域是 MC(X9 只有这一层),于是进入慢路径sched_balance_find_dst_cpu()(kernel/sched/fair.c:7600)。
慢路径在 MC 域的 12 个单 CPU 组里,调用 sched_balance_find_dst_group()(kernel/sched/fair.c:10852):
| 步骤 | 做了什么 | X9 上的结果 |
|---|---|---|
| 给每个组分类 | 空闲的 CPU0/2/7 是 group_has_spare(idle_cpus = 1),忙的 CPU 是 fully_busy 或 has_spare(idle_cpus = 0) |
候选组:CPU0、CPU2、CPU7 |
| 选最空闲的组 | update_pick_idlest:先比 idle_cpus,平手再比 group_util,取更小的 |
CPU2(12)< CPU0(25)< CPU7(40)→ idlest = CPU2 |
| 和本地组比较 | 本地组是 CPU4(父进程在跑,idle_cpus = 0),0 >= 1 不成立 |
不留在本地,返回 CPU2 |
| 组内选 CPU | 组里只有 1 个 CPU | new_cpu = 2 |
为什么会挑到一个小核? 关键在于这一刻子进程的 util_avg = 0(4.5 节)。update_sg_wakeup_stats 里对每个 CPU 调用的 task_fits_cpu(p, cpu),在 util 为 0 时恒为真,所以不会产生 group_misfit_task 这种分类,capacity 大小完全不参与比较。fork 选核只看“哪里空”,不看“哪里强”。
例:三种“想让子进程一出生就在大核上”的做法
- uclamp_min:父进程先调用
sched_setattr(.sched_flags = SCHED_FLAG_UTIL_CLAMP_MIN, .sched_util_min = 600),uclamp 设置会随 fork 继承(uclamp_fork)。update_sg_wakeup_stats在带SD_ASYM_CPUCAPACITY的域里会检查task_fits_cpu(kernel/sched/fair.c:10770),它用的是 clamp 之后的值。600 放不进 capacity 280 的小核,CPU0 和 CPU2 所在的组因此被判为group_misfit_task,等级比group_has_spare差。结果会选中空闲的 CPU7(A725)。如果设成 800,连 A725(760)都放不下,就会选到 X925,哪怕它正忙着。 - 亲和性:
taskset -c 4-11 ./demo,子进程继承cpus_mask,候选范围里根本没有小核。 - 什么都不做,等 A8:C 跑一段时间后 util 涨起来,被判为 misfit,负载均衡会把它迁到大核。代价是开头那一小段时间跑在小核上。
7.2 推算初始 util:C 在 CPU2 上“一出生”就有 134
选定 CPU2 之后,post_init_entity_util_avg()(kernel/sched/fair.c:1100)根据目标 CPU 的情况推算初值:
long cpu_scale = arch_scale_cpu_capacity(cpu_of(rq_of(cfs_rq))); // CPU2: 280
long cap = (long)(cpu_scale - cfs_rq->avg.util_avg) / 2; // (280 - 12) / 2 = 134
if (cfs_rq->avg.util_avg != 0) {
sa->util_avg = cfs_rq->avg.util_avg * se_weight(se); // 12 × 1024
sa->util_avg /= (cfs_rq->avg.load_avg + 1); // ÷ (10 + 1) ≈ 1117
if (sa->util_avg > cap)
sa->util_avg = cap; // → 134
}
例:“剩余容量的一半”规则
源码注释里的例子:在一个 capacity 为 1024 的空 CPU 上连续 fork,每个新任务最多拿走剩余容量的一半:
第 n 个任务的 util_avg: 512, 256, 128, 64, 32, ...
cfs_rq 的 util_avg: 512, 768, 896, 960, 992, ...
这样可以保证总和不会超过 CPU 的 capacity。放到 X9 上:同一个 fork 如果选中的是 CPU10(X925,1024),C 的初始 util 最多可以到 (1024 - util) / 2 ≈ 500;落在 CPU2 上最多只有 134。同一个任务,出生在哪里,“出生体重”就不一样。
7.3 入队与放置:vruntime 和 deadline 的初值
activate_task → enqueue_task_fair → enqueue_entity → place_entity(cfs_rq, se, ENQUEUE_INITIAL)(kernel/sched/fair.c:5291):
u64 vslice, vruntime = avg_vruntime(cfs_rq); // V:队列的加权平均虚拟时间
if (!se->custom_slice)
se->slice = sysctl_sched_base_slice; // 2.8ms
vslice = calc_delta_fair(se->slice, se); // nice 0:vslice = slice = 2.8ms
if (sched_feat(PLACE_LAG) && cfs_rq->nr_running) {
lag = se->vlag; // 新任务 vlag = 0 → lag = 0
...
}
se->vruntime = vruntime - lag; // = V
if (sched_feat(PLACE_DEADLINE_INITIAL) && (flags & ENQUEUE_INITIAL))
vslice /= 2; // 新任务只给半个 slice 的 deadline
se->deadline = se->vruntime + vslice; // = V + 1.4ms
把 EEVDF 的规则放到这两个例子里来看(B3 会完整展开 EEVDF):
- eligible:
v ≤ V的任务才有资格被选中(它“没多拿”)。 - 在所有 eligible 的任务里,选 deadline 最早的那个。
例 a:落在空闲的 CPU2(本文的真实情况)
CPU2 的 cfs_rq 是空的,nr_running = 0,跳过 PLACE_LAG 分支。avg_vruntime 返回的是队列的 min_vruntime,这里假设是 101.0ms。于是:v = 101.0,d = 101.0 + 1.4 = 102.4。C 是队列里唯一的任务,一定会被选中。
例 b:假如落在 CPU4(和父进程挤在一起)
CPU4 上已有 P(正在运行,v = 100.0,d = 102.8)和 Q(v = 102.0,d = 104.8),都是 nice 0。C 入队后:
V = (100 + 102 + 101) / 3 = 101.0,C 的v = V = 101.0,d = 102.4。- eligible 的任务:P(100 ≤ 101)和 C(101 ≤ 101)。Q(102 > 101)不 eligible。
- 两者里 deadline 最早的是 C(102.4 < 102.8)……
- ……但
RUN_TO_PARITY默认打开(kernel/sched/features.h:20):P 在被选中时调用过set_protect_slice()(vlag = deadline),只要 P 还 eligible、它的这一片时间片还没用完,pick_eevdf就直接返回 P(kernel/sched/fair.c:943)。所以 C 不会立刻抢占 P,最多等 P 把当前这一片(≤ 2.8ms)用完。
对比 CFS 时代:以前有 sysctl_sched_child_runs_first 可以让子进程先跑,6.6 换成 EEVDF 之后这个开关已经删掉了(在 6.12 的 kernel/sched/ 里 grep 不到)。子进程能不能“先跑”,完全由上面这套 V、deadline 和 slice 保护的规则决定。
为什么新任务的 deadline 只给半个 slice? 源码注释的解释是:“新加入竞争时,已有任务平均来说正处在它们时间片的一半,所以新任务也从半个时间片起步。”这让新任务比较快就能轮到,但又不会像 v 设得更小那样“插队插到最前面”。
7.4 wakeup_preempt:要不要叫醒 CPU2?
// kernel/sched/core.c:2140
void wakeup_preempt(struct rq *rq, struct task_struct *p, int flags)
{
if (p->sched_class == rq->curr->sched_class)
rq->curr->sched_class->wakeup_preempt(rq, p, flags); // 同类:交给 fair 判断
else if (sched_class_above(p->sched_class, rq->curr->sched_class))
resched_curr(rq); // 更高的类:直接抢
}
CPU2 当前运行的是 idle 任务(swapper/2,属于 idle 类)。fair 类比 idle 类高,所以直接调用 resched_curr(rq2)。
- 如果 C 落在例 b 的 CPU4 上,就会走
check_preempt_wakeup_fair(kernel/sched/fair.c:8862):NEXT_BUDDY默认关闭,并且对WF_FORK明确排除;之后调用pick_eevdf,因为 slice 保护返回的是 P,所以不抢占。 - fork 的特殊之处:普通唤醒如果开了
NEXT_BUDDY,会把被唤醒者设为“下一个优先选”;代码里用!(wake_flags & WF_FORK)明确把 fork 排除在外,新出生的子进程不会因此被照顾。
8. 进程视图:两把锁、一个远程 CPU、一次 IPI
从并发的角度看,第 7 节发生在 CPU4 上,但它修改的是 CPU2 的运行队列:
- 锁的顺序是
p->pi_lock→rq->lock,全内核统一。pi_lock保护任务的“放哪”(cpus_ptr、task_cpu、__state),rq->lock保护“队列里有谁”。先锁任务、再锁队列,C1 会解释为什么不能反过来。 - CPU4 远程拿 CPU2 的 rq 锁:
__task_rq_lock(p)根据task_cpu(p) = 2锁住cpu_rq(2)->__lock。持锁期间 CPU2 对此毫不知情,它还在wfi里睡着。 - 怎么叫醒 CPU2:
resched_curr(rq2)(kernel/sched/core.c:1083)先给 CPU2 的当前任务(idle)设置TIF_NEED_RESCHED。有些架构的 idle 是轮询need_resched的(设置了TIF_POLLING_NRFLAG),只要写这个标志位就能叫醒;ARM64 没有定义TIF_POLLING_NRFLAG,idle 是真的执行wfi停在那里,所以必须发 IPI:smp_send_reschedule(2)→arch_smp_send_reschedule→ GIC 发出 SGI(IPI_RESCHEDULE)(arch/arm64/kernel/smp.c:1099)。 - CPU2 收到 SGI → WFI 退出 →
do_handle_IPI→scheduler_ipi()→preempt_fold_need_resched()。之后回到do_idle的循环,发现need_resched()为真,于是退出循环,调用schedule_idle()。这是下一篇 A3 的起点。
PREEMPT_RT 下这里有什么不同?
基本没有变化。pi_lock 和 rq->lock 都是 raw_spinlock_t,在 RT 内核里仍然是真正的自旋锁,并且关中断,不会变成睡眠锁。调度器自己不能睡眠,这是 RT 改造里明确保留下来的“不可抢占核心”之一(C3)。fork 路径上其余可能睡眠的操作,比如分配内存、复制页表,本来就在可抢占上下文里执行,RT 内核只是让它们更容易被高优先级任务打断。
9. 父进程这一边:返回 PID 之前的几个细节
回到 kernel_clone(kernel/fork.c:2782):
p = copy_process(NULL, trace, NUMA_NO_NODE, args);
trace_sched_process_fork(current, p); // 必须在唤醒之前:唤醒之后 p 可能已经跑完并退出了
pid = get_task_pid(p, PIDTYPE_PID); // 先拿一个引用
nr = pid_vnr(pid);
wake_up_new_task(p); // 从这一刻起,C 可能已经在 CPU2 上运行
if (clone_flags & CLONE_VFORK)
wait_for_vfork_done(p, &vfork); // vfork:父进程睡眠,直到子进程 exec 或 exit
put_pid(pid);
return nr;
例:为什么 tracepoint 和 get_task_pid 要放在唤醒之前
在 X9 上,CPU2 可能在几微秒内就被 IPI 叫醒,并开始运行 C。如果 C 是一个 exit(0) 的程序,它甚至可能在父进程执行下一行代码之前就已经退出,并且被父进程所在会话里的其他线程 wait 回收掉了。代码里凡是需要引用 p 的地方,都必须在 wake_up_new_task(p) 之前完成,或者先拿引用。这是 fork 路径里一个典型的“发布之后就不再拥有”的并发模式。
10. 动手实验
10.1 用 tracepoint 看 fork 与选核
sudo trace-cmd record -e sched:sched_process_fork -e sched:sched_wakeup_new \
-e sched:sched_switch ./demo 1
sudo trace-cmd report | grep -E "fork|wakeup_new|==> next_comm=demo" | head
期望看到类似下面的输出(X9,数字为示意):
demo-1234 [004] 100.200011: sched_process_fork: comm=demo pid=1234 child_comm=demo child_pid=1235
demo-1234 [004] 100.200015: sched_wakeup_new: comm=demo pid=1235 prio=120 target_cpu=002
<idle>-0 [002] 100.200019: sched_switch: prev_comm=swapper/2 prev_pid=0 prev_prio=120 prev_state=R ==> next_comm=demo next_pid=1235 next_prio=120
sched_wakeup_new是在 CPU4 上打出来的(方括号里是 004),target_cpu=002就是 7.1 节选核的结果。- 4 微秒之后,CPU2 上出现了
swapper/2 ==> demo,这是 A3 讲的那次切换。这 4 微秒包括:IPI 投递、WFI 退出,以及可能的 idle 状态退出延迟。如果 CPU2 当时处在更深的 PSCI 睡眠状态,这个数会大很多。A6 会专门讨论这一点。
10.2 用 bpftrace 看初始 util 与 vruntime
sudo bpftrace -e '
tracepoint:sched:sched_wakeup_new {
printf("child %d -> cpu%d\n", args->pid, args->target_cpu);
}
kprobe:place_entity {
$se = (struct sched_entity *)arg1;
printf(" place: util=%lu vlag=%lld flags=0x%x\n", $se->avg.util_avg, $se->vlag, arg2);
}'
place_entity 抓不到?
place_entity 是 static 函数,可能会被编译器内联。先用 grep place_entity /proc/kallsyms 确认它有没有独立的符号;如果没有,可以改抓 enqueue_task_fair,或者直接看 /proc/<pid>/sched 里的 se.vruntime、se.deadline、se.avg.util_avg。
10.3 验证“小核出生,大核长大”
子进程的主线程大部分时间在 sleep(),util 不会涨。真正一直在计算的是 worker 线程,它由 pthread_create → clone 创建,同样走了一遍本篇的流程:
./demo 3 &
sleep 0.05
W=$(ps -eLo tid,comm | awk '$2=="worker"{print $1}')
grep -E "se.avg.util_avg|nr_migrations" /proc/$W/sched # 刚出生:util 是推算出的初值,nr_migrations = 0
sleep 1
grep -E "se.avg.util_avg|nr_migrations" /proc/$W/sched # 如果出生在小核:util 涨到接近 280 后会被搬走,nr_migrations ≥ 1
taskset -pc $W; ps -o psr= -L -p $(pgrep -n demo) # 看它现在在哪个 CPU
11. 小结
| 问题 | 答案 |
|---|---|
| 子进程的调度字段从哪来? | dup_task_struct 整块复制父进程,再由 sched_fork 修正:清零累计量、设为 TASK_NEW、不继承 PI 提升、按策略选调度类、PELT 设为“负载满、util 0” |
| 为什么落到小核 CPU2? | WF_FORK 走 sched_balance_find_dst_cpu 慢路径,只比较“空闲程度”;子进程 util 此时为 0,capacity 不参与比较 |
| vruntime / deadline 初值? | v = V(新任务 lag = 0),d = V + slice/2(PLACE_DEADLINE_INITIAL),在 X9 上 slice = 2.8ms |
| 能立刻抢占别人吗? | 落在空闲 CPU 上时,idle 类一定会被抢;落在忙 CPU 上时,要看 EEVDF 的选择,而且 RUN_TO_PARITY 会保护当前任务的时间片;fork 不享受 NEXT_BUDDY |
| 怎么叫醒 CPU2? | 远程拿 rq 锁,设置 TIF_NEED_RESCHED;ARM64 没有 polling 标志,所以一定会发 SGI(IPI_RESCHEDULE) |
| 子进程第一次会从哪里开始执行? | copy_thread 伪造的 cpu_context.pc = ret_from_fork,内核栈顶的 pt_regs 里 x0 = 0 |
12. 自测
- ctrl(FIFO 80)线程调用
fork(),子进程是什么策略?如果 ctrl 在调用前被 PI 提升到了 FIFO 90,子进程又是什么策略? - 把 demo 放在一个
cpu.max = 50000 100000的 cgroup 里运行,fork 出来的子进程会在哪一步进入这个 cgroup? - 如果 X9 上 12 个 CPU 全都忙,fork 选核会选到哪里?(提示:看
group_fully_busy/group_overloaded分支比较的是什么) - 为什么
post_init_entity_util_avg必须在选核之后执行,而不能在sched_fork里就算好?