尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Linux上下文切换详解:原理、排查与优化实战

Linux上下文切换详解:原理、排查与优化实战 干我们 Linux 运维和性能调优这一行的迟早会遇到一个绕不开的概念上下文切换Context Switch。很多刚入行的朋友在排查系统负载过高、应用卡顿的问题时都盯着 CPU 使用率、内存占用看半天结果一头雾水——CPU 明明没跑满服务却慢得像蜗牛。这时候如果你用vmstat 1看一眼大概率会发现cscontext switch那列的数字高得吓人。问题就出在这里系统的时间都被上下文切换吃掉了真正干活的时间反而所剩无几。这篇东西我就结合自己这些年踩过的坑和做过的大大小小的优化把 Linux 上下文切换这件事彻底拆开揉碎讲清楚。包括它到底是什么、切换一次要付出多少代价、怎么快速定位它是不是系统性能的罪魁祸首以及针对高并发场景我们能做哪些实实在在的优化。无论你是刚接触 Linux 开发的新手还是已经维护着成百上千台服务器的老手我相信里面总有几句经验是能直接上手用的。1. 上下文切换到底在解决什么问题1.1 从“多任务”说起为什么必须切换我们在终端里同时开着好几个程序感觉它们在“同时运行”。但放到物理硬件层面一个 CPU 核心在一个时刻只能执行一条指令流。所谓多任务其实是操作系统把 CPU 的时间切成很多个极短的时间片轮流分配给不同的任务。时间片通常只有几毫秒到几十毫秒因为切换速度太快人眼根本感知不到所以就有了“并发执行”的错觉。但这个“轮流”可不是免费的。每次操作系统要把 CPU 从一个任务交给另一个任务之前必须把当前任务干到一半的现场全部保存好再把下一个任务之前保存的现场恢复出来。这个“保存现场 恢复现场”的动作就是上下文切换。你可以把它理解成学校办公室里老师和学生的切换老师正在批改作业突然叫你进来说话他得先把手里这份作业批改到第几题、发现了什么问题都记在脑子里然后才把注意力转给你跟你聊完他又得回想刚才作业批到哪了再继续往下批。这一来一回真正的有效工作时间就被打了折扣。操作系统之所以必须做这件事是因为它要保证公平性和响应性。如果没有上下文切换一个陷入死循环的进程就能独占 CPU其他所有任务全部卡死。有了切换机制每个任务都能获得执行机会交互式的操作也才能得到及时响应。换句话说上下文切换是“并发”这个核心能力的地基只不过这个地基本身是有成本的。1.2 上下文里到底装了什么核心数据我第一次接触这个概念的时候也觉得抽象直到后来看内核代码和调优实战多了才建立起具体的画面感。所谓的“上下文”本质上是当前任务在 CPU 上运行所依赖的一整套状态集合。最少包括这几类东西寄存器组通用寄存器eax、ebx、ecx、edx 这些、程序计数器 PC、栈指针 SP。这些是 CPU 执行指令的直接依据丢了任何一个程序都没法从头跑起来。内核栈每个进程或线程在内核态都有一个独立的栈用来保存函数调用链、局部变量和内核路径上的临时数据。切换的时候必须切换到目标任务的内核栈。浮点寄存器/SIMD 状态如果你的程序涉及到浮点运算或者多媒体指令xmm、ymm 这类寄存器这些也得保存和恢复否则计算结果就是乱的。内存管理信息主要是页表基地址等。每个进程都有自己的虚拟地址空间切换进程时必须把 MMU 的页表指向切换后的进程。这一项后面会讲到它恰恰是进程切换比线程切换贵的一个重要原因。内核数据结构指针比如 task_struct、thread_info 等内核需要通过这些结构来找到新任务的调度信息、状态和资源。你可以把“上下文”就理解成 CPU 干活时的完整草稿纸。换一个人来用这张草稿纸之前必须把上一个人写写画画的内容全部保存好再把下一个人的草稿纸内容重新铺开。保存和铺开这两个动作一旦做得太频繁CPU 就会陷入“一直在擦黑板、写黑板”的循环真正的计算工作反而被挤占了。2. 上下文切换的三种典型类型2.1 进程上下文切换代价最重的一种当一个用户态进程因为时间片耗尽、被更高优先级任务抢占、或者主动进入睡眠状态时内核就需要切换到另一个进程。这属于进程上下文切换也是我们讨论最多、影响最大的一种。进程上下文切换最特殊的地方在于每个进程都有独立的虚拟地址空间。所以切换的时候内核不仅要把寄存器状态和内核栈切过去还得把整个地址空间切过去——具体来说就是加载新的页表刷新 TLBTranslation Lookaside Buffer地址转换缓存。光是这一步代价就不小。我之前排查过一个 Java 应用的问题业务量一大 CPU 使用率就到了百分之八九十但应用吞吐量却上不去。后来发现这台机器上部署了十几个 Java 进程每个进程都在持续输出日志、各自管理一堆线程调度器每隔几毫秒就要在这些进程之间来回切换。每次切换都伴随着 TLB 刷新后续的指令和数据访问几乎处处 miss等于是 CPU 每次切换后都要经历一段“冷启动”时间。这就好比一个厨师刚把锅热好准备炒菜你让他立刻换到另一口锅去炒另一道菜他得重新洗锅、重新热锅这段时间厨房里看着忙忙碌碌实际上菜根本没出锅几道。2.2 线程上下文切换为什么比进程轻线程是进程内部的执行单元同一进程内的多个线程共享进程的地址空间、打开的文件、全局变量等资源。因此多线程之间的上下文切换不需要重新加载页表也不需要完全刷新 TLB只需要切换寄存器、栈指针、程序计数器等线程私有的数据即可。这也是为什么业界反复强调多线程模型比多进程模型在并发方面开销更低其根源就在这里。不过要注意“更轻”不等于“免费”。线程切换依然要进入内核态经过调度器的调度决策保存和恢复线程的运行状态。如果线程数量太多、切换频率太高这部分的累加成本一样相当可观。我在实际项目里遇到过一种比较典型的情况用 C 写了一个高并发的网络服务为了追求“每连接一线程”连接数一上来就创建了几百上千个线程。线程数量多了之后光是在这些线程之间切换的调度开销就把 CPU 吃掉了三四个核。后面改成线程池 非阻塞 IO线程数量控制在几十个以内整体吞吐量反而大幅提升。还有一个容易忽略的细节如果多个线程分属不同的进程这种线程切换实际上会退化为进程切换的代价。只有当这些线程属于同一个进程时切换成本才是真正的线程级切换。在查看系统上下文切换数据的时候这点很容易造成误判。2.3 中断上下文切换藏在水面下的开销前面两种都是任务与任务之间的切换还有一种是硬件中断或者异常触发的上下文切换。比如网卡收到一个数据包会触发中断通知 CPU 处理磁盘 IO 完成了也会中断 CPU 去执行回调。当 CPU 响应中断时它会从当前正在执行的任务中强行抽身跳到内核的中断处理程序去执行。这个过程中当前任务的现场同样需要保存中断处理完成后还要恢复现场继续执行。这类切换不会出现在常见的上下文切换计数里面也不会对应到一个具体进程的被调度所以经常被忽略。但它一样会消耗 CPU 时间还会打断正在运行的进程可能造成额外的延迟。我在网络抓包和性能分析时深有体会当网卡流量达到接近上限的时候系统的软中断处理softirq会占用大量 CPU甚至导致正常业务进程得不到足够的执行时间。你从top里看可能某个核的si软中断占比明显偏高进程上下文切换的数据反而很正常但其实中断处理已经把 CPU 的资源偷走了。这时候就需要用到中断负载均衡、或者网卡多队列RSS之类的技术把中断分散到多个 CPU 核上才能缓解单个核上的压力。3. 上下文切换的成本为什么说它很贵3.1 一次切换的耗时可能远超你的直觉很多人都知道上下文切换有开销但“开销”到底是多少我贴一组我当年在测试环境里实测出来的近似数量级不同内核版本、不同 CPU 架构差异很大但量级可以参考一次进程上下文切换大约需要 110 微秒μs一次线程上下文切换大约需要几百纳秒到 1 微秒一次系统调用不涉及任务切换大约需要几十到几百纳秒。单个看1 微秒似乎微不足道。但如果这台机器每秒要发生几万甚至几十万次的上下文切换那消耗掉的 CPU 时间就非常可观了。举个例子如果一个 CPU 每秒能做 100 万次操作其中一个操作是切换任务它占用了 1 微秒那这个 CPU 每秒钟真正用于计算的时间就要扣除几乎全部时间——这当然是很夸张的极端情况但也能看出上下文切换一旦失控系统性能就会断崖式下跌。需要明确的是上下文切换不仅仅是脏活累活本身还有一系列连锁效应。最典型的就是CPU Cache 和 TLB 被污染。一个进程运行得正顺的时候它的热数据、热指令很可能已经加载到了 L1、L2、L3 Cache 里。一旦切换到另一个进程Cache 里的内容对新的进程来说就是无效的触发 cache miss 之后不得不回主存甚至磁盘加载数据这个延迟比 CPU 执行一条指令要慢两个数量级以上。用大白话讲上下文切换就像是你从工位上站起来去开一个会等你回到座位脑子里的工作记忆已经丢了一半得重新读文档、重新理思路甚至重新打开浏览器里的几十个标签页。你表面上只花了五分钟开会但实际上重新进入工作状态又花了十几分钟。3.2 进程切换的 TLB 惩罚进程上下文切换里还有一项隐蔽但影响很大的开销——TLB 刷新。CPU 为了加速虚拟地址到物理地址的转换内部维护了一个很小的缓存表叫 TLB。进程切换后旧进程的地址映射对新进程没有意义所以 TLB 里大量条目需要失效这意味着接下来一段时间的地址转换都会 miss也就是要走慢速路径去查页表。现代处理器在 TLB 设计上做了一些优化比如引入 PCIDProcess Context Identifier和 ASIDAddress Space Identifier也就是说切换进程时不一定需要把 TLB 全部清空而是通过标识符区分不同进程的缓存条目。这样在一定程度上缓解了进程切换的 TLB 惩罚但并没有完全消除。特别是那些访存密集型的应用进程切换频率高的时候性能损耗依然非常显著。我记得有段时间在数据库服务器上做压测8 核 16 线程的机器跑到大概 2000 并发就上不去了。用perf测出来的结果是上下文切换占比很高再看cache-misses也是居高不下。后来把数据库连接池和每个连接对应的工作线程做了绑核CPU affinity设置把切换频率降下去之后并发能力提升了一大截。绑定之后线程长时间在自己对应的核上运行Cache 和 TLB 的命中率大幅度提高效果立竿见影。3.3 调度延迟除了耗 CPU还增加响应时间上下文切换还会引入调度延迟也就是一个任务虽然已经处于可运行状态但要等排队轮到它才能真正执行。如果系统里可运行的线程数量远远超过 CPU 核心数且每个任务被分配的时间片又很短那么这个延迟会变得很显著。我拿一个真实案例来说之前一个广告投放系统高峰期就会出现大量请求耗时超过 1 秒的情况但数据库和下游服务的响应时间都正常。后来查下来问题出在网关层部署了很多个 Java 服务每个服务的线程池又配置得特别大导致每个 CPU 核上排队的可运行线程多到几十个。线程的负载很高一个线程刚跑完被换下去可能要等好几个时间片才能重新被调度上来用户请求自然就卡住了。把线程池拉小、把业务拆到更多实例上之后这个延迟问题很快就消失。4. 如何观察和度量上下文切换4.1 vmstat 快速定位全系统切换频率遇到性能问题我的习惯是先用vmstat看个全貌。命令很简单vmstat 1 5输出里有两列和我关系最大rrunning运行队列中进程数量和cscontext switch每秒上下文切换次数。r如果长期大于 CPU 核心数说明有大量任务在排队cs如果长期偏高比如稳定在几十万甚至上百万那基本可以断定上下文切换已经把系统拖住了。举个例子我曾经在一台 4 核的虚拟机上跑压测vmstat出来的cs高达 30 万us用户态 CPU却只有 35%。这说明 CPU 很多时间都不是在跑业务代码而是在处理切换。我第一反应就是找虚拟机宿主机上的原因后来才发现原来是虚拟机上的 IO 线程和业务线程配比失衡导致大量进程在“可运行”和“睡眠等待”之间来回跳。调整之后cs降到了 5 万左右us上去了吞吐量也上去了。提示cs高并不一定就代表有问题。高并发系统里上下文切换天然会偏高关键是看它吃掉的 CPU 时间增加了多少、业务有没有因此受损。排查的时候要结合us、sy、wa和多核数据综合判断不要一看cs高就慌了。4.2 pidstat 定位到具体线程全局cs高了就要进一步缩小范围。pidstat是 sysstat 工具包里的命令可以按进程或线程展示上下文切换的次数pidstat -w 1输出里的Cswch/s表示每秒自愿上下文切换次数nVCswch/s表示每秒非自愿上下文切换次数。两者含义不同分析方向也不同自愿上下文切换cswch任务因为等待资源比如索要锁、等待 IO、主动 sleep而主动让出 CPU。数值高往往说明有资源争抢或者 IO 等待。非自愿上下文切换nvcswch任务时间片被耗尽、被更高优先级任务抢占、或者被调度器强制切换。数值高往往说明线程太多、CPU 不足以满足运行需求。我之前定位一个 Go 服务性能抖动的问题时就是用pidstat -w 1看到某个 goroutine 较多的工作线程nVCswch/s一直在几千次以上其他线程却正常。这说明它被频繁抢占或者调度器分片太短。后来查发现是因为这个线程所在的 CPU 上还跑着一个高优先级的内核线程时不时抢它导致它不断地被换出。通过调整线程优先级和 CPU 亲和性问题就解决了。如果是多线程程序还可以加上-t参数按线程维度查看pidstat -wt 1这样能看到具体某个线程的切换次数在排查 Java、Go、C 这类多线程应用时非常有用。4.3 深入内核perf 和 /proc/stat如果想看得更细可以用perf来统计上下文切换相关的内核事件perf stat -e context-switches,cpu-migrations,page-faults sleep 10这里的cpu-migrations是指任务在不同 CPU 核之间迁移的次数。这个值如果很高说明调度器经常把一个任务挪来挪去每次迁移都相当于一次变相的进程切换而且会把缓存热度彻底打散。针对 NUMA 结构下的多路服务器这个值尤其值得关注。另外/proc/pid/status文件里也有关于线程切换的统计字段voluntary_ctxt_switches和nonvoluntary_ctxt_switches可以针对特定进程做长时间观察。比如每隔一秒采样一次看某个进程的这两列增长趋势grep ctxt /proc/12345/status我之前就用这种办法判断一个进程是不是在疯狂抢占 CPU——非自愿切换数快速增长基本可以认定它被调度器频繁打断背后往往就是线程数太多、优先级不合理或者 CPU 配额不足。5. 常见问题与排查技巧实录5.1 cs 列居高不下如何按部就班定位我在技术社群里经常看到有人问“cs 高怎么办”。说实话没有固定的“药方”但有一套排查流程是可以通用的先用vmstat 1看整体cs和各核负载判断是否持续偏高。再用top按 1 展开看每个 CPU 核的使用率观察是否存在单核被打满、其他核空闲的情况。用pidstat -w 1定位是哪些进程贡献了主要的切换数量。针对可疑进程用pidstat -wt 1看具体线程的切换和迁移情况。用perf top或者perf stat进一步确认是不是跟锁竞争、调度器、甚至是硬件中断相关。结合应用日志和监控曲线判断切换高发的时间和业务流量是否有强相关。把这几步走下来基本能定位到到底是因为进程太多、线程池设置过大、锁竞争严重还是中断负载不均衡造成的。最忌讳的就是一上来就凭感觉调参数改了一堆配置最后连问题在哪都没弄清楚。5.2 锁竞争带来的“伪上下文切换”有一种特别容易误导人的情况系统的自愿上下文切换次数暴增但系统整体负载并不高应用却感觉响应很慢。这种时候十有八九是锁竞争太激烈。线程拿不到锁只能把自身状态切换为阻塞或者睡眠主动让出 CPU等锁被释放后又被唤醒再次被调度执行。这一来一回就产生了两次上下文切换。线程越多、锁竞争越激烈这种无效切换就越严重。我之前排查过一个 MySQL 慢查询引发的高频锁等待问题当时看到应用层的线程切换数特别高还以为业务量增加了后来定位到是一把全局业务锁被某个慢查询长时间占用其他线程全部在等待。解决的办法是拆分锁粒度、用读写锁代替互斥锁、借助无锁数据结构减少争抢最终切换次数直接下降了一个数量级。如果你在做性能排查的时候发现cswch很高一定要想到锁竞争这个可能性。5.3 优化上下文切换的几条实战经验这里我把这些年用得最多的招数整理一下都是实践中屡试不爽的第一合理设置线程池大小。线程数不等于越大越好一旦可运行线程数超过 CPU 核心数多出来的线程并不能提高吞吐量只会增加调度和切换开销。对于 CPU 密集型任务线程数通常不超过CPU 核心数 1IO 密集型任务可以多一些但也要结合 IO 等待比来计算不能盲目放大。第二善用 CPU 亲和性taskset。对于延迟敏感、热数据明显的进程可以把它绑定到固定的 CPU 核上运行避免被调度器在不同核之间来回迁移。命令很简单taskset -c 0,1 ./your_app也可以开启irqbalance或者手动设置中断的 CPU 亲和性让网络中断均匀分布到多个 CPU 核上避免单核处理中断过载。第三减少锁竞争。尽量用无锁数据结构、读写锁、细粒度锁、或者尽量缩短持锁时间。如果你发现perf里大量时间花在锁相关的操作上那说明代码层面的锁设计已经到了需要优化的时候。第四降低系统调用频率。系统调用本身虽然不等同于上下文切换但高频的系统调用往往会带来进程状态的反复切换。批量处理 IO、使用 epoll 替代多线程阻塞 IO、减少不必要的文件读写这些都能从根源上减少调度器和上下文切换的压力。第五控制 CPU 的过度订阅。如果你在宿主机上运行了多个虚拟机或者一次性启动了大量容器且它们的 CPU 配额之和超出了物理核心数调度器就需要频繁地切来切去。这种情况通常要调整容器的 CPU limit或者合理规划物理机的资源分配。第六调整内核参数。比如通过sysctl调整调度器的某些行为或者在实时性要求高的场景下启用实时调度策略。不过这类操作风险更高改动之前一定要在测试环境充分验证生产环境谨慎使用。5.4 我踩过的坑希望你避开最后分享几个真实踩坑的教训。一个是把“上下文切换高”直接等同于“出了问题”。有一次我们上线一个新服务我看到监控面板上的cs水平比旧服务高了不少就陷进去排查了一整天。后来冷静下来一对比新服务的吞吐量本身就是旧服务的三倍并发连接数也高得多单位请求的切换次数其实是下降的。上下文切换是一个需要结合负载来看的指标不是越低越好真正该关心的是它到底消耗了多少 CPU、是否拖累了业务。另一个是只调参数不验证。有一次为了降低 MySQL 所在物理机的上下文切换我把kernel.sched_migration_cost_ns调得很高结果切换确实少了但任务迁移变少之后有些 CPU 核忙死、有些核闲死负载严重失衡反而把服务的尾部延迟拉高了。后来我把挫折总结成一句话任何调度相关的优化都必须以业务指标为准不能只盯着一两个内核计数器看短期内“变漂亮”的数据有可能掩盖更深层的性能问题。再有一个教训来自监控阈值误报。有段时间我们做了个简单的告警规则一旦cs超过某阈值就报警。结果业务高峰期报警声音几乎没停过大家都产生了“狼来了”的错觉真出问题的时候反而反应慢了。后来改成综合判断——同时考察cs上升、CPU 使用率异常、和业务延迟增加这三者命中率才真正提升上来。指标和监控永远是为了定位问题服务的不要让它反过来绑架你。做 Linux 性能排查这么久我的一个很深的体会是上下文切换就像系统里的“隐形税”平时不显山不露水真正出问题的时候它往往是把性能拖垮的最后一根稻草。排查这类问题关键不是背多少命令和参数而是建立一套“由全局到局部、由现象到本质”的思路。先看清楚全局指标再逐步缩小范围结合代码和业务找到真正的原因最后才是对症下药地做优化。如果你能把文中这些命令和排查思路真正用起来下一次再遇到 CPU 使用率不高但系统卡顿的情况就不会再一头雾水了。
返回列表