Skip to content

调试档案 020 · 抢占式调度的三个坑——时间片没机会触发,新任务丢了中断,还有一颗没引信的雷

document/notes/020/001_time_slice_too_long.md002_if_flag_lost_in_context_switch.md 提炼并补全「定位/防复发」,配套主书 020 · 时钟到点,该换人了:抢占式调度。020 把调度从「线程主动 yield 才切」升级成「PIT 时钟中断到点强制切」,过程里两个坑最典型:一个是「明明挂上了时钟中断、却从来没抢占」——不是切换坏了,是工作负载没撑到一个时间片;一个是「切换本身把新任务的中断给关了」——协作式 context_switch 本来不碰 RFLAGS,一旦被从中断上下文复用,新任务就带着 IF=0 起跑、再也收不到下一次时钟。这两条之外,本章还埋着一颗当下不炸、迟早要命的雷:Spinlock 定义了却没人用、PerCPU 只是个单核全局占位、TSS.RSP0 接口接上了却根本不生效——等并发或 ring3 一上,它们会一齐找上门。

案例一:线程全顺序跑完,抢占从头到尾没发生过

  • 症状:三个线程各跑 5 轮,串口输出严丝合缝地顺序——A 跑完五轮才轮到 B,B 跑完才轮到 C,没有任何交错。看着像是「时钟中断根本没接上」或者「调度器没在 tick 里调 schedule」。

    [A] thread_a iteration 0
    ... (A 连跑 5 轮)
    [A] thread_a iteration 4
    [B] thread_b iteration 0
    ... (B 连跑 5 轮)
    [B] thread_b iteration 4
    [C] thread_c iteration 0
  • 根因:不是调度坏了,是工作负载太小、时间片太大,线程在自己的时间片内就跑完了,定时器中断压根没机会插手。PIT 配在 100Hz(每 tick 10ms),DEFAULT_TIME_SLICE = 10,也就是要攒满 100ms 才触发一次 schedule()。而那段用来「忙一会儿」的循环——

    cpp
    for (volatile int j = 0; j < 1000000; j++) {}

    在 QEMU TCG(软件翻译,无硬件加速)模式下跑得极快,一轮一百万次迭代实际不到 5ms。每个线程总共 5 轮,耗时不超过 50ms,远小于 100ms 的时间片。线程从头到尾待在同一个时间片里,时钟节拍还没攒够,它自己就 done 了,于是三个线程永远是「A 全跑完 → B 全跑完 → C 全跑完」。抢占的代码路径完全正确,只是没有任何一次 tick() 真正走到 current_slice_ >= DEFAULT_TIME_SLICE 的那一步。

  • 定位:先怀疑「时钟中断没接上」。最快的判断办法是在 pit_irq0_handler 里临时打一行 [TICK],确认它每个 tick 都在走、Scheduler::tick() 也被调到了。注意这里的顺序很关键:irq0_handler 是先 PIC::send_eoi(0)Scheduler::tick()——EOI 先发,保证下一个 IRQ 能到,然后才去点调度器的名。如果 tick 一直在涨、schedule 却从未执行,问题就落在「节拍没攒够」,也就是时间片设置和工作负载不匹配。再回头核对 DEFAULT_TIME_SLICE 的值和忙循环的量级,两边一对照就能发现「时间片 vs 单轮实际耗时」严重失衡。020 当前的 DEFAULT_TIME_SLICE = 2(20ms)、忙循环 2000 万次,正是修完之后的数字。

  • 修复:两头一起调。一是把 DEFAULT_TIME_SLICE 从 10 收到 2(时间片从 100ms 缩到 20ms);二是把忙循环的迭代次数从一百万提到两千万,确保每个线程的执行横跨多个时间片、必然被时钟中断打断。改完之后,六个线程才会被时钟交错打断,出现「A 跑两轮 → 切到 B → …」的交错。

  • 防复发:在虚拟化环境里,简单的 CPU 密集循环比裸机上快得多——别拿裸机的直觉估时。测抢占调度时,要么把工作负载做得足够大,要么把时间片做得足够小,核心只一条:让定时器中断有机会在任务跑完之前介入。一个更稳的自检姿势是:每次调时间片或改忙循环后,先确认串口里出现了「不同线程的输出交错」,而不是某个线程连跑完才轮到下一个;一旦看到「整段顺序、毫无交错」,第一反应是去量「单轮耗时 vs 时间片」,而不是去怀疑 context_switch

