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

A1 fork:一次调用,两次返回

系列 A · 场景主线+1 场景视图逻辑视图进程视图开发视图物理视图

这一篇只盯一行代码: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

我们要回答的问题:

  1. 子进程 C 的调度相关字段是怎么从父进程那里“复制 + 修正”出来的?
  2. C 为什么最后会落到 CPU2(一个小核) 上?
  3. C 的 vruntimedeadline 初值是多少?它能不能立刻抢占别人?
  4. CPU4 把 C 放进 CPU2 的队列时,CPU2 正在 wfi,它是怎么知道自己该干活的?

1. 全景:12 步

图 A1-1 fork() 全链路(场景 + 开发视图)
图 A1-1 fork() 全链路(场景 + 开发视图)
# 步骤 源码位置 这一步和调度器的关系
②③④ svc #0el0t_64_syncel0_svc__arm64_sys_clone arch/arm64/kernel/entry-common.c:839kernel/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 的系统调用表里没有 forkarch/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

有两个细节会在后面用到:

ARM64 还选了 CONFIG_CLONE_BACKWARDSarch/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.vruntimese.avgpriosched_classon_cpucpus_mask……全部是父进程此刻的值。所以 sched_fork 的任务不是“初始化”,而是“把不该继承的东西改掉”

ARM64 的 arch_dup_task_structarch/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_forkkernel/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
}

这里有一个有意设计的不对称

这个“先 0 后推算”的时间差,正是第 7 节里 C 会被放到小核的根本原因。

4.6 ⑧ FORK_PREEMPT_COUNT:给 A3 埋的伏笔

init_task_preempt_count(p) 把子进程的 preempt_count 设为 FORK_PREEMPT_COUNT = 2 × PREEMPT_DISABLE_OFFSET + PREEMPT_ENABLEDinclude/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 \| … 共享同一个 mmmm_users++ 同进程的线程之间切换 → 页表不动

例:fork 对父进程里的实时线程也有代价

dup_mm 会把父进程里所有私有可写页的 PTE 都改成只读,这样才能实现写时复制。代价是:demo 子进程 fork 出来之后,父进程里任何线程第一次写这些页,都会触发一次缺页并复制页面,这也包括父进程里的实时线程。

所以,实时程序的常见建议是:在创建 RT 线程之前完成所有 fork,或者改用 posix_spawn/vforkCLONE_VM | CLONE_VFORK,不复制页表)。demo 里 ctrl 线程是在子进程内部、fork 之后才创建的,正好避开了这个问题。

5.2 copy_thread:伪造一份“刚被切走”的现场

图 A1-2 copy_thread 之后,子进程的内核栈与 cpu_context(物理视图)
图 A1-2 copy_thread 之后,子进程的内核栈与 cpu_context(物理视图)

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

所以调度器不需要知道谁是新任务,同一套 context_switch 代码对新老任务都适用。这是一种很常见的“伪造栈帧”技巧,x86、RISC-V 也都这么做。

还有两个 ARM64 特有的点:

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);

例: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

图 A1-4 fork 选核在 X9 上的推演(场景 + 物理视图)
图 A1-4 fork 选核在 X9 上的推演(场景 + 物理视图)

select_task_rq(p, 4, WF_FORK)select_task_rq_fair()kernel/sched/fair.c:8687)。由于带的是 WF_FORK,而不是 WF_TTWU

  1. 跳过 EASfind_energy_efficient_cpu 只在 WF_TTWU 分支里调用,fork 不走 EAS。
  2. 跳过 wake_affinewant_affine 同样只在 WF_TTWU 下计算。
  3. 沿调度域从下往上找,第一个带 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_busyhas_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 选核只看“哪里空”,不看“哪里强”。

例:三种“想让子进程一出生就在大核上”的做法

  1. 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_cpukernel/sched/fair.c:10770),它用的是 clamp 之后的值。600 放不进 capacity 280 的小核,CPU0 和 CPU2 所在的组因此被判为 group_misfit_task,等级比 group_has_spare 差。结果会选中空闲的 CPU7(A725)。如果设成 800,连 A725(760)都放不下,就会选到 X925,哪怕它正忙着。
  2. 亲和性taskset -c 4-11 ./demo,子进程继承 cpus_mask,候选范围里根本没有小核。
  3. 什么都不做,等 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 的初值

图 A1-3 place_entity(ENQUEUE_INITIAL) 的两种情况(逻辑视图)
图 A1-3 place_entity(ENQUEUE_INITIAL) 的两种情况(逻辑视图)

activate_taskenqueue_task_fairenqueue_entityplace_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):

例 a:落在空闲的 CPU2(本文的真实情况)

CPU2 的 cfs_rq 是空的,nr_running = 0,跳过 PLACE_LAG 分支。avg_vruntime 返回的是队列的 min_vruntime,这里假设是 101.0ms。于是:v = 101.0d = 101.0 + 1.4 = 102.4。C 是队列里唯一的任务,一定会被选中。

