ARTICLE DETAIL

资讯详情

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

进程切换全解析:从上下文切换到内核实现与性能优化

进程切换全解析:从上下文切换到内核实现与性能优化 从“3.4 进程切换”这个章节标题说起。我当年学操作系统的时候进程切换这一节是最让我犯晕的地方之一。教材上往往就是一两页纸画一个 PCB 状态转移图然后告诉你“进程切换需要保存上下文、恢复上下文”听起来很简单但一到上机实验或者读内核代码就完全对不上号了。后来工作中实际遇到性能问题、排查线上服务卡顿我才发现很多问题的根子恰恰就藏在这几个字的细节里。这篇文章我就把“进程切换”从头到尾拆开聊透不绕弯子直接讲清楚它到底做了什么、是怎么做的、有哪些坑以及为什么你写的程序会因此变慢或者在某些极端场景下会“卡”得让人抓狂。1. 进程切换到底在干什么1.1 从“同时运行”的错觉说起你打开电脑一边写代码、一边听歌、一边挂着浏览器感觉这些程序是同时在跑的。但真实情况是对于单核 CPU 而言同一个时刻只能执行一条指令。之所以你感觉不到“排队”是因为操作系统把 CPU 时间切成了很小很小的片比如每个进程分到几毫秒到几十毫秒然后轮流执行。这个“轮流”的动作就是进程切换。每次切换都意味着 CPU 要放弃当前正在运行的进程转去运行另一个进程。这件事听起来像是“换个程序跑”那么简单但仔细想想就会发现问题当前进程可能正算到一半寄存器里存着中间结果、栈里压着一堆局部变量、内存里还有它打开的文件和分配的资源。如果就这么不管三七二十一地切走等它再回来时早就不知道之前算到哪儿了程序直接乱套。所以进程切换的核心任务可以概括成一句话把当前进程的运行现场完整保存下来再把目标进程的运行现场完整恢复上去让 CPU 像“从未离开过”一样继续执行。1.2 进程切换的触发场景不是你想切就能切的进程切换有一套明确的触发机制大体上分成三类进程主动让出 CPU比如执行了 sleep、等待 I/O、或者调用了 yield 之类的系统调用这属于“主动让路”。时间片耗尽CPU 的定时器发出时钟中断操作系统发现当前进程跑得太久了于是强制把它换下去这属于“被动剥夺”。有更高优先级的进程变得可运行比如用户敲了一下键盘、网络数据包到了等待中的高优先级进程被唤醒调度器决定“先办急事”于是把当前进程挤下去。不管是哪一种最终都会走到同一个入口调用调度器选出一个新的进程然后执行上下文切换。1.3 你需要理解的真正难点上下文是什么“上下文”这个词在进程切换里是核心中的核心但很多初学者对它没概念。你可以把上下文理解成进程在 CPU 上的“全部记忆”。具体包括通用寄存器比如 rax、rbx、rcx、rsp、rbp 这些保存着当前计算过程的中间值。程序计数器 PC也就是下一条要执行的指令地址。没有它恢复后不知道从哪儿继续。程序状态字 PSW保存着条件码、中断开关状态等 CPU 标志位。栈指针因为每个进程有自己的内核栈和用户栈切过去之后连栈都要换成另一套。内存管理信息比如页表基地址换成目标进程的地址空间CPU 的 TLB快表缓存也得跟着失效。这些信息全部要打包保存好放在这个进程自己的 PCB进程控制块里。PCB 相当于进程的“档案袋”操作系统就是靠它来认识和恢复一个进程的。提示我见过不少初学 Linux 的同学以为进程切换就是把 PCB 里的字段复制一遍。实际远比这个复杂因为 CPU 的寄存器是硬件资源只能靠软件去“存”和“取”而具体怎么做跟 CPU 架构、操作系统内核实现都有关系后面细讲。2. 进程切换的完整链路拆解2.1 一次系统调用引发的“半程切换”要理解完全的进程切换最好先看一次系统调用。比如你的程序调用read()去读一个文件这会陷入内核态。陷入内核态时CPU 会自动把用户态的寄存器、PC、栈指针保存到当前进程的内核栈上然后切换到内核态去执行系统调用逻辑。但注意这次陷入内核态之后如果文件数据还没准备好进程就会在内核里进入睡眠状态。此时会触发真正的进程切换把当前进程从运行队列移到等待队列然后从运行队列里选一个其他进程把之前保存在内核栈上的“那个进程的现场”换成“另一个进程的现场”这样 CPU 才能真正去跑另一个进程。所以你需要分清楚两个概念模式切换用户态和内核态之间的切换比如系统调用、中断触发这只是同一个进程内部的切换不改变运行进程。进程切换从一个进程切换到另一个进程才需要保存和恢复不同的上下文是真正的“换人”。很多初学者把这两者混为一谈一看到“陷入内核”就以为发生了进程切换。不是的如果一个进程发起系统调用后数据很快返回它可能全程不切换只是模式变了。2.2 内核栈进程切换最关键的那块内存我在上面提到了内核栈这里必须单独说清楚因为它是理解进程切换的一把钥匙。每个进程在内核态运行时会有一个独立的内核栈。当发生中断、系统调用时CPU 会把用户态的现场压入这个栈中当进行进程切换时内核代码也要用这个栈来保存更完整的上下文。所以你可以把内核栈理解成“进程在内核中的临时工作台”。更关键的是Linux 中进程的task_struct结构体里有一个thread字段里面保存的就是进程切换时需要保存和恢复的 CPU 上下文比如 SP、PC、寄存器组。当进程被切走时内核把这些内容保存在当前进程的内核栈或 thread 结构中当进程被切回来时内核再从这些地方恢复。这里有一个我觉得很巧妙的设计进程切换的最后一步往往是把目标进程之前保存的栈指针加载到 CPU 的 rsp 寄存器里。因为栈指针一变后续的返回操作就会从目标进程的内核栈上弹出它之前保存的地址CPU 自然就继续执行目标进程剩下的代码了。整个过程像不像“换了一本剧本继续演”对吧就是这么个道理。2.3 调度器决定切给谁进程切换不是随机的背后是调度器在起作用。Linux 的 CFS完全公平调度器是所有调度算法里比较有代表性的一个它维护着一个红黑树树上的每个节点代表一个可运行的进程键值是进程的虚拟运行时间。虚拟运行时间越短说明该进程被 CPU 照顾得越少于是它越靠左越容易被选中。调度器每次选“最左边的节点”把它作为下一个运行的进程。随着进程运行它的虚拟运行时间不断增长在树上的位置不断右移别的进程就有机会上位。这个设计非常聪明它不追求绝对的“平均”而是用一种动态平衡的方式让每个进程都能获得相对公平的 CPU 时间。响应延迟也被控制得很好。但调度器只是负责“选人”真正的“换人动作”还是得靠底层的上下文切换函数。调度器选完人之后就会调用context_switch()之类的函数进入切换流程。2.4 上下文切换的底层动作分解这里我把进程切换的底层动作拆成六步这样你在脑子里可以建立一幅图保存当前进程的 CPU 现场包括通用寄存器、PC、栈指针等写入当前进程的内核栈或 PCB。更新当前进程的 PCB 状态将其标注为“非运行态”可能就绪、睡眠、僵死等。将当前进程移入相应的队列比如等待队列或者就绪队列。调度器从就绪队列中选定下一个要运行的进程并取出它的 PCB。更新目标进程的 PCB 状态为“运行态”。恢复目标进程的 CPU 现场加载其寄存器、页表等然后 CPU 交出控制权开始执行目标进程。这六步里第 1 步和第 6 步是性能开销的主要来源而且它们不是软件想快就快的因为寄存器保存、内存屏障、TLB 刷新这些动作都受硬件限制。注意步骤 5 和步骤 6 之间通常还会涉及地址空间的切换也就是切换页表。如果两个进程属于同一个进程组或者共享内存区域页表切换的代价会小很多否则TLB 基本全废下一次内存访问会到处缺页性能影响很直接。3. 实操视角在 Linux 下观察进程切换3.1 用 vmstat 看 CPU 上下文切换的次数先别急着读内核代码我们可以从系统层面直接观察进程切换发生得有多频繁。打开终端执行vmstat 1输出的cs列context switch就是每秒上下文切换的次数。我自己的服务器上空闲时这个数字大概在几百到一两千如果跑着编译任务或者数据库轻松上万。这个数字大不大要看场景如果系统负载正常cs 高说明进程交互频繁这本身不是坏事。如果 cs 异常高同时wa或sy也很高CPU 大量时间耗在切换上而不是干活那就有问题了。3.2 用 pidstat 盯住单个进程如果你想看某个具体进程的切换情况可以用 pidstatpidstat -w -p 进程PID 1它会显示这个进程每秒的 voluntary context switches主动切换比如等待 I/O和 nonvoluntary context switches被动切换比如时间片用完被抢。如果 nonvoluntary 很高说明这个进程优先级不高或者时间片设置不合理频繁被别的进程挤下来。这类工具能帮你把“进程切换”从抽象概念变成可量化的数据排查性能问题时非常有用。3.3 内核函数追踪看一眼真正的切换函数如果你装了内核调试符号可以用 ftrace 或者 perf 去看真正触发切换的函数路径。一个典型的切换过程大概长这样schedule() - __schedule() - context_switch() - switch_mm_irqs_off() // 切换地址空间页表 - switch_to() // 切换寄存器状态和栈switch_to这个函数是真正干活的地方。在 x86 架构上它会把当前进程的栈指针保存到旧进程的thread.sp再把新进程的thread.sp加载到 CPU 的 rsp 寄存器。这样做完之后CPU 后续的压栈、弹栈操作就都发生在目标进程的内核栈上了新的进程就这么被“接住”了。另一个有意思的地方是switch_to的实现还巧妙地处理了“返回时怎么知道是哪个进程回来了”的问题。它利用了宏在切换前记录当前进程切换回来后再获取当前进程确保代码逻辑不会混乱。4. 进程切换的高昂代价与性能陷阱4.1 切换一次到底有多贵业界常引用的数据是一次进程上下文切换大约在微秒量级比如 2~5 微秒。这个数字看着很小但要注意两点每次切换CPU 的流水线会被清空很多预取好的指令就白干了。如果切了地址空间TLB 也会失效后续内存访问会频繁查页表变慢。所以一个进程如果频繁被切换它的实际计算效率会大打折扣。打个比方你写报表时如果每隔两分钟就有人让你去处理别的事而且每次回来还要重新翻资料、回忆刚才算到哪那你干活的效率一定很低。进程也是同样的道理。4.2 线程切换为什么通常更便宜同一个进程里的线程切换由于共享地址空间页表不用切换TLB 失效的问题就小得多。所以多线程模型的上下文切换开销通常远小于多进程。这也是高性能服务器普遍选择多线程或者事件驱动模型比如 epoll 单线程的原因之一。但注意我这里说的是“通常”。如果线程跑在不同的 CPU 核心上还是有缓存迁移、NUMA 访问等额外开销不能一概而论。4.3 性能陷阱切换风暴有一种很坑的场景叫“切换风暴”就是系统负载不高但上下文切换次数爆炸CPU 时间全耗在切换上。常见原因包括线程数量开得太多每个线程都在抢 CPU。大量线程依赖锁或者条件变量导致频繁睡眠和唤醒。定时器太多太密导致 CPU 不停被中断打断。遇到这种情况性能往往断崖式下跌因为 CPU 什么正事都没干光在“换人”了。解决思路通常是减少线程数、使用无锁数据结构、合并定时器、用 I/O 多路复用替代阻塞模型。我曾踩过一个大坑一个服务开了 200 多个线程每个线程只处理一个非常轻量的定时任务结果线上 CPU 占用 80% 以上vmstat 里 cs 列疯狂跳动。后来把所有线程改成一个时间轮线程CPU 一下子降到 5%。原因很简单——大量的线程在不停的睡眠、唤醒、切换光这些开销就把资源吃光了。5. 那些容易踩坑的细节从概念到实战5.1 进程切换与中断返回不要混为一谈中断返回时CPU 可能会决定切换进程也可能不切换。如果中断处理完之后发现当前进程还有时间片剩余那就直接返回用户态不切换如果当前进程已经耗尽时间片或者有更高优先级进程被唤醒那就会顺便做一次进程切换。所以“中断返回”和“进程切换”之间是交叉关系不是等价关系。5.2 用户态与内核态切换被误当作进程切换我前面已经提到系统调用不是每一次都会导致进程切换。但反过来如果你在用户态写了一个死循环操作系统怎么办单核下它会用时钟中断强制剥夺控制权然后进入内核触发进程切换。所以即使进程“没有主动做过任何系统调用”进程切换也照样会发生。5.3 锁竞争导致的切换开销多线程程序里锁竞争是隐蔽的切换来源。线程 A 持有锁线程 B 尝试拿锁失败于是睡眠线程 A 释放锁后线程 B 被唤醒。这一睡一醒之间往往是两次上下文切换的开销。锁竞争越激烈线程切换越频繁程序越慢。这个问题的优化方向要么减少临界区的长度要么用自旋锁代替互斥锁在临界区非常短的时候要么干脆把数据分片让每个线程只处理自己的那份从根上避免竞争。5.4 看性能数据时不要只看 CPU 负载以前我排查问题只看 load average发现负载不高就觉得系统没问题。后来才意识到load average 只反映“可运行队列长度”的某种平均并不直接体现上下文切换的开销。你还需要结合 vmstat 的 cs、pidstat 的 cswch/s 一起看定位才准确。6. 常见问题速查与排查思路下面这张表总结了我在学习和工作中遇到的高频问题如果你也卡住了可以直接翻到这里对号入座。现象可能原因排查/解决方向vmstat 中 cs 很高但 CPU 空闲系统反应慢存在大量短生命周期进程或线程频繁切换查看进程线程数考虑线程池化或事件驱动模型多线程程序性能不随核数增加而提升锁竞争激烈线程频繁睡眠唤醒使用性能分析工具锁等待减少临界区或改用无锁结构单核机器上运行 I/O 密集程序CPU 占用率过高进程频繁等待 I/O唤醒/睡眠开销大用异步 I/O 或 epoll 替代阻塞 I/O实时性要求高的应用偶尔“卡顿”高优先级进程抢占或中断频繁绑核、调整进程优先级、合并中断进程切换后程序出现偶发数据错乱上下文保存/恢复不完整或共享资源未加锁检查寄存器保存逻辑检查线程同步机制关于绑核这点我再多说一句。如果进程经常在两个 CPU 核心之间迁移缓存命中率会明显下降每次都要重新加载一大堆数据。通过sched_setaffinity把关键进程绑在固定核心上有时能带来立竿见影的提升。7. 一些更底层的思考为什么切换必须做得这么小心深入到汇编层面你才会真正理解为什么进程切换是操作系统中“最容易出错又最不能出错”的地方之一。寄存器保存顺序不能乱栈指针切换的时机必须精确否则一旦返回到错误的地址整个系统就崩了。x86 架构下中断或系统调用发生时CPU 硬件会自动完成一部分现场保存剩下的由操作系统代码接管。硬件和软件之间的这种“分工”是几十年来不断磨合的结果任何一环出错都会导致不可预知的后果。所以如果你要深入源码我建议重点关注__schedule()和switch_to()这两个函数。前者藏着调度逻辑的全部秘密后者藏着上下文切换的全部细节。看懂它们再回头读任何操作系统教材的“进程切换”章节都会觉得那只是提纲真正的血肉都在代码里。我在看源码过程中还有个心得Linux 内核的注释有时候不会把每一步写得特别细你必须结合体系结构手册和汇编代码一起读。比如 x86 的调用约定里哪些寄存器由调用者保存、哪些由被调用者保存这些如果不清楚看switch_to的汇编代码就会一头雾水。8. 最后再分享一个小技巧如果你想快速验证“进程切换开销到底有多大”可以做一个简单的实验写两个程序一个单线程循环算数学题一个开 200 个线程每个线程只做极轻量的工作然后在同样的 CPU 下跑同样的计算量对比耗时。我担保第二个程序会慢得让你吃惊。这个实验比任何讲解都直观能让你切身体会到进程切换的代价。整个过程读下来你会发现进程切换这个概念表面上只有四个字背后却串联着硬件中断、内核栈、调度算法、寄存器、缓存一致性这么多环节。搞懂它你不只是在学一章操作系统更是在理解“现代计算机如何管理自己最重要的资源——CPU”。希望这篇能帮你把那层窗户纸捅破。
返回列表