案例二:只有第一个被抢占的线程能恢复中断,之后全是顺序跑

  • 症状:这次工作负载够了、时间片也够小——六个线程 × 10 轮 × 两千万次忙循环。第一次抢占确实发生了(A 跑了两轮之后被切到 B),但诡异的是,之后就再也没有第二回抢占:B、C、D、E、F 全程顺序跑完各自的十轮,中间一次都没被打断,直到 A 最后回来收尾。

    [A] tid=2 iter 1/10
    [A] tid=2 iter 2/10          ← 第一次抢占发生在这里
    [B] tid=3 iter 1/10
    ... (B 连跑 10 轮,从未被抢占)
    [B] tid=3 iter 10/10
    [B] done
    [C] tid=4 iter 1/10
    ...
    [F] done
    [A] tid=2 iter 3/10          ← A 回来继续

    「第一次正常、之后全废」这个形态很有迷惑性——它不像是「时钟彻底没接上」,反而像是「时钟只对第一个任务有效」。

  • 根因:context_switch.S 只保存/恢复 callee-saved 寄存器(r15r12rbprbxrsprip),完全不碰 RFLAGS。这套设计在协作式时代是合理的:总是在明确的函数调用点切换,IF 不变,没人需要存它(System V AMD64 ABI 的 callee-saved 约定本来就不列 RFLAGS)。可一旦抢占式把它「复用」到中断上下文里,就出事了。

    抢占发生时的调用链是这样的:

    IRQ0 → ISR stub (CPU 清掉 IF) → pit_irq0_handler → Scheduler::tick()
          → schedule() → context_switch

    进入 ISR 时,CPU 按 interrupt-gate 的语义自动把 IF 清零(中断门与陷阱门的区别就在这里:中断门进处理程序时关中断,陷阱门不关;Intel SDM Vol.3A §6.3 / §6.9 讲的就是这条)。于是 context_switch 是在一个 IF=0 的环境里跑的——它切到新任务时,新任务继承了当前的 IF=0。关键在于新任务有两条命运:

    • 全新任务(第一次被调度):它的 ctx.rip 直接指向线程入口函数,context_switchjmp *56(%rsi) 跳过去。这一路上没有任何会还原 RFLAGS 的指令,新任务就带着 IF=0 跑下去——再也不会收到下一次时钟中断,所以它独占 CPU、直到自己跑完。
    • 被抢占过的任务(恢复运行):它的 ctx.rip 指向 context_switch 里的 .restore 标号(那是它上次被切走时存进去的「回来继续执行的点」)。.restore 之后走 ret 链一路退回 ISR stub,最后由 IRETQ 从中断帧里还原原始 RFLAGS(IF=1,SDM Vol.3A §6.12.1:中断帧里压着 CS/RIP/RFLAGS,IRET 弹回 RFLAGS)——中断这才恢复。

    也就是说,只有「被抢占过一次、靠 IRETQ 退栈」的任务能找回中断;而所有「全新启动」的任务都带着 IF=0 起跑。于是 A 被抢占后能正常恢复,而 B–F 都是首次启动,全程关着中断,谁也抢不动它。

  • 定位:看到「第一次抢占之后,后续任务全是整段顺序、再无交错」,就该高度怀疑中断在切换中丢了。一条快速的旁证:在 pit_irq0_handler 里打 [TICK] 日志。如果 tick 计数在某个任务执行期间完全停住(不再增长),基本就能断定那个任务跑在关中断状态、时钟信号进不来。再读 context_switch.S,核对它存/恢复了哪些寄存器——只要发现它存的是 callee-saved 那 8 个、唯独没有 RFLAGS(没有 pushfq/popfq),就能定位到「IF 没被保存也没被恢复,而是被动地继承了进入 ISR 时的 0」。结合调用链「ISR 进来 IF 已被清」,根因就坐实了。

  • 修复:在 context_switch.S 里,切换栈之后、jmp 到新任务之前,加一条 sti:

    asm
        movq 48(%rsi), %rsp          # 切换到新任务栈
        sti                           # 开中断:我们可能是从中断上下文(IF=0)进来的
        jmp *56(%rsi)                 # 跳到新任务

    为什么这么放、又为什么安全:

    • 位置:sti 必须在换完栈、但还没 jmp 之前。换栈之后,当前 rsp 已经指向新任务;sti 让中断在此刻打开,jmp 落到新任务时它就以 IF=1 启动,下一次时钟中断能正常到达。顺带一条硬件事实:STI 在执行后要延迟一条指令才真正响应中断(SDM Vol.2 STI 描述),所以紧跟着的 jmp 本身不会被中途打断,换栈 + 跳转是一个干净的整体。
    • 对全新任务:正是这条 sti 救了它们——以 IF=1 起跑,抢占恢复正常。
    • 对被抢占过的任务:它恢复到 .restore,之后靠 ret 退回 ISR、由 IRETQ 还原原始 RFLAGS。这条路径上 sti冗余但无害的——反正后面 IRETQ 会把 RFLAGS 盖回去。
    • 嵌套中断风险:要诚实说清——从 .restore 经过 ret 退栈到 IRETQ,中间有一个中断已开但尚未退完中断帧的小窗口,理论上可能被下一次时钟中断命中。但这个窗口只有几条指令、微秒量级,而 PIT 是 100Hz(10ms 一次节拍),在这个频率下撞上的概率可忽略。不是零,但 020 选择不为此过度设计。
  • 防复发:协作式的 context_switch,一旦被「从中断上下文复用」,就必须显式保证新任务的中断状态正确——这是从 cooperative 迈向 preemptive 的经典陷阱。更稳、更精细的做法是把 RFLAGS 正式纳入 CpuContext(在 64 字节布局里给它留位),保存时 pushfq、恢复时 popfq,让每个任务都自带它被切走那一刻的 RFLAGS,谁来恢复都对。020 没走这条,是因为当下只有内核线程、场景简单,这是最简洁的修法;但只要哪天调度路径要支持「中断上下文以外的地方也能切、且切换前后中断状态必须各自正确」,就得回来补上 RFLAGS 字段——把「切换点的 sti」当成临时补丁,别当成永久方案。

