ARTICLE DETAIL

资讯详情

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

CPU上下文切换详解:进程上下文与中断上下文的核心区别

CPU上下文切换详解:进程上下文与中断上下文的核心区别 1. 到底什么是“上下文”聊到CPU怎么切任务有个词绕不开上下文。但说实话这个词在内核开发者的嘴里和在大模型、前端工程师的嘴里完全不是一回事。你要是在大模型群里说“1M上下文”大家想的是Token窗口在JS群里说“执行上下文”想的是Scope Chain但在内核这儿“上下文”指的是CPU运行一个任务所需要的全部状态。这个状态具体包括什么说白了就是三样东西CPU寄存器里的值程序计数器PC、栈指针SP、通用寄存器、状态寄存器、当前进程的地址空间映射页表、TLB以及内核栈里的状态。你可以把CPU想象成一个流水线上的工人寄存器是工人手里的工具和便签纸页表是仓库的货架布局图内核栈是工人自己的工作台。切换任务就是把工具、便签纸、布局图全部换一套。标题里说的“两种上下文”内核圈默认的分法是进程上下文和中断上下文。进程上下文是任务被调度走之前保存的完整运行环境里面包含用户态和内核态两部分的寄存器快照、内存映射、文件描述符等中断上下文则是CPU在响应硬中断、软中断或异常时临时保存的一套精简环境。两者的核心区别在于进程上下文可以睡眠、可以被抢占、可以触发调度中断上下文不能睡眠、不能调用可能阻塞的函数、也不能随便触发调度。这个区别是所有内核新手的一道坎。我见过不少人把“中断上下文”和“内核态”混为一谈其实内核态函数大多数跑在进程上下文里只有中断处理的那一小段才真正算中断上下文。理解了这一点后面看调度器、看锁的实现、看中断下半部机制都会顺畅很多。2. 切换的原理保存现场恢复现场2.1 上下文切换到底在换什么日常说的上下文切换Context Switch指的是CPU从一个进程切到另一个进程。这个过程可以拆成四个动作保存当前进程的CPU寄存器到它的内核栈和task_struct里切换到新进程的内核栈切换到新进程的地址空间也就是更新页表基地址寄存器同时使TLB失效最后把新进程之前存的寄存器恢复出来让CPU接着跑。其中寄存器保存和恢复是最直观的部分。x86_64架构下callee-saved寄存器RBX、RBP、R12-R15必须由内核自己保存因为编译器生成的代码默认这些寄存器跨函数调用不变caller-saved寄存器RAX、RCX、RDX、RSI、RDI等则可以顺其自然。你看内核源码里switch_to那个宏本质上就是把这些寄存器压栈、换栈、再出栈。地址空间切换也不是说换就换。Linux用的是虚拟内存每个进程有自己的页表CR3寄存器指向当前页表物理地址。进程切走时CR3不用管等切回来时要把CR3指回原页表并清掉TLB里所有属于旧进程的缓存条目。这里面有个常见的优化叫ASIDAddress Space Identifier给每个进程分配编号TLB条目带上ASID标记这样切换时不用清空TLB代价是硬件要支持ASID机制ARM和x86的PCID都支持。2.2 模式切换和上下文切换不要搞混一个特别常见的误区是把模式切换当成上下文切换。系统调用、缺页异常、中断触发的时候CPU会从用户态跳进内核态这叫模式切换Mode Switch它也要切换栈、保存一部分寄存器但它不换进程地址空间完全不变也不涉及调度。真正的上下文切换只发生在调度器决定让出CPU、挑下一个进程跑的时候。那什么场景会触发上下文切换常规的有进程主动sleep、阻塞在IO上、等待锁、时间片耗尽被抢占、被高优先级任务抢跑、以及中断处理完后发现该让更高优先级任务先跑。换句话说系统调用本身不产生上下文切换但系统调用里涉及阻塞等待那就很可能引发切换。这也是为什么非阻塞IO和IO多路复用能省下大量切换开销——减少的是频繁阻塞导致的切换次数而不是系统调用本身的成本。2.3 切换是昂贵的贵在哪一次上下文切换的典型开销在微秒量级听着不大但在每秒几万次切换的高并发服务里就是巨大的浪费。除了寄存器搬移本身的CPU周期有两个隐性成本容易被忽略。第一个是Cache失效。进程切走再切回来L1/L2里的指令和数据可能已经别别人冲掉了L3里的热数据也未必还在再加上TLB可能被清空下一次访存要重新遍历页表。实测经验里上下文切换后的首次访问延迟可能比切换前大一个数量级这种“冷启动损失”往往比寄存器保存恢复还要贵。第二个是内核栈的切换。每个进程在内核态有自己的栈一般是16KB。切换进程就要切换内核栈栈里保存的调用链、局部变量全部作废。这也是为什么锁竞争极其消耗性能——两个线程抢同一把自旋锁抢不到的那个反复让出CPU让出时上下文切换一次抢回来又切换一次一来一回把性能吃得干干净净。3. 从Linux源码看两种上下文切换3.1 先认识几个关键数据结构看内核代码得先知道状态存哪。每个进程的task_struct里有一个thread字段类型是struct thread_structx86下它保存的就是硬件上下文。里面有sp内核栈指针、ip指令指针、fs、gs等段寄存器还有debug寄存器和浮点寄存器状态。注意这里存的不是通用寄存器组那些在切栈时就已经压到栈上了thread_struct只需要记栈的顶端在哪就行。每个进程还有一个内核栈。x86_64下内核栈大小默认16KB栈底高地址端嵌着struct thread_info里面放着flag、status、cpu这些调度相关的元数据。内核栈低地址端是真正的栈空间。每次从用户态陷入内核态CPU自动切到当前进程的内核栈每次切换进程内核栈也跟着换。你要是看了不少汇编级的上下文切换代码会发现整个切换过程就是围绕“换栈”展开的。内存映射则是另一个维度。进程的mm_struct里记录着页表、各区域的VMA但上下文切换时内核并不会搬这些数据只换页表基址让CPU看到的新地址空间变成目标进程的。切换页表的活叫switch_mm_irqs_off最核心的操作就是更新CR3或读TTBR0_EL1。3.2 进程上下文切换的完整路径从调度入口看进程切换的路径大致是schedule() - __schedule() - context_switch() - switch_mm_irqs_off() // 切换地址空间 - switch_to() // 切换内核栈和寄存器 - __switch_to_asm() // 汇编级寄存器保存恢复 - __switch_to() // 加载线程状态、FPU状态等其中switch_to是架构相关宏x86上会展开成一段内嵌汇编核心逻辑是把当前进程的ESP内核栈指针存到old_thread-sp把new_thread-sp加载到ESP再把PC推进到__switch_to返回后的地址。这个“推进PC”的戏法很有意思它保证了两个进程在各自的内核栈上从同一个位置继续执行但current宏得到的进程已经变了。还有一点值得注意汇编层面保存寄存器时用的是push/pop指令而保存的位置就是当前进程的内核栈。所以内核栈既承担了系统调用、中断的临时栈也承担了上下文切换时的寄存器保存区一层一层叠着栈的大小要算好溢出会触发栈保护。3.3 中断上下文切换的特殊性中断到来时CPU不会走调度器路径而是走中断入口。x86_64上硬件会先把SS、RSP、RFLAGS、CS、RIP这五个寄存器压到当前栈也就是当前进程的内核栈然后跳进内核的中断处理入口。入口处的汇编代码会再压一套通用寄存器形成一个pt_regs结构这就是完整的中断现场。于是中断切换的本质就清楚了它不一定切换进程只是切换了“执行现场”。中断处理期间当前进程还是当前进程CPU只是临时让位给中断处理代码。中断处理完iret指令把之前压栈的寄存器全弹回去CPU恢复成中断前的模样继续跑。但中断上下文里有个隐藏的坑CPU在中断入口处自动压栈依赖于当前栈指针TSS里的RSP0而RSP0指向的是当前进程的内核栈。这意味着中断处理会借用当前进程的内核栈——这在内核代码里叫“中断栈借用”实际内核还会把中断处理切到专门的per-CPU中断栈上x86的irq_stack防止深嵌套中断把进程内核栈挤爆。3.4 中断上下文为什么不能睡眠理解了中断上下文的保存方式就能明白为什么它不能睡眠。操作系统睡眠动作的本质是把当前进程挂到等待队列调用调度器选下一个进程。调度器需要能够拿到当前进程的task_struct、修改它的状态、放到可运行队列。但中断处理时current宏指向的是被打断的任意进程拿它当“当前任务”去睡眠语义上完全错位。更麻烦的是中断上下文里没有进程地址空间的保证。中断处理虽然在当前进程的内核栈上跑但它访问用户空间地址是危险的因为页表可能是任何进程的加上中断可能发生在这个进程被切走之后旧栈刚换掉此时连栈都不一定靠谱。所以内核给中断上下文立了规矩不能睡眠、不能调用might_sleep相关的函数、不能拿普通互斥锁mutex只能用自旋锁。4. 怎么用数据观察上下文切换4.1 看vmstat的cs和in最直接的观测工具是vmstat。vmstat 1每秒输出一行其中cs列是每秒上下文切换次数in列是每秒中断次数。$ vmstat 1 procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 4 0 0 526400 33340 2018840 0 0 0 0 2452 32541 12 88 0 0 0cs到了几万基本能说明系统在调度上消耗了大量CPU。但你得结合第二列r运行队列长度一起看如果r一直大于核数说明任务多到排队切换多是正常现象如果r很小但cs高就得怀疑是不是锁竞争或者频繁的主动让出。4.2 细粒度盯每个进程想看到具体哪个进程在频繁切换用pidstat$ pidstat -w 1 Linux 5.15.0 (node01) 08/12/2024 _x86_64_ (16 CPU) 10:00:01 UID PID cswch/s nvcswch/s Command 10:00:02 0 1234 12.00 0.00 nginx 10:00:02 0 5678 320.00 180.00 javacswch/s是自愿切换主动让出CPU比如等IO、等锁、睡眠nvcswch/s是非自愿切换时间片耗尽被抢占或者被高优先级任务抢走。Java进程这种两个数值都极高的十有八九是线程数量多、锁竞争激烈。还有些更细的视角watch -d cat /proc/PID/status可以看到voluntary_ctxt_switches和nonvoluntary_ctxt_switches这两个累计值配合前后差值可以精确定位抖动进程。4.3 一次真实排查记录有次线上服务访问延迟抖得厉害CPU负载不高但业务RT就是不行。第一把看vmstatcs高达每秒8万而机器是32核的相当于每核每秒2500次切换确实偏高。接着用pidstat盯发现是某个NIO线程频繁出现nvcswch数值波动特别剧烈。再往下排查是线程池配了核心数和最大数不一致任务高峰时疯狂创建新线程新线程一多就互相抢CPU导致大量非自愿切换。最后把线程池调成固定大小配合适当的队列容量cs立刻降到2万以内RT抖动也消失了。这个案例提醒我观察切换数值要和业务场景结合不要一刀切地认为“切换越少越好”有些切换是为了让出CPU给更急的任务是有价值的真正要警惕的是无意义的频繁切换。4.4 面向切换开销的性能优化思路既然切换成本高优化方向无非两个减少切换次数、降低单次切换成本。前者常见手段是合并IO、批量处理、合理设置线程池大小、使用协程降低线程级切换后者包括绑定CPU核affinity让进程尽量跑在一个核上提高cache命中率、使用大页减少TLB miss、开启PCID减少TLB全清等。另外一个容易忽略的点是在处理网络IO时epoll本身不产生切换但是每个连接的数据到达、发送、关闭都可能触发调度。如果连接数几千、QPS几万切换次数轻松上万这种场景下用io_uring或RDMA这类新机制能显著摊薄单次IO的调度成本。5. 常见困惑与避坑经验5.1 中断上下文和软中断有什么不同硬中断上下文要求极短内核不能在里面磨蹭所以把耗时工作推迟到软中断softirq和底半部tasklet、workqueue里做。注意软中断虽然也叫“中断上下文”但它可以被打断可以被硬中断抢占某些情况下还能被调度器插一脚。workqueue更特殊它是在内核线程的进程上下文里执行的可以睡眠——虽然它也由内核触发不来自某个用户进程。写驱动时别把workqueue当成中断上下文否则会在加锁上踩坑。5.2 内核态不等于中断上下文很多新手在内核模块里调用printk、mutex_lock都很顺手就觉得内核态随便写。实际上内核模块里的代码绝大多数跑在系统调用的进程上下文里拿锁、睡眠都合法。但一旦进了中断处理函数或者注册了timer回调代码就变成中断上下文了此时连printk都可能因为锁冲突而出问题。判断当前是不是中断上下文用in_interrupt()或in_irq()宏调试的时候多打一打能少掉好多头发。5.3 大模型的1M上下文和内核上下文没关系热词里那堆“上下文”容易让人眼花大模型的1M上下文是模型能看到的Token长度JS执行上下文是JavaScript引擎的调用栈和作用域链上下文工程是提示词设计的方法论——这些和CPU的上下文切换是完全不同的两个世界。接触到的项目如果混着聊这些概念先分辨清楚对方说的是哪个“上下文”。尤其面试时被问到“上下文切换”不要扯到Token窗口上去那是两个截然不同的知识体系。5.4 切换不一定都是坏事最后分享一个观念层面的坑不要看到cs高就急着调优。上下文切换是操作系统完成多任务并发的必备手段把时间片切给等待IO的任务、让高优先级任务抢跑这些都是正面价值。真正的问题是无意义的切换、被锁拖着反复空转的切换。观测切换数据时要同时看任务的到达率、CPU利用率、锁竞争指标找到切换的“病因”而不是对着切换计数本身做手术。我自己实际排查过不少性能问题最深刻的体会是上下文切换就像系统的呼吸呼吸急促往往是身体有问题但治病不能盯着呼吸本身得找到心肺的症结。理解了两类上下文的原理和观测手段再去审视调度、锁、中断这些内核机制整个系统的运行图景会清晰很多。遇到卡顿和毛刺先看数据、再查语义、最后改参数这个顺序比凭感觉调优可靠得多。
返回列表