ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

进程切换与上下文切换:从 ucontext 模拟到 Linux 内核

进程切换与上下文切换:从 ucontext 模拟到 Linux 内核 1. 先搞清楚进程切换到底在切什么进程切换这个事教材上通常一句话就带过去了——保存当前进程的现场恢复下一个进程的现场。但凡是真动手写过切换代码的人都知道这句话背后藏着一堆容易翻车的细节。这次课堂练习3.4 的内容就是进程的切换我打算把它拆成两条线来讲一条是原理线讲清楚 CPU 到底把什么东西从一个进程手里交到了另一个进程手里另一条是实操线用用户态代码把切换这件事真正跑起来让每个抽象概念都有对应的机器行为。这两条线合起来基本就覆盖了操作系统课里这块内容的全部门槛。先说清楚这个练习适合谁。如果你正在学操作系统、刚背完进程三态图和 PCB 的字段定义但一看到 switch_to 就发懵那这篇内容正好对路。如果你已经写过内核模块、调过调度器也可以把中间那段用户态模拟器当成一个干净的教学沙盒——它把内存管理、中断处理、锁全部剥掉了只剩下上下文和调度两个变量调试起来比在内核里加 printk 舒服得多。我先把结论摆在前面进程切换的本质是把一个执行流的可继续执行所需的最小状态集合从 CPU 上摘下来再把另一个执行流的同款状态集合装上去。这个状态集合在 x86-64 上大致就是通用寄存器、指令指针、栈指针、标志寄存器加上浮点/SIMD 状态和一堆段寄存器。难点不在于存哪些寄存器而在于存到哪里和什么时候存。前者对应内核栈与 PCB 的配合后者对应切换时机的判断。1.1 CPU 只有一个进程却有好几个这是理解切换的起点。单核机器上同一时刻只有一个进程能占用 CPU其他进程的代码根本没有在执行。它们之所以看起来同时在跑是因为切换足够频繁——典型桌面系统上每秒几千次到上万次的上下文切换是很常见的量级人类的感知分辨率根本追不上。所以进程的并发是一个时间维度上的错觉物理上永远是串行的。既然物理上串行就必然存在交棒动作。交棒的那一刻CPU 里所有的寄存器都还写着上一个进程的值rax 里可能是它刚算出来的中间结果rsp 指向它的栈顶rip 指向它即将执行的那条指令。这时候如果把 CPU 直接交给下一个进程下一个进程一执行就会从上一个进程的指令流里继续跑然后立刻崩溃。所以交棒前必须先把这套寄存器存进内存交棒后再从内存里把另一套读出来。这就是为什么进程切换必须发生在内核态。用户态代码没有权限去操作另一个进程的内核栈、页表基址寄存器和特权级栈指针也没有权限去替换整个地址空间。切换动作天然属于内核的特权范围用户态能做的只有请求——通过系统调用、中断或异常进入内核然后由内核决定要不要换人。这里有个常见的误解需要澄清系统调用进入内核不等于发生了进程切换。一次 read 系统调用可能只是内核替当前进程读了几个字节就返回了全程没有换人。判断标准是看调度器有没有选中另一个进程、有没有真的执行 swapcontext 那类动作。这个区分在后面看性能计数器的时候特别重要因为很多新手把系统调用次数和上下文切换次数混为一谈然后对着 vmstat 的输出百思不得其解。1.2 PCB、内核栈与上下文三件套要动手写切换必须先约定进程的状态存在哪。操作系统给的答案是进程控制块PCBLinux 里对应的结构体叫 task_struct。这个结构体里字段几百个但跟切换直接相关的是这么几类进程标识pid、tgid、状态字段TASK_RUNNING / TASK_INTERRUPTIBLE 等、调度相关优先级、时间片、调度实体、内存相关mm_struct 指针、页表基址、以及最关键的——保存寄存器现场的 thread_struct。thread_struct 里存的是内核视角下的 CPU 现场。注意这里有个容易被忽略的点进程在用户态被打断时用户态的寄存器现场并不是直接存在 thread_struct 里的而是被压在了内核栈上。原因很简单从用户态陷入内核时硬件和入口代码会自动把一部分寄存器比如 ss、rsp、rflags、cs、rip压栈随后内核的保存代码再把剩余的通用寄存器也压进去。所以完整的用户态现场 内核栈上的一段数据。而线程切换时真正需要保存的内核态现场是当切换发生时正运行在内核里的那条执行流的状态——内核栈指针、被调用者保存寄存器、指令指针。这部分由 switch_to 宏在汇编层面处理存进 thread_struct 的 sp 字段。所以你会看到一个双层结构用户态现场压在内核栈上内核栈指针存在 thread_struct 里thread_struct 属于 task_structtask_struct 由调度器管理。切换时只要换掉内核栈指针下次这个进程被调度回来从新的其实是它自己的栈指针开始恢复就能一路返回到当初陷入内核的地方再 iret 回用户态从被打断的那条用户指令继续执行。整个过程像一个精心设计的俄罗斯套娃缺一层都接不上。我在教学里经常用一个类比解释这个结构你把正在读的书合上夹一张书签用户态现场把书放回书架的一个格子内核栈然后在借阅记录本上写下格子编号thread_struct 的 sp。下次要看这本书翻记录本找到格子编号取出书翻到书签那页继续读。书架上的格子就是内核栈记录本就是 task_struct书签就是压栈的寄存器。1.3 切换的时机主动让出与被动抢占什么时候会切粗分两类。第一类是当前进程自己不想继续跑了它调用了阻塞式系统调用等 I/O、等锁、等信号量或者主动调用 sched_yield 让出 CPU或者干脆执行完了退出。这类切换是自愿的统计上对应自愿上下文切换。第二类是当前进程还想跑但内核不让它跑了时间片用完、被更高优先级进程抢占、或者收到了需要立刻处理的信号。这类是被动的对应非自愿上下文切换。抢占通常由时钟中断触发——内核在定时器中断的处理路径里检查当前进程是否该被换下如果是就设置一个重调度标志然后在返回用户态之前的合适时机执行切换。这个合适时机的把握很讲究内核必须确保自己不在持有自旋锁、不在中断上下文、不在原子操作中间的时候换人否则会死锁或者数据错乱。这两种切换在性能上的含义完全不同。自愿切换通常意味着进程在等资源系统整体可能处于资源紧张状态非自愿切换高则说明 CPU 时间被抢得厉害进程想跑却排不上队。后面讲 vmstat 和 pidstat 的时候我会具体说怎么把这两个数字读出来、怎么判断系统到底是哪种瓶颈。还有一个概念要顺手区分进程切换和模式切换。模式切换是用户态到内核态或反过来的转换只换特权级不换执行流开销相对小。进程切换是换执行流开销大得多。很多性能问题就出在把这两者搞混——比如觉得某个程序系统调用多所以慢其实真正贵的是它每次系统调用都触发了调度。2. 课堂练习3.4 的整体设计思路拿到这个练习我第一反应不是去翻内核源码而是先想清楚我要验证什么。如果目标是背下 switch_to 的汇编那读源码就够了但这个练习叫进程的切换重点应该在切换这个动作本身——它怎么发生、代价在哪、状态怎么保住。这几点在用户态就能完整演示而且调试成本低一百倍。我的方案分三层。最底层是用户态的上下文切换原语用 ucontext 家族实现把保存/恢复寄存器现场这个动作显式暴露出来。中间层是一个极简调度器用轮转策略挑下一个进程把调度决策和上下文切换这两件事分开。最上层是几个测试进程它们各自维护自己的局部变量计数用来验证切换后现场是否真的保住了。跑完之后再对照真实内核的机制把用户态模拟里被隐掉的部分补回来。2.1 为什么不直接读内核源码先用用户态模拟内核源码里那条路径太长了。从时钟中断进来经过中断门、保存现场、调用调度器、pick_next_task、context_switch、switch_mm、switch_to最后再恢复、返回。任何一环出问题现象都是机器卡死或者内核 panic调试基本靠二分注释和猜。对于刚接触这块的同学这条路径的学习曲线近乎垂直。用户态模拟的好处是把变量降到最少。ucontext 提供了 getcontext / swapcontext / makecontext 三个函数能把当前寄存器现场存进一个结构体、把另一个现场装载回来。你可以在 GDB 里单步看每个步骤可以打印 ucontext_t 里关键字段的值可以在切换前后加日志观察执行流走向。等这套逻辑在脑子里跑通了再去看内核的 switch_to你会发现结构完全一样只是寄存器保存的粒度更细、还要额外处理地址空间切换。提示ucontext 家族的接口在某些平台上被标记为已废弃但主流 Linux 发行版的 glibc 至今可用用来做教学演示完全没问题。生产环境不推荐依赖它的性能表现。2.2 两条技术路线的对比用户态实现上下文切换常见的有两套方案ucontext 和 setjmp/longjmp。我在练习里让同学两套都写一遍因为它们的取舍很能说明问题。对比维度ucontextsetjmp/longjmp是否自带栈是makecontext 时指定否需要自己分配和切换栈保存的寄存器范围完整含信号掩码等主要是 callee-saved 寄存器 栈指针首次启动新执行流支持makecontext 可直接指定入口不支持需要额外技巧引导可移植性POSIX 定义但被标记过时标准 C广泛可用教学友好度高概念对应直观中需要理解栈切换的细节可控性低栈和掩码由库管理高每一步都要自己安排我一般推荐先用 ucontext 把整体框架跑通再换成 setjmp/longjmp 手动管理栈这样能真正理解栈切换这一步到底做了什么。用 ucontext 的时候栈是库帮你切的你只是换了 uc_stack 字段用 setjmp/longjmp 的时候你得自己写一小段汇编或者用内联汇编去改 rsp那一刻你才会明白为什么内核切换必须由汇编完成。2.3 这个练习应该交出什么我给练习定的交付标准是三条。第一能跑多个进程轮流执行每个进程打印自己的 pid 和局部计数输出的交错顺序符合调度策略预期。第二能量化统计每个进程的切换次数、总的切换次数并且能在退出时打印汇总。第三能解释对着自己的代码说清楚哪一行保存了现场哪一行恢复了现场如果去掉这一行会发生什么。第三条最容易被忽略但价值最高。很多同学代码跑通了一问swapcontext 返回的时候 CPU 在哪个栈上就答不上来。这种跑通了但不懂的状态到内核里一定会栽跟头。所以我在验证环节会专门设计几个破坏性实验故意把栈空间调小看溢出故意不初始化 uc_link 看返回路径故意在切换前后打印同一个局部变量的地址观察它在不同进程里的值是否独立。3. 动手实现一个可跑的进程切换模拟器下面是我实际用的代码框架。整体思路是维护一个 PCB 数组每个 PCB 带自己的栈和 ucontext一个全局变量记录当前运行的是谁切换函数负责交换两个 context。调度器用最简单的轮转测试进程每隔几次循环就主动让出一次 CPU。3.1 环境准备与编译参数环境没什么特别的Linux gcc 就行。编译的时候加几个参数-g 带调试信息方便 GDB 单步-Wall 把警告全打开。这里有个坑必须提前说如果开启了 -O2 优化用 setjmp/longjmp 那套方案时编译器可能把某些局部变量优化到寄存器里longjmp 回来之后值就乱了标准规定这类变量必须是 volatile。虽然 ucontext 方案不受这个影响但我建议一开始就用 -O0 编译把逻辑跑对再考虑优化。gcc -g -O0 -Wall -o pswitch pswitch.c栈大小我用 64KB。这个值不是随便定的ucontext 的 makecontext 要求栈足够放下新执行流的调用链太小了会直接段错误而且报错位置通常在 makecontext 内部看起来像是库的 bug。64KB 对演示程序绰绰有余八个进程加起来也就 512KB 内存不心疼。3.2 数据结构把 PCB 搬到用户态PCB 我定义为结构体字段尽量精简但要覆盖关键信息#include stdio.h #include stdlib.h #include string.h #include ucontext.h #define STACK_SIZE (64 * 1024) #define NPROC 4 #define MAX_ROUND 6 typedef enum { READY, RUNNING, DONE } pstate_t; typedef struct { int pid; char name[16]; pstate_t state; ucontext_t ctx; char stack[STACK_SIZE]; int switch_count; int progress; /* 每个进程自己的进度用来验证现场是否独立 */ } pcb_t; static pcb_t pcb[NPROC]; static ucontext_t sched_ctx; static int current -1; static const char *state_name(pstate_t s) { switch (s) { case READY: return READY; case RUNNING: return RUNNING; case DONE: return DONE; default: return ?; } }这里有几个设计决定值得说明。progress 字段是每个进程私有的计数器专门用来验证上下文是否真的隔离——如果切换时现场没保住两个进程的 progress 就会互相干扰表现为计数跳变或者回退这是最直观的 bug 信号。switch_count 则在每次被调度时递增用来统计切换分布是否均匀。name 字段纯粹为了输出好看。state 字段的取值我故意和内核的进程状态做了简化对应内核里的 TASK_RUNNING 实际上同时涵盖正在跑和就绪待跑我这里把它们拆成 RUNNING 和 READY 两个教学上更清楚代价是需要多写一点状态维护逻辑。3.3 核心切换函数 switch_to这是全篇最核心的十几行static void switch_to(int next) { int prev current; current next; if (prev 0) { /* 第一次启动从调度器上下文切到第一个进程 */ swapcontext(sched_ctx, pcb[next].ctx); } else if (prev ! next) { swapcontext(pcb[prev].ctx, pcb[next].ctx); } }swapcontext 的两个参数第一个是把当前现场存到这里第二个是从那里装载现场并跳过去。所以swapcontext(pcb[prev].ctx, pcb[next].ctx)这一行的语义是把当前 CPU 状态存进 prev 的 context然后从 next 的 context 恢复并继续执行。下一次 prev 被调度回来时它会从这一行的返回处继续就像中间什么都没发生过。秒表就藏在这一行。我让同学在 swapcontext 前后各取一次时钟累加所有切换的耗时除以切换次数得到本机上单次切换的平均开销。这个数字不必和大佬论文里的数值比较它的意义在于让你对切换是有成本的这件事有具体的量感。实测在我的开发机上用户态 ucontext 切换在纳秒到微秒之间具体数值受 CPU 频率、缓存状态、编译器优化影响很大每次跑都可能不一样。注意prev ! next这个判断不能省。如果不加当调度器选中同一个进程时swapcontext 会把当前上下文存进自己的 ctx再从同一个 ctx 恢复行为上虽然能跑通但会平白多一次系统调用开销而且日志里会出现自己切到自己的诡异记录。3.4 调度器与让出逻辑调度策略我用最朴素的轮转从当前进程的下一个位置开始找第一个 READY 的static int pick_next(int from) { for (int i 1; i NPROC; i) { int cand (from i) % NPROC; if (pcb[cand].state READY) { return cand; } } return from; /* 没有别的就绪进程继续跑自己 */ }从 from 1 开始找而不是从 from 开始是轮转算法的关键。如果从自己开始当前进程状态还是 RUNNING一找就找到自己永远轮不到别人。这个细节我在课堂上见过太多人踩。让出函数由测试进程主动调用static void yield(void) { int me current; int next pick_next(me); if (next me) { return; /* 就自己一个能跑让不让都一样 */ } pcb[me].state READY; pcb[next].state RUNNING; pcb[next].switch_count; switch_to(next); }状态维护的顺序很重要。必须先把当前进程标成 READY再标目标为 RUNNING最后才调 switch_to。如果顺序反了pick_next 可能会选中一个状态还没更新好的进程出现两个进程同时是 RUNNING 的状态不一致。进程退出则单独处理因为退出的进程不该再被调度回来static void proc_exit(void) { int me current; pcb[me].state DONE; printf([exit ] %s finished, switches%d progress%d\n, pcb[me].name, pcb[me].switch_count, pcb[me].progress); int next pick_next(me); if (next me) { /* 全部结束切回调度器上下文 */ swapcontext(pcb[me].ctx, sched_ctx); } else { pcb[next].state RUNNING; pcb[next].switch_count; switch_to(next); } }注意这里有个不对称正常让出用 switch_to最后清场时用swapcontext(pcb[me].ctx, sched_ctx)直接回调度器。这一行是整个程序的出口执行到它之后执行流会回到 main 里第一次调用 switch_to 的地方继续。这时候 current 还指着最后一个进程但那个进程已经是 DONE 状态不会再被调度。3.5 进程主体与完整启动流程测试进程的主体逻辑要足够简单简单到能一眼看出切换行为static void proc_body(void) { int me current; for (int round 0; round MAX_ROUND; round) { pcb[me].progress; printf([run ] %s pid%d round%d/%d progress%d\n, pcb[me].name, pcb[me].pid, round 1, MAX_ROUND, pcb[me].progress); yield(); } proc_exit(); }这里int me current;只在进入时取一次之后整个循环都用这个局部变量。这正好是个绝佳的验证点如果上下文保存有问题某次从 yield 返回时 me 变成了别的值输出就会错乱。实际调试时我把这行改成每次循环重新读 current对比两种写法的输出差异能很直观地看出局部变量存在栈上、栈被正确保存这件事。初始化部分要小心 makecontext 的两个要求栈必须提前准备好uc_link 必须指向一个有效的返回上下文static void init_procs(void) { for (int i 0; i NPROC; i) { pcb[i].pid i; snprintf(pcb[i].name, sizeof(pcb[i].name), proc-%d, i); pcb[i].state READY; pcb[i].switch_count 0; pcb[i].progress 0; getcontext(pcb[i].ctx); pcb[i].ctx.uc_stack.ss_sp pcb[i].stack; pcb[i].ctx.uc_stack.ss_size STACK_SIZE; pcb[i].ctx.uc_link sched_ctx; makecontext(pcb[i].ctx, (void (*)(void))proc_body, 0); } } int main(void) { init_procs(); printf( process switch simulator \n); int first pick_next(-1); current -1; switch_to(first); /* 所有进程结束后会回到这里 */ printf(\n all done \n); int total 0; for (int i 0; i NPROC; i) { printf(summary: %s state%s switches%d progress%d\n, pcb[i].name, state_name(pcb[i].state), pcb[i].switch_count, pcb[i].progress); total pcb[i].switch_count; } printf(total switches %d\n, total); return 0; }main 里pick_next(-1)这个调用有点绕传入 -1(-1 1) % NPROC等于 0正好从 0 号进程开始找这是有意的技巧。current 先设成 -1 再调 switch_to是为了让 switch_to 走首次启动分支从 sched_ctx 出发。如果我这里写 current 0那 switch_to 会尝试从 pcb[0].ctx 切出而 pcb[0] 的 ctx 还没被保存过行为未定义。跑起来的输出大致是这样实际顺序取决于调度轮转 process switch simulator [run ] proc-0 pid0 round1/6 progress1 [run ] proc-1 pid1 round1/6 progress1 [run ] proc-2 pid2 round1/6 progress1 [run ] proc-3 pid3 round1/6 progress1 [run ] proc-0 pid0 round2/6 progress2 ... [exit ] proc-0 finished, switches3 progress6 ... all done summary: proc-0 stateDONE switches3 progress6 total switches 13看到每个进程的 progress 都稳稳地从 1 走到 6说明现场保存和恢复是正确的。如果某个进程的 progress 出现重复值或跳变那就说明它的栈被别的进程踩了八成是 STACK_SIZE 不够或者栈指针算错了。4. 从用户态切到内核态真身Linux 上的对照用户态这套跑通之后理解内核的切换机制就轻松了。内核做的是同一件事只是多了三重负担要处理特权级、要处理地址空间、要处理并发安全。4.1 task_struct、内核栈与 thread_info 的三角关系Linux 早期版本把 thread_info 放在内核栈的尾部栈底通过栈指针按位与就能算出 thread_info 地址进而找到 task_struct。后来的版本出于安全考虑改成了独立分配但从任意内核执行流快速定位当前进程这个需求没变各种方案都围绕它做文章。每个进程都有自己独立的内核栈x86-64 上通常是 16KB4 个页面或者配置成 8KB。用户态程序在用户栈上跑一旦陷入内核CPU 会根据 TSS 里记录的 rsp0 自动切换到该进程的内核栈。所以同一个进程在用户态和内核态用的是两套栈这也是为什么内核代码不能随便访问用户栈上的指针——那个地址在当前地址空间里虽然存在但语义上不可信。进程切换不切用户栈只切内核栈指针。用户栈只是被压在页面里由 mm_struct 和页表管理等下次这个进程被调度回来从内核栈恢复、iret 回用户态时rsp 自然就回到用户栈上的正确位置。整个过程用户栈完全不知情。4.2 switch_to 宏里藏的几件事内核的 switch_to 是个宏不同架构实现不同x86-64 上最终落到 __switch_to 这个汇编函数。它做的事情比用户态 swapcontext 多这么几层第一层是寄存器保存范围的扩大。用户态 swapcontext 主要关心 callee-saved 寄存器和栈指针因为它是被 C 函数调用的caller-saved 由调用者负责。内核的 __switch_to 也会保存 callee-saved但入口处需要额外保存一些调用约定之外的寄存器因为调用它的上下文调度器核心对寄存器的假设更复杂。第二层是地址空间的切换。这部分并不完全在 __switch_to 里做而是在 context_switch 函数里通过 switch_mm 完成。切换 mm 本质上就是写 CR3 寄存器把页表基址换掉。这里有个优化点如果切换的两个进程其实是同一个进程的两个线程共享 mmCR3 就不用改省下一次 TLB 相关的开销。所以线程切换通常比进程切换便宜这个结论的来源就在这。第三层是 FPU/SIMD 状态的懒加载。页表的 CR3 切换会带来 TLB 失效而 x87/SSE/AVX 的寄存器是一大块上百字节的状态每次切换都存一次代价太大。内核的做法是标记FPU 状态不属于当前进程等真的用到再通过异常触发保存和恢复。这叫惰性切换代价转移到了真正使用的地方。第四层是 TSS 中 rsp0 的更新。下一个进程从用户态陷入内核时CPU 需要知道用哪个栈这个信息就存在 TSS 的 rsp0 字段里。所以在切换的最后必须把新进程的内核栈顶写进 TSS否则下次中断进来会压错栈直接毁掉数据。4.3 用 vmstat 和 pidstat 观察真实切换理论说完了跑个命令看看实际数字。vmstat 的输出里cs 列就是每秒上下文切换总次数vmstat 1 5输出大概长这样procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 2 0 0 8123456 123456 987654 0 0 12 45 890 2345 12 4 83 1 0r 列是运行队列长度cs 列是上下文切换次数in 列是中断次数。这两个数字要分开看in 高说明中断多可能是 I/O 密集cs 高说明调度频繁可能是等待多或者进程数太多。更细的观察用 pidstat 的 -w 参数它能区分自愿和非自愿切换pidstat -w -p ALL 1 3输出里的 cswch/s 是自愿上下文切换nvcswch/s 是非自愿。一个 CPU 密集型的计算程序如果 nvcswch/s 很高说明它总被抢占通常意味着系统上有更高优先级的任务在抢 CPU或者它自己被 nice 值调低了。反过来一个 I/O 型程序 cswch/s 高是正常的它在等数据。还有一个地方能看到进程维度的切换计数就是 /proc 下的状态文件grep ctxt /proc/pid/status会看到 voluntary_ctxt_switches 和 nonvoluntary_ctxt_switches 两行。这个数字是进程生命周期内的累计值适合做长时间观测但要注意它是累计的得隔一段时间采样两次做差。
返回列表