案例三(当下不发作):Spinlock 没人用、PerCPU 是单核占位、TSS.RSP0 不真正生效

  • 症状:020 当下完全跑得起来,demo 也漂亮地交错了。这一案不是「崩了」,而是几条接口接上了、骨架搭好了、但当前根本没真正干活的东西,现在不出问题,纯粹因为没有第二个执行流去碰它们、也没有 ring3 去触发特权级变化。它们是「下一站」的引信,本章先点出来,免得 021 一上并发、或往后一上用户进程,踩中时还以为是 021/那个新功能自己的锅。
  • 根因:
    • Spinlock 定义了却无调用方kernel/proc/sync.hpp 给了完整的 acquire/release/guard(__atomic_test_and_set + __ATOMIC_ACQUIRE__atomic_clear + __ATOMIC_RELEASE、自旋里夹 pause),但 020 的 SchedulerRoundRobin、PIT、就绪队列一处锁都没上。这之所以没事,是因为 020 仍然是「单执行流 + 时钟中断串行切换」:同一时刻只有一个任务在跑,中断处理里调 schedule() 也不会和别的 CPU 抢。可一旦真有并发(下一章就要加 Mutex/等待队列、并审查现有组件的并发安全),这个「全裸的调度器」就会变成第一个竞态现场。
    • PerCPU 只是个单核全局per_cpu.hppstruct PerCPU { Task* current; uint64_t kernel_stack; }extern PerCPU g_per_cpu;,每次切换同步 g_per_cpu.current——名字像 per-CPU,实质是一个静态全局变量。它没有用 GS 基址相对寻址的那套真 per-CPU 区域机制,所以现在只能描述「单核」。放它是为了「将来 current 从全局迁到 per-CPU」时改动小,但别把它当成 SMP 地基。
    • TSS.RSP0 更新不真正生效GDT::tss_set_rsp0(uint64_t) 直接写 g_gdt.tss_.rsp[0],run_first/schedule/exit_current 在切到非 idle 任务时都调它。按 SDM §6.12.1,RSP0 是「特权级升高时硬件从 TSS 取的那个栈顶」——只有在发生 ring3→ring0 的特权级变化时,硬件才会用它来换栈。而 020 全程 ring0,切换不涉及特权级变化,这条写 RSP0 的动作当前根本不会被触发。它现在「能编译、能跑、不出错」,纯粹是因为写一个不用的字段不会炸。
  • 定位:这种「不发作」的坑没法靠崩溃定位,得靠读代码 + 对照架构语义主动发现。判断 Spinlock 有没有被用:全局搜 Spinlock 的构造/guard()/acquire() 调用点,如果除了定义本身和测试之外零调用,就是「定义未用」。判断 PerCPU 是不是真 per-CPU:看它有没有走 GS 基址(wrmsrIA32_KERNEL_GS_BASE、访问用 %gs: 段前缀),没有就只是个全局。判断 TSS.RSP0 生不生效:看当前有没有任何 ring3 代码路径,没有就是「写了不读」。
  • 修复:020 阶段不修,这是设计上的「先占位、后填充」,不是 bug。该做的是把它们如实标注为「接口就位、实现待补」,并挂在 021 的待办上:Spinlock 要真正套到调度器/队列的关键段;PerCPU 要从全局长成 GS 相对的真 per-CPU 区;TSS.RSP0 要等用户进程/ring3 上线后才真正被硬件消费。在本章正文里,这几处一律按「接上接口、留好钩子、当前不生效」讲,不要演示一段加了锁的调度路径、不要把 PerCPU 说成多核地基、不要声称 TSS.RSP0 已经在保护栈切换——那是越界,是 021 及以后的事。
  • 防复发:「接口先行、实现后补」的模块,必须在文档里钉死「当前不生效」这四个字,否则很容易被后来的维护者(或我们自己)当成「已经工作」来依赖。一个可操作的防复发动作是:在 SpinlockPerCPUtss_set_rsp0 的注释里写明「020 定义/接入,首个真实调用方在 021/后续」,让「未生效」这件事可被代码注释自证,而不只靠人记。等并发或特权级切换真上线时,第一件事就是回来把这三处的「待补」标记清掉,而不是等到竞态或栈错位时才反应过来。

一句话总结

020 的三个坑,一个是时间片设得太大、工作负载太小,抢占的代码全对,只是定时器没机会介入——修在「调小 DEFAULT_TIME_SLICE、加大忙循环」;一个是从中断上下文复用协作式 context_switch,新任务继承了 IF=0、再也收不到时钟——修在「换栈后、jmp 前插一条 sti」;还有一个当下不炸的雷——Spinlock 定义了没人用、PerCPU 是单核全局占位、TSS.RSP0 在全程 ring0 里根本不被触发,它们是 021 并发审查与往后 ring3 上线的引信,本章只接接口、如实标「不生效」,等真用上了再回来收尾。前两个是「抢占式上路的必修课」,后一个是「别把占位当能力」的清醒剂。

035_multi_terminal-40-g5d72b8b · 5d72b8b · 2026-06-26