例 b:假如落在 CPU4(和父进程挤在一起)

CPU4 上已有 P(正在运行,v = 100.0d = 102.8)和 Q(v = 102.0d = 104.8),都是 nice 0。C 入队后:

  • V = (100 + 102 + 101) / 3 = 101.0,C 的 v = V = 101.0d = 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)

8. 进程视图:两把锁、一个远程 CPU、一次 IPI

图 A1-5 wake_up_new_task 的并发时序(进程视图)
图 A1-5 wake_up_new_task 的并发时序(进程视图)

从并发的角度看,第 7 节发生在 CPU4 上,但它修改的是 CPU2 的运行队列:

  1. 锁的顺序是 p->pi_lockrq->lock,全内核统一。pi_lock 保护任务的“放哪”(cpus_ptrtask_cpu__state),rq->lock 保护“队列里有谁”。先锁任务、再锁队列,C1 会解释为什么不能反过来。
  2. CPU4 远程拿 CPU2 的 rq 锁__task_rq_lock(p) 根据 task_cpu(p) = 2 锁住 cpu_rq(2)->__lock。持锁期间 CPU2 对此毫不知情,它还在 wfi 里睡着。
  3. 怎么叫醒 CPU2resched_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)。
  4. CPU2 收到 SGI → WFI 退出 → do_handle_IPIscheduler_ipi()preempt_fold_need_resched()。之后回到 do_idle 的循环,发现 need_resched() 为真,于是退出循环,调用 schedule_idle()这是下一篇 A3 的起点。

PREEMPT_RT 下这里有什么不同?

基本没有变化。pi_lockrq->lock 都是 raw_spinlock_t,在 RT 内核里仍然是真正的自旋锁,并且关中断,不会变成睡眠锁。调度器自己不能睡眠,这是 RT 改造里明确保留下来的“不可抢占核心”之一(C3)。fork 路径上其余可能睡眠的操作,比如分配内存、复制页表,本来就在可抢占上下文里执行,RT 内核只是让它们更容易被高优先级任务打断。

9. 父进程这一边:返回 PID 之前的几个细节

回到 kernel_clonekernel/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

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_entitystatic 函数,可能会被编译器内联。先用 grep place_entity /proc/kallsyms 确认它有没有独立的符号;如果没有,可以改抓 enqueue_task_fair,或者直接看 /proc/<pid>/sched 里的 se.vruntimese.deadlinese.avg.util_avg

10.3 验证“小核出生,大核长大”

子进程的主线程大部分时间在 sleep(),util 不会涨。真正一直在计算的是 worker 线程,它由 pthread_createclone 创建,同样走了一遍本篇的流程:

./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_FORKsched_balance_find_dst_cpu 慢路径,只比较“空闲程度”;子进程 util 此时为 0,capacity 不参与比较
vruntime / deadline 初值? v = V(新任务 lag = 0),d = V + slice/2PLACE_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_regsx0 = 0

12. 自测

  1. ctrl(FIFO 80)线程调用 fork(),子进程是什么策略?如果 ctrl 在调用前被 PI 提升到了 FIFO 90,子进程又是什么策略?
  2. 把 demo 放在一个 cpu.max = 50000 100000 的 cgroup 里运行,fork 出来的子进程会在哪一步进入这个 cgroup?
  3. 如果 X9 上 12 个 CPU 全都忙,fork 选核会选到哪里?(提示:看 group_fully_busy/group_overloaded 分支比较的是什么)
  4. 为什么 post_init_entity_util_avg 必须在选核之后执行,而不能在 sched_fork 里就算好?
参考答案 1. 第一问:FIFO 80,实时策略会被继承(`static_prio`、`policy`、`rt_priority` 都是拷贝来的)。第二问:仍然是 FIFO 80,因为 `sched_fork` 用的是 `current->normal_prio`,PI 提升出来的 90 不会被继承。 2. `sched_cgroup_fork`(`kernel/sched/core.c:4757`)根据 `kargs->cset` 确定 `sched_task_group`,此后在 `wake_up_new_task` 入队时挂到该 task_group 在 CPU2 上的 `cfs_rq` 下面。带宽限制作用在这个 group 的 cfs_rq 上。 3. 所有组都是 `fully_busy` 或 `overloaded`,这时比较 `avg_load`(按 capacity 归一化后的负载),挑最小的组。本地组也要和 `imbalance_pct` 的余量做比较,差得不多时就留在本地(`kernel/sched/fair.c:10852` 之后的 switch)。 4. 因为 util 的初值取决于**目标 CPU** 的 capacity 和它当前的 util。在 `sched_fork` 的时候还不知道子进程会被放到哪里,算出来的值没有意义。