
上周值班遇到的一个现场我现在还记得很清楚凌晨两点线上服务的延迟曲线突然从几十毫秒窜到几秒CPU 使用率却只有 30%。一开始怀疑磁盘iostat 干干净净怀疑锁gdb 拍出来的栈却都很正常。最后打开 vmstat 看了一眼那一列 cscontext switch已经飙到每秒十几万问题立刻清楚了——不是磁盘也不是锁是进程切换本身把系统拖垮了。很多人谈性能优化第一反应是 CPU 用量、内存占用却常常忽略进程切换这个“隐形税”。它不像磁盘 I/O 那样一眼能看出来也不像内存泄漏那样有明确的增长曲线但它会在你最不经意的时候吃掉大量吞吐而且越是多线程、多进程的应用越容易栽在这里。这篇文章就围绕 Linux 进程切换展开内核到底切换了什么、什么时候切换、怎么用命令观测、遇到了高切换怎么调优以及面试里那些高频考点。适合自己做后端服务、搞嵌入式、做系统运维或者正在准备 Linux 相关面试的朋友我尽量不写成内核源码逐行分析而是以能落地的排障思路为主。1. 进程切换到底在“切”什么1.1 一个朴素的问题CPU 凭什么记得你的程序先做个类比。你在图书馆占了一张桌子桌上摊着三本书电脑开着草稿纸写到一半。突然有个人要用这张桌子管理员过来把你的东西全收走等他用完再把你原来的书、草稿、电脑屏幕恢复原样放回。这个“收走再复原”的动作对应到 CPU 上就是进程切换。CPU 本身没有记忆它只有一组硬件状态程序计数器RIP、通用寄存器、栈指针RSP、栈基址RBP、标志寄存器还有浮点与 SIMD 相关的寄存器状态。当你写的代码正在执行RIP 指向下一条指令寄存器里放着中间计算结果RSP 指向当前函数栈。一旦切换到另一个进程这些值如果不清空、不保存回来的时候程序早就跑飞了。所以 Linux 在进程描述符 task_struct 里保存了一部分软件状态另外还有一套架构相关的 thread_struct专门存放切换时需要恢复的 CPU 现场。真正执行切换时内核会把当前进程的寄存器组、指令指针、栈指针等压入当前进程的内核栈然后从下一个进程的内核栈里弹出这些东西。这一进一出就是进程切换的物理本质。用过嵌入式板子或者了解过 RTOS 的朋友应该秒懂RTOS 里叫任务上下文Linux 里叫进程上下文背后的概念完全一致区别只是 Linux 的调度器更复杂、要处理的场景更多。1.2 什么时候会发生进程切换四种典型触发点不是内核想切就切真正触发进程切换的场景梳理下来主要是四种。第一种是进程主动让出 CPU。调用了 sleep、等待锁、等待管道或网络数据、发起阻塞 I/O这些操作都会让进程状态变成睡眠态或阻塞态随后调度器必须选择一个新进程放进 CPU。这类切换也叫主动切换。第二种是时间片耗尽。每个可运行进程都有运行时长限制CFS 调度器用一个叫虚拟运行时间的指标来记账当一个进程运行得足够久调度器会把它拉下来换另一个饥饿已久的进程上去。大家都熟悉的“公平调度”其实就是这个逻辑你分到的时间片用完了就得让位。第三种是更高优先级的进程被唤醒并抢占 CPU。实时进程、优先级更高的线程被唤醒或者另一个 CPU 上调过来的任务迁移过来都可能触发抢占。这里要区分一个概念如果新任务发现自己能抢占它会标记当前任务的 TIF_NEED_RESCHED等内核在某个安全点检查到这个标志再做切换。真正抢下来的动作叫“强制切换”对应 /proc/ /status 里的 nonvoluntary_ctxt_switches。第四种是中断或异常返回路径上的调度。中断发生的时候CPU 会临时跳到内核处理中断处理完本来应该回到原进程继续跑但如果中断期间唤醒了一个高优先级任务并且内核开启了抢占返回路径上就会顺手做一次调度。这也是为什么你看到的 ctx switch 数量往往和中断频率相关。面试的时候经常有人把“用户态到内核态切换”和“进程切换”混为一谈。系统调用、中断触发的只是 CPU 特权级切换当前执行的任务并没有变顶多算内核模式切换只有任务被替换掉了才叫进程切换/任务切换。搞清楚这两者的区别很多问题都能少绕弯。1.3 与进程间通信的关系为什么 IPC 会额外引出切换那热搜词里总出现 linux 进程间通信和这里有什么关系关系其实很大。管道、Socket、消息队列这些通信方式本质上是让一个进程等待另一个进程的数据。比如 A 进程往管道里写数据B 进程阻塞在 read 上等待。写入方唤醒 BB 被调度执行等 A 下次再写又可能因为 B 没及时读完而阻塞。一来一回每次 IPC 都可能附赠两次甚至多次进程切换。曾经有团队做性能测试两个进程用管道做高频消息转发消息很小延迟却压不下去。用系统工具一查每秒上下文切换十几万几乎每个消息都伴随一次唤醒和切换。后来改成共享内存加自旋锁切换骤降吞吐立刻翻倍。这不是说管道不行而是做架构设计的时候得意识到跨进程通信的代价里除了数据复制还有调度和切换成本。这茬后面还会在优化部分再展开。2. 内核的切换现场从 task_struct 到 switch_to2.1 调度器谁来决定下一个执行者进程切换的前半场是“换谁上”。这部分由调度器完成调度器维护着每个 CPU 上的运行队列也就是常说的 runqueue。CFS 是大家最熟悉的设计在较新的内核里社区又刷了 EEVDF 替代 CFS但核心思想还是那一套给每个任务维护虚拟运行时间按权重比例公平分配 CPU 带宽。好比你分蛋糕权重高的人分到的大块但它也消耗得多等虚拟时间追上来了就得让位。这里提一个容易忽略的点Linux 的运行队列可能是每个 CPU 一个任务可以在 CPU 间迁移这叫负载均衡。负载均衡听上去是好事但如果任务频繁从 CPU0 迁到 CPU1意味着缓存、TLB、分支预测器里的状态全都要重建开销比普通切换大得多。这也是为什么后来有了 CPU 亲和性把任务钉在某个 CPU 上。调度器选出下一个任务后会调用 __schedule() 这个核心函数里面走 pick_next_task() 选任务再调 context_switch() 执行硬件现场切换。一轮复杂的调度决策到这里才真正开始碰硬件。2.2 context_switch 与 switch_to 的脏活context_switch() 干的事情可以拆成两大块地址空间切换和寄存器现场切换。地址空间切换由 switch_mm() 负责说白了就是把当前进程的页表换成下一个进程的页表x86 上对应写 CR3 寄存器。这是一个很贵的操作因为换了页表之后原来进程的 TLB 条目很难继续使用。早期处理器只能把 TLB 整个刷掉后来支持了 PCID 和 ASID可以按进程 ID 保留一部分 TLB 条目省掉一部分开销。寄存器现场切换由 switch_to() 完成。老版本内核依赖一组内嵌汇编用 swapcontext 的思路把当前任务的寄存器保存到它的内核栈再从下一个任务的内核栈恢复。现代内核用了一个名为 __switch_to 的架构相关函数配合 asm_goto本质上都是在做同一件事保存前一任务的栈指针和寄存器获取后一任务的内核栈指针并加载再更新 current 指向新的 task_struct。浮点和向量寄存器的处理更特殊。很多内核不会在每次切换时都完整保存 AVX 等大块寄存器状态而是用懒加载策略等到新任务真正使用浮点指令时再切换 FPU 状态。这种偷懒不是 bug是性能设计。常规文档里很少写这一段但真正读内核源码的时候建议按这个顺序走schedule() → __schedule() → context_switch() → switch_mm() switch_to()。把这一条调用链读明白了再看任何调度相关的书都会轻松很多。2.3 线程切换和进程切换差距到底在哪里面试题里有一道高频题为什么线程切换比进程切换开销小答案就藏在 switch_mm() 里。同一进程的多个线程共享同一个 mm_struct切换线程时不需要更换页表CR3 可以原地不动TLB 不用重新加载这是最大的收益。同时线程共享用户栈、共享代码段和大部分数据段用户的缓存状态保留得更好。但请注意“线程切换更便宜”不等于“线程切换很便宜”。只要触发调度一样要保存寄存器、内核栈要切、调度器要运行、各种锁可能产生额外竞争。如果多个线程争抢同一个锁或者是虚假唤醒频繁发生线程切换同样能把系统性能打穿。更隐蔽的是 Cacheline Bouncing两个线程跑在同一个 CPU 上则还好要是分别跑在不同 CPU 上又频繁争抢共享变量缓存一致性协议会让性能断崖式下跌。嵌入式和后端开发看这个问题的角度不太一样。嵌入式更关心确定性也就是切换延迟是不是可控的后端起量之后更关心切换频率和缓存命中率。但基础的机制是一样的进程切换是给 CPU 换了一整套现场线程切换往往只换了一半现场。3. 拿命令说话如何观测和量化上下文切换3.1 vmstat 里那个 cs 列到底能不能信系统有没有在频繁切换任务最快的观测方式是 vmstat。执行 vmstat 1 3最后四列里的 cs 就是每秒上下文切换次数。$ vmstat 1 3 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 1024000 32000 456000 0 0 12 30 450 12000 5 18 77 0 0 3 0 0 1023956 32000 456120 0 0 0 12 520 15890 6 22 72 0 0 4 0 0 1023908 32000 456220 0 0 0 0 610 20100 8 26 66 0 0正常情况下一台 16 核的服务器每秒上下文切换在几千到一万多都算合理。持续超过五万基本可以断定系统里有什么东西在频繁唤醒任务或者线程数已经膨胀到离谱。像前面提的管道高频通信场景cs 冲到十万是很常见的。vmstat 的 cs 是全局计数没法看出是哪个进程在贡献切换量。所以看到 cs 高之后下一步要用能按进程拆分的工具。3.2 pidstat -w找出罪魁祸首sysstat 套件里的 pidstat 可以按进程查看切换统计其中 -w 参数专门输出两类上下文切换计数cswch/s 表示自愿切换nvcswch/s 表示非自愿切换。$ pidstat -w 1 Linux 5.15.0-91-generic (node01) 06/18/25 _x86_64_ (8 CPU) PID UID # Cmd # cswch/s nvcswch/s 1234 1000 mysqld 50.00 80.00 5678 1000 java 120.00 300.00自愿切换高说明进程经常主动阻塞比如等待 I/O、等待锁、调用 sleep非自愿切换高往往说明时间片被抢占或者可运行的线程太多、CPU 不够分。java 进程若 nvcswch 特别高通常意味着 JVM 起了几十个业务线程在同一个 CPU 池里抢时间片队列等得太久。再配合 /proc/ /status 里的 voluntary_ctxt_switches 和 nonvoluntary_ctxt_switches 两个累计值可以看一个长期趋势。排障时如果看到某个 worker 进程切得特别凶再去抓它的系统调用、锁等待基本就能锁死问题。3.3 perf sched 与 trace 工具把切换成本打回原形vmstat 和 pidstat 告诉我们“切换多不多”但没回答“切换到底浪费了多少时间”。perf sched 可以做到这一点。$ perf sched record -- sleep 1 $ perf sched latency --sort max输出里会列出每个任务的调度延迟平均值和最大值数值大就说明这个任务经常等很久才被拉上 CPU。配合 perf sched map 还能看到任务在 CPU 间的迁移轨迹。如果频繁出现在不同 CPU 上来回跳就考虑设置亲和性了。内核中的 tracepoint 更细sched_switch 事件记录每一次切换的原任务、新任务和切换原因。用 trace-cmd 或者 bpftrace 去采集这类事件可以精确还原到底谁在什么时候抢了谁的 CPU$ trace-cmd record -e sched:sched_switch sleep 3 $ trace-cmd report | head对大多数场景我觉得不需要一上来就上这些重武器。先看全局 cs再按进程拆分最后用 perf sched 确认延迟分布三层定位法足够解决 80% 的问题。4. 从故障现场到调优高切换问题怎么查怎么收4.1 现场一CPU 不高但服务不响应线上最坑的一种故障就是 CPU 没满Sys 占比也不高服务却卡成 PPT。这种通常不是单点资源耗尽而是“等待-唤醒”风暴大量任务在睡眠、唤醒之间反复横跳每次唤醒都要经过调度器排队。表面上 CPU 有时间片空闲但每个任务真正拿到 CPU 的时间极短大部分时间浪费在原地踏步。我之前排查的一个消息处理程序就是这样。它内部用了 64 个线程等一个共享队列但生产端的消息是突发型。突发来的时候64 个线程全部被唤醒然后 63 个发现队列空又回去睡了再被下一个消息唤醒如此循环。vnstat 里 cs 高达十几万。最后改的并不是线程数量而是消费者改用批量拉取加通知机制把一次唤醒的线程数压了下来延迟立刻恢复正常。遇到这种现场我的排查路径很固定先 vmstat 确认切换量再 pidstat -w 找出非自愿切换最高的进程然后看一眼该进程的线程数和锁使用最后用 perf sched 验证。大多数情况根因不是内核出问题而是应用层把任务切成太碎、太频繁的唤醒。4.2 现场二明明加了核性能反而不升另一个典型场景是“扩展性陷阱”。程序为了利用多核开了几百个线程结果在 8 核机器上性能正常换到 32 核机器上反而卡顿。原因往往在于全局锁、共享队列和过度创建线程共同作用线程越多调度器要处理的排队任务越多上下文切换量随着线程数平方级上涨。加上跨 CPU 的缓存同步性能最后被切换税吃干抹净。对这种问题先看系统里线程总数。CPU 密集计算型的应用最佳线程数通常就在核心数附近I/O 密集的可以适当放宽但也不是越多越好。很多经验老到的后端会把线程池大小设成“CPU 核数 1”或者按压测得出的阈值。不是玄学是在切换成本和吞吐之间妥协出来的结论。另一个容易忽略的做法是设置 CPU 亲和性。利用 taskset 把某个高负载进程钉在几个固定的 CPU 上$ taskset -c 0-3 ./app进程不再到处迁移TLB 和缓存命中率都会变好。对延迟敏感的服务还可以配合 Linux 的 cpuset cgroup把业务进程和系统进程隔离开减少被别的负载干扰的机会。4.3 调优工具箱调度策略、内核参数与实时补丁如果应用层已优化到极限还是对切换敏感可以考虑动内核调度参数。首先要说的是绝大多数 Linux 发布版默认用的都是 CFS/公平调度它服务的是一般服务器场景。为了降低切换延迟可以调整 kernel.sched_min_granularity_ns 和 kernel.sched_wakeup_granularity_ns。前者决定一个任务至少能运行多久调大它进程一次能拿到的 CPU 片段更长切换频率降低后者决定一个任务被唤醒后能多快抢占当前任务调小它唤醒延迟降低但切换次数会增加。这两个参数是跷跷板两端改之前务必想清楚要的是吞吐还是低延迟。sysctl -w kernel.sched_min_granularity_ns3000000 sysctl -w kernel.sched_wakeup_granularity_ns2000000对于明确有实时性要求的任务可以把调度策略改成 SCHED_FIFO 或 SCHED_RR。使用 chrt 命令就能操作$ chrt -r -p 99 pid但实时策略会抢占普通进程设置不当可能让整个系统卡死。我自己当年第一次调实时优先级把一个核心服务设成 SCHED_FIFO 99结果键盘都像死机一样排队等这个服务释放 CPU所以只建议在隔离的控制面使用千万别在共享服务器上乱开。另外主线的 CONFIG_PREEMPT 和实时内核 PREEMPT_RT 也是关键选项。嵌入式领域里很多人抱怨 Linux 不适合做运动控制后来发现其实是内核抢占配置不对。CONFIG_PREEMPT_FULL 的延迟虽然比普通内核好但和实时内核比还是有差距。如果做工业控制类产品直接评估 PREEMPT_RT 或支持 deadline 调度器的高版本内核方向比抠参数重要。5. 嵌入式与面试角度的几个补充5.1 嵌入式 Linux 的“确定性”之争嵌入式 Linux 是热搜里的热门它和进程切换的关系非常直接。嵌入式设备跑 Linux 和跑裸机 RTOS 最大的区别就在于调度行为不确定。裸机没操作系统中断来了就响应Linux 里有大量可抢占点中断处理完以后究竟先跑中断唤醒的高优先级任务还是让当前任务继续取决于抢占配置。这也催生了调度延迟这个概念也就是从事件发生到高优先级任务真正上 CPU 的时间。调度延迟越小实时性越好。之前做机器人的运动控制板电机控制线程需要严格的 1ms 周期。用普通发行版内核周期抖动能到 200μs 以上偶尔还会蹦一个 1ms 的峰值。换成 PREEMPT_RT 内核并把控制线程设成 SCHED_FIFO抖动立刻压到几十微秒。注意这不是把 CPU 调快而是把切换和中断路径上的不可控等待缩短了。嵌入式面试里经常追问的一个点是“中断上下文能不能休眠”。答案是不能因为中断上下文不是一个可调度的任务没有进程概念休眠意味着没有东西能唤醒它。这也从侧面说明切换机制是跟任务上下文绑定的不是所有代码都跑在任务上下文里。5.2 面试最常问的切换问题你背熟了吗结合 linux 面试题的搜索热度我把进程切换这块的高频考题列一下。第一个必然是“进程上下文切换和线程上下文切换有什么区别”回答落脚点要放在 mm 切换、CR3/TLB 以及缓存血统上。第二个是“从用户态到内核态是不是上下文切换”核心是区分模式切换与任务切换。第三个是“上下文切换开销包括哪些”至少要说出寄存器保存恢复、调度器运行、栈切换、TLB 和缓存失效能说出浮点懒加载就是加分项。第四个是“如何查看系统上下文切换”答案自然落到 vmstat、pidstat -w、perf sched。第五个是“为什么协程比线程更轻”本质是因为协程切换发生在用户态由用户代码自己保存上下文不走内核调度器自然省掉了陷入内核和恢复内核现场这一整套成本。但代价是协程是协作式的一个协程卡住同线程的其他协程也别想跑。5.3 我的个人习惯和几条实战建议做了这么多年 Linux 排障我养成了一个习惯任何说不清原因的性能抖动先把 vmstat 的 cs 列看一遍。不是因为它每次都能直接给答案而是它会逼你快速判断问题到底出在调度、锁、中断还是 I/O省去无头苍蝇式地乱试。最后提几个实战建议。第一写代码时少造无谓的线程线程池大小要压测不要拍脑袋。第二排查高切换时不要只盯着单机cgroup 限流、容器配额、OOM Killer 这些外围因素也会引发异常调度行为。第三动了内核参数一定要记录基线sched 参数这东西调完当天可能没事业务高峰才见分晓。第四多读一点 task_struct 和调度器源码不必背代码把调用链和核心结构记住遇到问题自然能联想到机制层面而不是停留在命令表面。进程切换是 Linux 系统性能问题里最“隐形”又最关键的一环。理解它不需要什么天赋需要的是把“CPU 现场、调度器、观测命令”这三板斧真正串起来。下次再遇到系统卡顿记得先瞄一眼 cs 那一列。