CPU上下文切换详解:从进程上下文到中断上下文及性能优化
1. 上下文到底是什么CPU视角下的“工作快照”1.1 先把这个概念掰开揉碎很多人在学习 Linux 时迟早会撞上“上下文”这个词。第一次见到它通常是在看进程管理或内核文档的时候然后一脸懵地绕过去了。直到某天排查性能问题发现系统负载不高、CPU也不忙但业务就是卡顿各种排查手段用尽之后有人丢给你一句“是不是上下文切换太高了”你才不得不回头面对这个概念。我个人的理解是上下文Context本质上是 CPU 为了“随时切走、随时切回”某个任务而必须保存的一组状态信息。你可以把它想象成一个厨师在流水线同时做几道菜——他不可能同时把每道菜都做完只能先做这道菜几步然后去处理另一道。但真正的问题是当他从A菜切到B菜时必须记住A菜当时炒到了什么程度、盐放了多少、锅在哪个灶眼上。没有这份记忆切回去就要重新尝咸淡、重新判断火候甚至直接做报废。CPU 也是这样。一个进程在跑的时候用到的寄存器的值、程序计数器指向哪里、函数调用栈长什么样、打开了哪些文件、内存映射是什么样子这些合在一起就是“这道菜做到一半的状态”。把这个状态保存下来再去执行另一个任务回头再恢复这套“保存现场、恢复现场”的动作就叫上下文切换Context Switch。有些资料喜欢把上下文说得特别玄乎实际拆开之后就是上面这件事。而 Linux 里说的上下文因为运行场景不同又分成几类进程上下文、中断上下文以及被反复提起的“上下文切换”。搞清楚这几个词的区别才能真正看懂那些与你纠缠多时的性能问题、驱动问题、内核报错。1.2 容易混淆的三组概念用户态、内核态、中断上下文网上很多帖子把“用户态切换到内核态”也叫上下文切换这容易把人带偏。准确地说系统调用、异常、中断发生时CPU 确实会切换特权级从用户态切到内核态并且会保存用户态的寄存器现场但本质上这不涉及“换一个任务来跑”只是同一个任务的执行态发生了变化所以在内核的语境里通常叫模式切换Mode Switch而不是严格意义上的进程上下文切换。真正的进程上下文切换一定是发生了任务的更替从进程 A 切换到进程 B。这是调度器在背后做的主。而中断上下文又是一个完全不同的维度——中断一旦触发CPU 会暂停当前正在运行的任何任务不管它是用户态还是内核态都会强制转去执行中断处理程序。此时 CPU 所处的环境就是中断上下文它不隶属于任何一个普通进程也基本不受进程调度体系的约束。理解到这层你再去看系统里那些指标会顺很多。vmstat 里看到的 cscontext switch列反映的是每秒进程切换加模式切换的综合统计值不是单纯某一类。后面我会专门展开这一点。2. 进程上下文与真正意义上的进程切换2.1 进程上下文里到底装了些什么我们平时所说的“进程上下文”其实是整个进程在运行期间所有需要的状态信息的集合。它不是一个单独的内核数据结构而是散落在多个地方靠内核统一管理。细拆的话至少包含这几块寄存器现场通用寄存器RAX、RBX、RCX 这类、栈指针 RSP、指令指针 RIP、标志寄存器 RFLAGS。这是切换发生时最先要保存和恢复的部分。内核栈每个进程在内核态运行时都有自己的内核栈通常大小是 16KB 或 8KB。系统调用、中断处理、内核函数调用都会用到它。切换进程时内核栈也必须跟着换否则函数调用栈就串了。地址空间每个进程有自己的页表通过 mm_struct 描述。切换进程时需要把 CR3 寄存器指向新进程的页表这是一件开销很大的事原因后面细说。浮点与 SIMD 状态FPU、SSE、AVX 这些寄存器的状态像 x86 上通过 xsave 系列指令保存和恢复。文件描述符表、信号掩码、线程局部存储TLS等这些虽然不直接在切换瞬间保存但它们是进程上下文的组成部分调度器必须保证新进程在自己的环境里运行。内核里有一个专门负责“换人”的函数叫context_switch它不是魔法核心就两步先切内存通过switch_mm_irqs_off再切寄存器与内核栈通过switch_to。理解了这两步进程上下文切换的主干就抓住了。2.2 系统调用里的“模式切换”为什么便宜得多很多人写代码时有疑问一次read()系统调用到底有多大开销为什么网上有的说微秒级有的说非常慢这其实要看你怎么算。系统调用发生时CPU 通过syscall指令x86_64 上进入内核态硬件会自动切换到内核栈保存用户态的 RIP、RSP 等信息然后执行内核里的系统调用处理函数。这是当前进程在自己的上下文里切换模式不会换页表、不会换进程。整个过程大概是几百纳秒到 1 微秒这个量级现代 CPU 对这条路径已经优化得非常狠了。那为什么很多人强调系统调用开销大因为系统调用后通常跟着实际IO操作IO 才慢。再一个频繁系统调用会导致进入内核、退出内核来回折腾缓存污染效应叠加起来实际影响远超单次指令执行本身。所以很多高性能程序会做批量系统调用、使用io_uring本质就是把多次模式切换合并成一次。2.3 从进程 A 切到进程 BLinux 到底挨个做了什么这是今天最硬核的部分我尽量用“讲人话带源码”的方式拆一遍整个流程。第一步当然是要有触发调度的时机。可能是进程主动让出 CPU比如调用了sched_yield、等待 IO、sleep也可能是定时器中断发现当前进程时间片耗尽或者是被更高优先级的任务抢占。最终都会走进调度器入口schedule()。第二步调用context_switch()。在这个函数里先做switch_mm_irqs_off()除了少数情况比如切换到内核线程它们共享上一任进程的地址空间只是借用大多数时候要把 CR3 切到新进程的页表。这一步是缓存杀手——不同进程的地址空间切换后TLB快表里大部分条目都失效了后续访问内存会频繁走到页表遍历这在大型内存数据库这种场景里是很致命的。第三步调用switch_to()。这个宏展开后会走到架构相关的__switch_to汇编代码。它保存当前进程的通用寄存器这里其实是保存被调用者保存寄存器即 callee-saved registers然后换掉内核栈指针 RSP把它指向下一个进程的内核栈。RSP 一旦切换后面的函数调用链就全部属于新进程了。最后恢复新进程的寄存器返回到它上次被切走的那个位置——也就是未来这个进程被调度回来时从哪儿继续跑。整个过程还有一次隐藏的“栈魔术”因为 RSP 已经换了__switch_to返回后执行的代码就变成了新进程的上下文CPU 感觉好像“什么都没发生过”一样继续运行。这种切换设计得非常精巧是理解内核经典难点“A 切到 B怎么又切回来”的关键。第四步处理 FPU 和 SIMD 状态。x86 上通常用fpu__switch和一个延迟加载机制来避免每次切换都保存全部浮点寄存器。这个细节平常不太会遇到但只要你在写涉及大量浮点运算的程序就能隐约感受到它的存在。从耗时上看一次进程上下文切换本身大约在 1 到 10 微秒看起来不贵但它是乘数效应。一个每秒切 10 万次的系统光切换就要用掉 0.1 到 1 秒的 CPU 时间这个损耗已经非常可观。更何况切换带来的缓存失效、流水线中断实际代价往往是这个数字的几倍。3. 中断上下文处理器被迫“插队”后的运行环境3.1 什么是中断上下文它与进程上下文差在哪现代 CPU 上跑的各类任务本质上都是由事件驱动的。鼠标动一下、网卡收到一个包、磁盘完成一次读写都会给 CPU 发一个中断信号。CPU 收到中断后会暂停手头正在跑的指令保存当前现场然后跳去执行内核事先注册好的中断处理程序。这个“正在执行中断处理程序”的时刻就是中断上下文。中断上下文和进程上下文最大的区别是没有“当前任务”的概念。一个普通进程被切走之前调度器还能找到它的task_struct知道它的状态、优先级、内核栈。而中断处理程序是不归调度器管的它直接抢占 CPU跑完就回到之前被打断的地方。它“不属于”任何进程也没有独立的task_struct更像是一个借用当前进程运行环境的临时过客。很多人看内核打印BUG: sleeping function called from invalid context at ...时会一头雾水搜索半天也不知道自己哪里错了。其实绝大多数情况就是出在中断上下文里做了不该做的操作。3.2 为什么中断上下文里不能睡眠这是驱动开发新手最容易踩的坑。先用一个简单的例子说明为什么绝对不能睡眠假设中断处理程序里调用了mutex_lock而这个锁恰好被别的进程持有那么中断处理程序会阻塞等待。要让它等就必须有调度机制把它挂到等待队列里然后切换出去。但问题是中断处理程序压根不是调度器的“合法公民”它甚至没有一个能代表它的进程实体被调度器感知。真要这么干整个内核的调度逻辑就崩了轻则死锁重则整机挂起。即使让调度器硬着头皮去切换也还有第二个问题中断处理程序在使用当前进程的内核栈。这个进程可能是任意的它不在自己的上下文里板子上的状态也不完整。如果调度器切走了另一个进程回来之后这个进程的内核栈已经被污染了数据自然就乱了。总结起来中断上下文遵守的硬规矩是不能睡眠、不能调用任何可能睡眠的函数比如kmalloc(GFP_KERNEL)也得换成GFP_ATOMIC。不能拿互斥锁最多只能用自旋锁spinlock因为自旋锁的等待方式是忙等而不是睡眠。处理时间要尽可能短。中断处理期间同级别的其他中断会被屏蔽处理时间太长会直接拉高系统延迟。3.3 上半部与下半部把大活拆散干的套路既然中断处理程序要求快而短那网卡一秒钟来几千个包每个包都要几十微秒处理的话系统肯定卡死。所以内核社区早年间就琢磨出一套拆分机制把中断工作分成上半部top half和下半部bottom half。上半部就是真正的硬件中断处理程序它只在中断上下文中执行最紧急的事确认硬件状态、拷贝数据到内存或者用 DMA 提前搞定、把后续工作丢给下半部然后快速返回。它唯一的目标就是“尽快把 CPU 释放出来”。下半部则可以分成好几种从历史的softirq、tasklet到后来的workqueue、threaded irq各有各的使用场景软中断softirq运行在中断上下文准确说是软中断上下文中不能睡眠。它适合处理高频率、短小的网络包处理。内核的软中断机制做得比较底层的活儿比如网络收发、块设备请求处理。tasklet基于软中断实现同一时刻同一个 tasklet 只会跑在一个 CPU 上不要求重入比直接操作软中断简单很多但它同样运行在中断上下文中不能睡眠。workqueue完全不同的思路。它把任务丢给一组内核线程去执行运行在进程上下文里可以睡眠可以拿互斥锁。适合那些相对耗时、不需要极低延迟的收尾工作比如卸载设备之后的清理。选型上没有银弹我的经验是能丢给 workqueue 的就别在中断里硬扛。只有像网卡收包路径那种对延迟极度敏感、频率高到 workqueue 扛不住的场景才值得动用 softirq 去搞。3.4 驱动开发中如何判断当前在哪种上下文写驱动时偶尔需要知道自己现在到底在什么环境里跑。内核提供了几个现成的东西in_interrupt()返回非 0说明正处于中断上下文包括硬中断和软中断。in_softirq()判断是不是在软中断上下文中。current宏正常进程上下文里current指向当前进程的task_struct可以打印调试信息。但严格意义上current在中断上下文里也不是完全没意义——它指向的是被打断的那个进程只是你不能依赖它做任何调度相关的操作。实际开发中最安全的做法不是写好判断逻辑而是在写代码之前就规定好哪些函数只能在进程上下文调用哪些可以在中断上下文调用遵循内核里那些函数名的潜规则。比如带_atomic后缀的函数往往可以在原子上下文使用GFP_KERNEL带不动的时候想想GFP_ATOMIC。这些命名约定比什么判断都可靠。4. 上下文切换的性能代价与调优实操4.1 切换的真正杀伤力不是时间是“清场”前面提过一次进程切换的耗时但业内对“上下文切换的代价”一直有争论原因就在于单看时间开销其实没那么夸张真正的杀伤力在缓存。一个进程如果长期在同一个 CPU 上跑它的热点数据都在 L1/L2 缓存里、相关的页表项都在 TLB 里、分支预测器也在“熟悉”它的行为模式。切换去另一个进程这些全部被打乱。你再切回来的时候缓存几乎全空所有热数据都要从主存重新加载一遍。这个“冷却效应”如果摊到单次切换上可能比切换本身还贵几倍。用一张粗略的表格来对比会直观很多操作大致开销主要代价点函数调用几纳秒栈操作、跳转系统调用模式切换几百纳秒到 1 微秒进入内核、退出内核进程上下文切换1 到 10 微秒寄存器保存、页表切换进程切换缓存全冷数微秒到数十微秒缓存失效、TLB 失效创建/销毁一个进程数十微秒到毫秒级内存分配、初始化所以实际排查性能问题时永远别只看切换本身的时间还要问一句切换后缓存被破坏到了什么程度大量线程频繁切换的系统CPU 明明显示空闲任务却慢如蜗牛多半就是缓存级联失效在作祟。4.2 用四条命令看清系统的切换情况盲目调优是新手最容易犯的错先学会观测再动手这是我这几年总结出的最大心得。Linux 下看上下文切换我常用的有四条路子。第一条是vmstat 1这是最快的大盘检查。关注cs列和in列。cs是每秒上下文切换次数in是每秒中断次数。如果在某个负载下 cs 长期上万甚至更高就要警惕了。第二条是pidstat -w 1它能把切换按进程统计出来输出里cswch/s是自愿切换voluntarynvcswch/s是非自愿切换nonvoluntary。我一般先拿它定位到具体是哪个进程在频繁切换。第三条是直接看/proc/pid/status里的voluntary_ctxt_switches和nonvoluntary_ctxt_switches适合对单个进程做历史值对比看它到底是被谁拖累了。第四条是perf sched record加perf sched timehist这属于内核级的高级观测能按 CPU 维度还原出每个调度事件的时间线。平时用的不多但遇到“多核负载不均”这类疑难杂症时它几乎是一击致命。4.3 让上下文切换“降下来”的五个方向观测到切换过高接下来才是优化。方向上无非这几类第一从线程数量上下手。线程池开得过大大量线程在锁上排队然后被换来换去这是最常见的浪费。线程数量应该和 IO 等待时间、CPU 密集型任务的比例匹配而不是“多多益善”。第二从锁竞争上下手。锁是上下文切换的催化剂。遇到高并发场景先看能不能改成无锁设计比如原子变量、CAS、读写锁分离再看能不能缩小锁粒度或者用futex做更精细的用户态同步。锁的优化往往比单纯调大线程池有效得多。第三从系统调用频率下手。批量处理用户态数据、用readv/writev聚合 IO、考虑io_uring这类异步框架都是在减少模式切换的次数。虽然模式切换不是完整上下文切换但它带来的缓存破坏和内核进入退出开销一样不可忽视。第四从 CPU 亲和性下手。通过sched_setaffinity把一个进程绑定到固定 CPU 上可以避免它反复在不同的 CPU 之间迁移。跨 CPU 迁移意味着缓存整体换血代价极高。第五从中断处理下手。网卡这类高速设备开启中断合并coalescing能显著减少中断次数但会小幅增加延迟。数据库和高性能中间件场景要权衡取舍通常是“延迟敏感就关合并吞吐优先就开合并”。4.4 一个真实风格的排查小案例有个后端服务业务高峰期响应变慢CPU 使用率只有 30%负载也不算高但用户就是感觉卡顿。第一步跑vmstat 1发现cs列飙到 6 万左右in列只有几百说明问题不在中断就在任务切换。第二步用pidstat -w 1定位发现有两个进程组分别是业务主线程和某个日志落盘线程nvcswch/s高得离谱。第三步用perf top一看hotspot 全在futex_wait和futex_wake附近。也就是说这些线程是在锁上抢来抢去抢不到就被挂起抢到了又被唤醒切换全耗在这上面了。定位到原因后处理方案是把日志线程改成批量异步提交同时把业务线程池从 200 缩到 64减少线程间锁竞争。改动上线后cs 降到了 8000 以内响应延迟直接回到正常水平。这个案例想说明的其实就一句话上下文切换高往往只是表层的“果”真正的“因”在锁竞争、线程设计或者 IO 模式上往下挖一层再动手。5. 几个特别容易混淆的问题5.1 voluntary 与 nonvoluntary 的切换到底有什么区别很多资料里把voluntary_ctxt_switches翻译成“自愿切换”把nonvoluntary_ctxt_switches翻译成“非自愿切换”看完仍然是一头雾水。我的理解方式是这样的自愿切换是当前进程主动放弃 CPU 导致的典型场景是等 IO、等锁、主动 sleep。每次read()从磁盘读数据进程都要“睡”一会儿这个入睡动作就是一次自愿切换。非自愿切换是被调度器强制的典型场景是时间片用完了或者被更高优先级的任务抢占。看这两个指标的数值可以粗略判断程序的行为模式一个大量做 IO 的程序voluntary 必然高一个纯 CPU 计算程序如果 nonvoluntary 特别高说明它和别的进程在抢 CPU这时候就要考虑是不是 CPU 核数不够、或者进程被频繁迁移。总之别一看 switching 高就觉得是天大的问题先分清是哪种再对症下药。5.2 中断上下文里用锁这么多讲究是什么套路假设你正在写网卡驱动想用一把锁保护一个共享数据结构。如果你的代码可能运行在中断上下文里就不能随便用 mutex因为它会睡眠。标准做法是用自旋锁进程在等待锁时会原地打转自旋不会睡眠。自旋锁在单核系统上往往直接变成关中断在多核系统上会配合原子指令做忙等。但自旋锁也不是银弹临界区太长其他 CPU 就在那里空转纯粹烧 CPU。所以内核里还有一套“锁 中断”的配合姿势比如spin_lock_irqsave这类变体拿锁之前先保存中断状态再关中断释放时再恢复。为什么要关中断因为如果不关当前 CPU 可能在持锁过程中被中断打断而中断处理程序恰好也想拿同一把锁那就死锁了。这些细节只有真去写驱动的人才会体会到它们有多重要。5.3 协程、线程、进程的上下文切换是一回事吗这三个概念经常被拿出来对比但背后的切换机制完全不同。进程切换是内核里最重的一档要换地址空间、换寄存器、换内核栈代价最高。线程切换比进程切换轻一些因为同一进程下的线程共享地址空间切线程不需要换页表、不需要刷 TLB这是它便宜的关键。协程切换又完全是另一套玩法。协程的调度通常在用户态完成比如 Go 里的 goroutine。它切换时只需要保存少量的寄存器和栈指针不需要进入内核不需要经过调度器所以可以做到极快的切换速度十亿级别协程的梦幻场景才得以实现。但注意协程本质上是在“同一个线程里”轮流执行它没法利用多核除非配合线程池使用。这三者的开销排序大致是协程切换 线程切换 进程切换。理解了这层你再去选技术架构时会更清楚需要高并发 IO协程优先多核并行计算线程配合核数来进程隔离需求再考虑加重型切换的代价。写在内核之后的话我最初啃这块内容时看内核源码看得云里雾里真正让我开窍的是两件事一是自己写了一个简单内核模块故意在中断上下文里尝试睡眠亲眼看到内核打印出刺眼的警告信息再由 panic 或系统卡死倒逼自己回去翻资料二是拿着perf sched的数据把一次真实进程切换的全过程还原出来。从那之后“进程上下文”“中断上下文”“上下文切换”就不再是面试题里的冰冷术语而是能实实在在分析问题的工具。如果你也想真正掌握这块我的建议是别光读文章动手做两件事第一把kernel/sched/core.c里的context_switch函数从头到尾读一遍几十行的规模而已第二找到arch/x86/entry/entry_64.S或者对应架构的__switch_to汇编弄明白那一小段代码是怎么完成“换栈”和“换寄存器”的。读完之后再回来验证本文讲的这些细节你会发现原来大牛们常说的“内功”其实就是把这一层层的底层机制摸透了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →