☰
CPU上下文切换详解:从进程上下文到中断上下文及性能优化
2026/10/1 17:11:48 网站建设 项目流程

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 里看到的 cs(context 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 切到进程 B,Linux 到底挨个做了什么

这是今天最硬核的部分,我尽量用“讲人话+带源码”的方式拆一遍整个流程。

第一步,当然是要有触发调度的时机。可能是进程主动让出 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是自愿切换(voluntary),nvcswch/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汇编,弄明白那一小段代码是怎么完成“换栈”和“换寄存器”的。读完之后再回来验证本文讲的这些细节,你会发现原来大牛们常说的“内功”,其实就是把这一层层的底层机制摸透了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询