
1. 把进程切换这个词拆开它到底在切什么课本上给进程切换下的定义通常只有一句话保存当前进程的上下文恢复另一个进程的上下文。第一遍读觉得懂了真到代码里加断点单步走一遍才发现这句话里每一个词都需要展开——上下文具体指哪些寄存器、保存在哪里、谁负责恢复、恢复之后从哪条指令继续跑。课堂练习 3.4 这类题目本质上就是逼你把这句话落到实处能在内核里指出保存点、恢复点能数出切换了几次能解释清楚为什么这一次切换是必需的而不是多余的。先把最容易含糊的三个概念区分开。模式切换用户态进内核态或者内核态返回用户态只改变 CPU 特权级和栈指针进程本身没换页表没换PCB 没有任何变化。上下文切换是保存和恢复 CPU 寄存器状态可以发生在同一个进程的线程之间也可以发生在不同进程之间。进程切换比前两者多了一步除了寄存器和栈还要切换地址空间也就是页表基址寄存器。这个区别在实际调优时非常关键——同一进程内的两个线程切换因为共享页表TLB 和 cache 的命中率影响小得多跨进程切换则要承受地址空间切换带来的额外代价。下面这张表是我自己在复习时整理的对照关系做练习之前建议先把这张表默写一遍维度模式切换同进程线程切换跨进程切换特权级变化有无有返回用户态时通用寄存器保存发生进内核时发生发生内核栈切换切到该进程的内核栈切到该线程的内核栈切到目标进程内核栈地址空间页表切换不切不切切TLB / cache 影响小小大典型触发点系统调用、中断调度器选中同进程线程调度器选中其他进程需要说明的是不同教材的练习 3.4 要求不完全一样。有的是基于教学内核比如 xv6、Linux 0.11 这类精简内核加计数器并观察有的是在真实 Linux 上写用户态程序测开销也有的只是要求在纸上画出状态迁移图和栈布局。我下面按最常见的形式展开你对照自己手上的题目裁剪即可。1.1 切换从来不是你主动发起的新手最常有的一个误解是进程切换是某个切换函数主动调用的。实际上在绝大多数情况下进程切换是被动发生的。真正的情况是——时钟中断来了中断处理程序发现当前进程的时间片用完了于是在返回用户态之前的那个时机把需要重新调度的标志置上然后在返回路径上检查这个标志才进入调度器。整个过程像这样时钟芯片发出中断信号CPU 打断当前执行的指令流硬件自动保存一部分现场返回地址、标志位跳到中断入口;内核入口代码把通用寄存器压到当前进程的内核栈上中断处理逻辑更新统计、判断是否需要抢占在返回用户态的途中检查重新调度标志满足条件则调用调度器选出下一个进程切换内核栈和地址空间恢复目标进程的寄存器目标进程沿着它自己上次被打断的位置继续。注意第 8 步的措辞——它自己上次被打断的位置。这就是为什么一个进程被换下去再换回来它完全不知道自己中间被暂停过就像什么都没发生一样。特权级和栈的切换发生在第 3 步之前那些是你写代码时看不见的硬件行为。另外还有一类是主动放弃 CPU进程调用 sleep、等待 I/O、等待锁、读取管道没有数据这些都会让进程从运行态转入阻塞态然后在系统调用返回用户态之前直接调用调度器。这种情况下的切换是自愿切换。区分自愿和非自愿是后面排查性能问题必须掌握的技能。1.2 被保存下来的到底是什么东西问题的关键在这里切换时要保存的不是进程的所有信息因为进程的所有信息早就在 memory 里了代码段、数据段、堆、栈都在。真正需要保存的是只存在于 CPU 内部、一旦被别的进程改写就找不回来的那部分状态。具体包括通用寄存器eax、ebx、ecx、edx、esi、edi 等它们是进程计算的中间结果栈指针esp32 位或 rsp64 位指向当前内核栈的位置指令指针eip 或 rip决定这个进程下次从哪条指令继续标志寄存器eflags / rflags存着条件码和中断使能位浮点和向量寄存器现代 CPU 上这部分的体积比通用寄存器大得多通常采用惰性保存策略也就是只有真的用到了才存地址空间标识页表基址cr3或者 64 位下的 pcid 相关状态。而下面这些通常不需要放进切换过程因为它们本来就在内存里打开的文件描述符表、内存映射信息、信号处理配置、进程优先级和调度参数、父子关系。这些统一放在进程控制块 PCB 里切换时只是指针换了一个不需要逐字段拷贝。这里有个很有意思的设计细节值得单独说一下。在很多教学内核里PCB 里并不直接内嵌一个巨大的寄存器快照而是只放一个栈指针。因为所有需要保存的寄存器已经被压在当前进程的内核栈上了只要把栈顶指针记下来下次把这个栈顶指针恢复回去再逐个弹栈寄存器就全回来了。这就是为什么你翻 xv6 的struct context会发现里面只有五六个字段而不是几十个。1.3 内核栈切换真正的主角如果把进程切换比作换人上台那么内核栈就是那个人的座椅——你离开时把身上的东西都掏出来放在椅子上回来时从椅子上把东西拿回来。每个进程都有自己独立的内核栈这是切换能够成立的基础。为什么必须是独立的因为内核代码执行到一半被打断时局部变量、返回地址、保存的寄存器全都压在这段栈上。如果所有进程共用一个内核栈下一个进程一进来就把上一个进程的栈内容踩掉了等上一个进程被换回来它的返回地址已经变成了别人的数据直接执行到随机地址上去。栈的独立性还带来一个推论切换动作本身必须在栈上完成。你不可能在上一个进程的栈上恢复下一个进程的寄存器所以这类切换代码总是用汇编手写顺序是先把当前栈指针存到旧 PCB、再把新 PCB 的栈指针装进 esp然后开始弹栈。一旦 esp 被改写从下一行汇编开始你已经在别人的栈上执行了。2. 一次完整切换的时间线从时钟中断到新进程的第一条指令光看概念容易飘我们把一次非自愿切换的全过程按时间顺序拆开。我建议你在读这一节的时候打开自己的练习环境在这些关键点上打上断点或者加打印跟着走一遍。走一遍之后你会对上下文这个词有完全不同的理解。2.1 中断入口做了哪些脏活累活中断发生的瞬间CPU 做的事情极其有限把当前标志寄存器和返回地址也就是被打断的那条指令的地址压到当前进程的内核栈上然后根据中断号查表跳到对应的入口。注意这里压栈用的是当前进程的内核栈而不是某个全局栈——这个细节决定了后面一切都能对上号。接下来是入口代码的活。不同架构写法不同但逻辑一致保存被调用者保存寄存器以及部分调用者保存寄存器取决于约定保存段寄存器或者用户栈指针从硬件状态里读中断原因必要时切换数据段调用 C 语言写的分发函数。之所以要在汇编里做这一步是因为 C 编译器在使用寄存器时不会替你保存现场你必须在进入 C 代码之前自己把该压的压好。这段代码通常位于entry.S之类的文件里形如一连串push指令。它们看起来枯燥但每一次压栈都对应着后面上下文里的一个字段是理解切换开销的直接依据。中断上半场处理完之后可能会做两件事一是更新当前进程的时间统计二是判断是否应该触发抢占。有的内核把判断逻辑做成一个宏在中断返回路径上调用有的直接在中断处理里就置标志位。不管哪种写法最终都落到同一个地方——在返回用户态之前检查是否需要调度。2.2 调度器里的三件事选谁、换栈、换地址空间进入调度逻辑之后事情反而清晰了核心就三件第一件选出下一个进程。这由调度策略决定时间片轮转看剩余时间CFS 看虚拟运行时间实时调度看优先级和截止期。教学内核里这一步往往简单到几十行遍历就绪队列比较优先级。真实内核里则是一棵红黑树加上一堆启发式规则。选不出来的时候会退回到 idle 进程让 CPU 进入低功耗状态等待下一个中断。第二件切换栈。这是整套机制的核心动作。伪代码大概长这样static void switch_to(struct task *prev, struct task *next) { if (prev next) return; prev-state READY; // 或由调用方提前设置 __switch(prev-ctx, next-ctx); // 保存 prev 的栈顶到 prev-ctx // 装载 next-ctx 到 esp开始弹栈 // 从这一行开始prev 这个变量可能已经不是原来那个进程了 }__switch是汇编函数只干两件事把寄存器压到旧栈上并把旧栈顶存进prev-ctx把next-ctx装进栈指针然后把寄存器弹回来。听起来简单但里面藏着两个大坑后面第 5 节会专门讲。第三件切换地址空间。如果两个进程不属于同一地址空间比如不同进程而非同进程线程就要更新页表基址寄存器。这一步是性能杀手换了页表绝大部分 TLB 表项立即失效后面几条指令访问内存全都要走完整的页表遍历。64 位系统上有 PCID 这类机制来缓解但代价依然存在。三件事做完__switch最后一条ret会把栈上保存的返回地址弹到指令指针里CPU 跳过去继续执行——此时执行的已经是另一个进程了。2.3 新进程为什么总是从切换函数返回处继续这是练习里最值得琢磨的问题理解了它你对上下文的理解就到位了。任何一个进程被换下去的时候它正在执行的代码位置恰好就在__switch内部。所以它保存的返回地址就是__switch里ret指令要用的那个地址。等它再被换上来弹栈得到的就是同一个地址于是它自然从__switch返回处继续。对进程来说它调用的switch_to函数刚刚返回中间经过的时间它完全感知不到。对于刚刚第一次被调度的新进程情况要特殊一点。它的上下文是被人为构造的——在创建进程时内核会在这段栈上假装压入一组初值返回地址指向一个特殊的入口函数xv6 里叫forkretLinux 0.11 里是ret_from_sys_call相关路径。所以新进程第一次被调度时看起来就像它以前调用过__switch并且现在返回了一样然后顺着这个入口一步步走到用户态开始执行真正的程序。Linux 0.11 用的是另一种思路。它借助 x86 的硬件任务切换执行一条远跳转到目标进程 TSS 段选择子的指令CPU 就会自动把当前所有寄存器写进旧 TSS再从新 TSS 里读回全部寄存器包括 cr3所以地址空间也一起切了。这个方案写代码极少但硬件任务切换本身很慢要访问内存里的 TSS还要做一堆检查所以现代 Linux 早就改成软件切换了。有意思的是TSS 并没有被完全废弃——64 位下每个 CPU 还保留一个 TSS但只用来在用户态陷入内核时告诉硬件内核栈在哪跟任务切换已经没关系了。在练习报告里把这段演进写清楚通常是个加分项。3. 照着代码走一遍教学内核里的 switch_to 怎么写的纸上谈兵没意义这一节我们把两套经典实现拆开看。你可以挑一套对照自己的练习环境。3.1 xv6 的 swtch五行汇编句句有讲究xv632 位版本的上下文结构只有一个字段很小的一家子struct context { uint edi; uint esi; uint ebx; uint ebp; uint eip; };对应的切换代码大致是这样# void swtch(struct context **old, struct context *new); swtch: movl 4(%esp), %eax # eax old指向旧 context 指针的地址 movl 8(%esp), %edx # edx new pushl %ebp pushl %ebx pushl %esi pushl %edi movl %esp, (%eax) # 把当前栈顶存进 *old movl %edx, %esp # 装载新栈顶 popl %edi popl %esi popl %ebx popl %ebp ret四个push的顺序和结构体字段的顺序是反着的这不是笔误。压栈是从高地址往低地址走所以最后压入的 edi 在最低地址而 C 结构体字段在内存里是从低地址往高地址排所以 edi 排第一。两边一配struct context的头几个字段正好和栈上的布局一一对应。这个细节在练习题里经常被拿出来考如果把 push 顺序改成 ebp、ebx、esi、edi程序会怎样答案是会跑到错误的位置去因为恢复时的 pop 顺序没变读到的值全错位了。栈布局对照表如下以保存下来的栈顶为偏移 0偏移内容来源0edi最后的 pushl %edi4esipushl %esi8ebxpushl %ebx12ebppushl %ebp16eipcall swtch 时由 call 指令压入的返回地址只有五个字段说明 xv6 的编译器约定里调用者保存寄存器由调用者在调用前自己处理swtch只需要管被调用者保存的那几个。这也是为什么切换开销在不同编译选项下会有差异——优化级别变了调用者保存寄存器的使用情况就变了。如果你用的是新版的 RISC-V 版本 xv6结构体换成了ra、sp、s0到s11切换代码在swtch.S里用sd/ld成对指令完成。逻辑完全一样存栈指针到旧上下文、装新栈指针、恢复被调用者保存寄存器、ret。换架构不换思想这句话在这里体现得特别明显。3.2 Linux 0.11 的 switch_to硬件任务切换的完整套路Linux 0.11 的宏定义在sched.h里核心是一条远跳转加上几个条件判断。它依赖几样硬件设施TSS任务状态段每个任务一个里面存着全套寄存器值、内核栈指针、cr3GDT 里的 TSS 描述符远跳转的目标ljmp 指令触发硬件任务切换。执行流程是先把current指针交换成新任务然后远跳到新任务的 TSS 选择子。CPU 一看目标是个 TSS 描述符就自动保存现场、加载新的上下文。有个细节很妙——新任务恢复后的 eip 恰好是 ljmp 后面那条指令的地址因为硬件在保存现场时保存的是下一条要执行的指令的地址。所以新任务被第一次调度时看起来就像它刚刚执行完那条 ljmp 一样然后继续往下走。这套方案还有个附带好处地址空间切换是硬件顺带做的因为 TSS 里有 cr3 字段CPU 在加载任务状态时会连页目录基址一起换掉不用写代码。缺点是慢而且要占用 GDT 表项、有任务数量限制所以后来被彻底抛弃了。对比两套实现能总结出一条很实用的判断原则如果某项硬件功能提供的性能不如软件实现那它最终一定会被软件取代只保留那些绝对必要的部分。TSS 就是活生生的例子硬件任务切换被删了但 TSS 里提供内核栈指针这个功能被保留了下来。3.3 手工把寄存器状态画出来练习里经常有一道题画出切换前后栈的变化。我自己的做法是在纸上画两条竖线代表两个内存区域然后把每一次 push 都用一个小方格画出来标注这个格子里放的是什么、是哪一行代码压进去的。这样做一遍比看十遍代码都管用。画的时候注意三个易错点一是栈的增长方向x86 上是向低地址二是参数是压在哪条栈上的32 位下 xv6 的参数压在被调用者的新栈上这个顺序和 64 位寄存器传参完全不同三是call指令压入的返回地址到底属于哪个上下文。把这三点搞清楚你就具备了独立阅读任何一款内核切换代码的能力。4. 课堂练习 3.4 的动手部分观测、验证与量化光理解不算完成练习题目一般还要求你观测并量化。这一节给出三种由易到难的方案你可以按自己的时间预算挑。4.1 加打印加计数器最土但最有效的观测手段在教学内核里最快的办法就是在调度函数里加一个全局计数器和一个打印。示意如下// 全局统计 static unsigned long switch_count 0; static struct proc *last 0; void scheduler(void) { for (;;) { for (int i 0; i NPROC; i) { struct proc *p proc[i]; if (p-state ! RUNNABLE) continue; if (p ! last) { switch_count; if (switch_count % 100 0) printf([switch] %lu: %s - %s\n, switch_count, last ? last-name : (none), p-name); last p; } // ... 切换到 p } } }有两个坑必须提前说第一不要在持有锁的情况下调用打印。很多教学内核的调度器是在持有进程表锁的循环里跑的printf内部可能触发 I/O 等待进而触发调度直接死锁。常见做法是先把要打印的内容存到缓冲区等释放锁之后再输出。第二计数器本身会改变被测对象。每多一条内存写cache 行为就变一点。所以做定量分析时务必把打印关掉只留计数或者用统计采样的方式。观测之后要回答的问题通常是某个进程运行期间被切换了几次每次切换前后系统处于什么状态把这些问题回答清楚比单纯的数字更有价值。4.2 用 ftrace 看真实的内核切换流水如果练习环境是完整的 Linuxftrace是最好用的工具不用写一行内核代码。打开调度事件追踪# 需要 root 权限且内核开启了 CONFIG_FUNCTION_TRACER 等选项 echo 0 /sys/kernel/debug/tracing/tracing_on echo 1 /sys/kernel/debug/tracing/events/sched/sched_switch/enable echo 1 /sys/kernel/debug/tracing/tracing_on # 让某个程序跑一会儿然后 cat /sys/kernel/debug/tracing/trace | head -50 echo 0 /sys/kernel/debug/tracing/tracing_on输出里每一行都会告诉你上一个进程是谁、下一个是谁、优先级多少、因为什么被换下去。我实测下来这个工具最有用的一点是能直接看到切换的原因是主动阻塞比如等待 I/O还是被抢占还是退出。很多程序卡顿的排查思路就是从这些原因里来的。再看统计信息grep ctxt_switches /proc/pid/status你会看到两个数字voluntary_ctxt_switches和nonvoluntary_ctxt_switches。前者是主动让出 CPU 的次数后者是被强制抢占的次数。这个区分价值极高——一个进程自愿切换多说明它在大量等待 I/O是 I/O 密集型非自愿切换多说明它在被时间片频繁打断可能是 CPU 密集且优先级配置不合理。4.3 亲手测一次切换开销想拿到具体的微秒数最经典的做法是两个进程用管道来回传递一个字节用往返总时间除以切换次数。下面是我自己用的最小版本#define _GNU_SOURCE #include stdio.h #include stdlib.h #include unistd.h #include time.h static double now_us(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); return ts.tv_sec * 1e6 ts.tv_nsec / 1e3; } int main(int argc, char **argv) { int n argc 1 ? atoi(argv[1]) : 100000; int p2c[2], c2p[2]; if (pipe(p2c) || pipe(c2p)) { perror(pipe); return 1; } if (fork() 0) { /* 子进程收到就回不做别的 */ char c; for (int i 0; i n; i) { if (read(p2c[0], c, 1) ! 1) break; if (write(c2p[1], c, 1) ! 1) break; } _exit(0); } char c x; double t0 now_us(); for (int i 0; i n; i) { write(p2c[1], c, 1); read(c2p[0], c, 1); } double t1 now_us(); printf(%d 次往返平均 %.3f us/往返\n, n, (t1 - t0) / n); return 0; }编译运行gcc -O2 -o ctsw ctsw.c # 绑到同一个核上保证切换真的发生而不是并行执行 taskset -c 0 ./ctsw 200000几个必须注意的点不注意就会得出错误结论一定要绑核。不绑核的话父进程写完管道可能被子进程在另一个核上并行处理你测到的就不是切换开销了一次往返包含两次切换父→子、子→父还要叠加两次 read/write 系统调用的开销和管道缓冲区管理的开销所以你算出来的是上界不是纯粹的切换代价warm-up很有必要。第一轮循环可能会触发缺页、动态链接等一堆一次性开销前几千次通常明显偏慢建议丢掉前 10% 的数据再取平均数字强依赖硬件。现代 x86 服务器上一次纯切换的直接开销大致在 1 到 3 微秒量级算上流水线重填、cache 和 TLB 失效的间接影响端到端可能到 5 到 10 微秒。老机器、开了大量缓解措施的环境下会更慢。别照抄网上的数字自己测一遍才有意义。我建议你在练习报告里同时给出三组数绑核与不绑核、开优化与关优化、单次与批量。三组数据之间的差异本身就是一篇很好的分析。5. 做这个练习最容易踩的五个坑下面这些坑我基本都亲手踩过至少一次。有些是概念不清导致的有些是工具用错导致的还有的是观测手段本身干扰了结果。5.1 在不该切换的地方睡了第一个坑最致命在内核里调用了可能睡眠的函数但当前处于不允许睡眠的上下文。哪些上下文不允许睡眠中断处理程序、软中断、持有自旋锁的临界区、显式关闭抢占的区域、RCU 读侧临界区。理由很直接这些上下文没有可以保存和恢复的进程上下文或者说它们占用的资源在睡眠期间无法释放。你一旦在里面调用调度器轻则内核发出警告、打印一大段栈回溯重则直接死机。识别方法很简单凡是看到might_sleep、BUG: scheduling while atomic这类提示基本就是这个毛病。修法也很直接——把可能阻塞的操作挪到可以睡眠的上下文里去比如从中断处理里把工作丢给工作队列或者内核线程从自旋锁临界区里挪出来改用别的同步方式。在教学内核里这个坑往往表现为加了打印就卡死。因为打印函数内部可能等 I/O而调度器正持有进程表锁。记住一句话调度器是最不该被打扰的地方任何在它里面执行的代码都必须是确定性的、非阻塞的。5.2 switch_to 的两次返回陷阱这个坑非常隐蔽属于那种不知道就永远想不明白为什么程序行为诡异的类型。切换函数执行完之后看起来它返回了。但请仔细想如果A调用switch_to换到B那么当B后来又被换下去、A被换回来时从A的视角看那个switch_to调用刚刚返回。问题在于这条返回路径可能由完全不同的执行流走到而局部变量的值、当前 CPU、甚至prev指针指向的结构体内容都可能已经变了。在真实的 SMP 系统上这个问题更尖锐A被换下去后调度器可能让A跑到另一个 CPU 上去了这时候prev指向的进程正在别的 CPU 上运行甚至已经被销毁了。所以现代的切换宏必须处理返回之后我拿到的 prev 到底是谁这个问题——通常的做法是让被调用者返回一个能标识上一个任务的数值然后在宏里把这个返回值重新赋给局部变量屏蔽掉编译器对局部变量做过期优化。这个点在做课堂练习时通常用不上单核、任务不迁移但如果你在报告里能把这段说清楚说明你真正理解了切换函数的语义边界而不是只记住了汇编指令。5.3 栈指针错位段错误的最大来源自己动手写切换代码时最常见的崩溃原因是栈指针算错。典型症状是编译通过、链接通过一跑就 triple fault 或者跳到一个莫名其妙的地