Linux进程状态与上下文切换:从状态机到调度调优 1. 进程状态与切换一张被反复涂抹的状态机做内核观测和性能调优这些年被问到最多的问题里这台机器上下文切换怎么这么高和D 状态的进程到底卡在哪这两个基本占了前三。进程切换和进程状态这两个词看着像是教科书前几章的概念实际上它们是线上排障和调度参数调优绕不开的两根主线状态决定了任务想不想跑、能不能跑切换决定了任务什么时候真正上 CPU。把这两件事拆开看很多看起来玄学的延迟抖动、吞吐上不去、CPU 空转但队列不平的问题都能落到很具体的一行代码或一个字段上。这篇东西适合三类人看写过并发程序、想知道自己那几十个线程到底在内核里怎么排队的人做过服务端性能调优、看到cs那一列数字心里没底的人以及正在啃内核调度源码、被__state、on_rq、on_cpu这几个字段绕晕的人。我会从状态机的设计意图讲起再把一次进程切换拆到寄存器粒度然后用一套可复刻的观测流程带你亲手量出切换代价最后给出我实际踩过的坑和一套排查速查表。中间涉及的参数计算、命令、脚本都能直接抄走用。先抛一个我觉得最值得记住的结论进程状态描述的是任务愿不愿意被调度而不是任务是不是正在跑。这两件事在内核里由不同字段表示混淆了它们后面所有的分析都会歪。记住这一点再看ps输出里的R、S、D你的理解会完全不一样。2. 拆解状态机为什么是这几种状态而不是别的2.1 六种核心状态的职责边界主流开源内核里任务的状态用一个位图字段表示task_struct里的__state早期版本直接叫state。它不是枚举是比特位因为有些状态需要叠加比如可被信号唤醒的睡眠和被杀掉的标记可以同时存在。这一个小设计就决定了后面很多行为。状态标记ps显示语义谁负责唤醒TASK_RUNNINGR在运行队列里随时可被选中无需唤醒等调度器TASK_INTERRUPTIBLES睡眠等条件或信号唤醒源或信号TASK_UNINTERRUPTIBLED睡眠只等条件不理信号仅唤醒源__TASK_STOPPEDT被作业控制暂停SIGCONTEXIT_ZOMBIEZ已退出等父进程回收父进程 waitEXIT_DEADX正在被回收的最后瞬间无人即将消失这里最容易被误解的是TASK_RUNNING。名字叫 RUNNING实际上是runnable包含正在 CPU 上执行的也包含在运行队列里排队的。判断是不是真的在跑要看另一个字段on_cpu或者调度实体的on_rq标志。我在刚开始看调度代码时就栽在这上面写了个采样脚本统计TASK_RUNNING的进程数结果发现它恒等于线程总数减睡眠数根本不是 CPU 占用数白折腾半天。TASK_UNINTERRUPTIBLED 状态之所以存在核心原因是必须保证某些临界资源在被等待期间不被打断。最典型的是块设备 IO 的数据结构操作如果这时允许信号中断睡眠然后处理函数去释放一块正在被硬件 DMA 写入的缓冲区数据就毁了。所以 D 状态的设计初衷不是让进程卡住而是保护一段不可重入的状态。代价是如果唤醒源出问题进程会永远挂着而且kill -9也杀不掉因为它压根不看信号。2.2 状态迁移的边界条件与设计权衡状态机本身不复杂复杂的是迁移发生的时机。从TASK_RUNNING到睡眠路径是设置状态 → 加入等待队列 → 检查条件 → 主动让出 CPU。这里有个经典的两步检查问题如果先检查条件再设置状态条件可能在两者之间被满足导致唤醒丢失进程永久睡眠。所以标准写法是先把状态改成TASK_INTERRUPTIBLE再进等待队列再检查条件然后调用调度器。这个顺序不能反。反方向从睡眠到TASK_RUNNING是由唤醒方中断处理、另一个进程、定时器完成的。唤醒方要做三件事把状态改回TASK_RUNNING、把任务从等待队列摘下、把它塞进某个 CPU 的运行队列。第三步涉及选哪个 CPU这就是负载均衡的入口也是跨核唤醒开销的来源。我认为理解状态机最有价值的一个视角是每个状态的停留时间分布比状态本身更能说明问题。一个进程偶尔进 D 状态几十微秒是正常的一直挂在 D 状态十分钟就是故障一个进程频繁在 R 和 S 之间来回跳说明它在等某种短周期事件可能是锁可能是网络小包。我习惯用采样去统计状态的驻留时间分布而不是只看瞬时快照这样能一眼看出抖动型和卡死型两类完全不同的病。注意TASK_KILLABLE这个变体值得单独记一笔它的行为类似不可中断睡眠但收到致命信号时会退出。驱动作者如果不确定自己的等待是否真的不能被信号打断用 KILLABLE 比 UNINTERRUPTIBLE 安全得多。我见过不止一次因为误用 D 状态导致进程变僵尸活死人只能重启机器。2.3 状态字段在内存里长什么样状态字段占多少位、和哪些字段相邻直接影响你用工具读取的可行性。在 64 位平台上它通常是一个unsigned int和on_cpu、on_rq、抢占计数这些标志挤在同一批字段里为的是让调度器热路径上读取的字段尽量落在同一个缓存行。这种热字段聚簇的布局思路在内核里非常普遍也侧面说明了状态判断是调度器最高频的操作之一。如果你要自己写观测工具比如一个自研的 proc 解析脚本有两个点要留意第一不要依赖固定偏移不同版本布局会变用procfs提供的稳定接口最省心第二状态位是位图判断时要按位与不能做等值比较否则带附加标记的任务会被漏掉。我写过一个用等值比较的脚本结果所有带TASK_NOLOAD标记的 IO 线程都没统计进来排查了半天才发现是自己写错。3. 一次切换到底切了什么从寄存器到地址空间3.1 三层上下文与保存范围很多人把进程切换和线程切换和模式切换混为一谈导致分析方向完全不同。按切换的成本从低到高排序是这样三层第一层是模式切换用户态进内核态执行系统调用或处理中断。不换地址空间不换内核栈只压一部分寄存器到内核栈成本在几十到几百纳秒量级。这一层通常不计入cs统计。第二层是同地址空间内的任务切换也就是同一进程的多个线程之间切换。需要换内核栈、换寄存器上下文但页表基址寄存器不用动TLB 大部分有效。这是线程池场景下最常发生的切换。第三层是跨地址空间的切换真正的进程切换。除了上面那些还要换页表基址、处理 TLB、可能触发页表相关的一致性操作。这一层成本最高也是进程比线程重这句话的技术来源。真正需要保存的寄存器比想象中少。x86-64 的调用约定规定了一批调用者保存寄存器如rax、rcx、rdx、rsi、rdi、r8到r11它们在函数调用点就已经由编译器决定是否需要落栈了切换函数本身是个函数调用所以它只需要处理被调用者保存的那部分即rbx、rbp、r12到r15加上栈指针和指令指针。这就是为什么切换代码可以写得那么短——不是内核偷懒是 ABI 帮它把账算清楚了。浮点、向量寄存器的处理更有意思。传统做法是惰性保存切换时只设置一个标志位等到新任务第一次执行浮点指令时触发异常异常处理里再真正保存和恢复整套向量状态。理由是绝大多数任务根本不用浮点或 SIMD为它们保存几百字节的状态纯属浪费。这个设计带来一个副作用——如果两个任务都在密集做向量运算那每次切换都会触发一次异常反而变慢。所以现代实现里改成了条件式的立即保存。这个细节告诉我们一个通用道理延迟工作不等于消灭工作只在不做的人多的时候划算。3.2 切换的执行路径与关键节点把一次完整的切换按时间顺序摊开大概是这样几个阶段标记需要调度。唤醒源、时间片耗尽、优先级变化、显式让出都会在某个位置置上需要重调度标志。选择调度点。最安全的位置是返回用户态之前以及中断处理结束、抢占计数归零的时刻。内核里有很多cond_resched之类的显式检查点。选出下一个任务。从当前 CPU 的运行队列里按调度类优先级取实时类优先于普通类普通类内部再按虚拟运行时间排队。执行底层切换。保存当前任务栈指针加载下一个任务的栈指针恢复那批被调用者保存寄存器返回。收尾。切换地址空间、更新统计计数、处理抢占标记、清理延迟任务。第 4 步在源码里通常是个宏直接嵌入手写汇编。这里有个容易被忽略的点切换动作本身是在旧任务的栈上执行的直到最后一条返回指令才真正跳到新任务的栈上。所以但凡在切换函数里还想访问局部变量都是危险操作。这类 bug 一旦出现就是随机崩溃而且频率极低非常难查。我见过有人为了加个日志在切换路径里插了打印结果系统偶发挂死查了两周。3.3 地址空间切换与缓存污染的代价估算页表基址寄存器的写入本身只是一条指令代价在后面TLB 里缓存的地址翻译失效后续访存要重新走页表遍历。为了缓解现代处理器给 TLB 条目加上地址空间标识切换时只改标识不清空整个 TLB。但当标识数量用尽还是会触发整体刷新。比 TLB 更难量化的是各层缓存和分支预测器的污染。切换之后新任务的代码和数据大概率不在 L1、L2 里甚至 L3 也可能被挤掉。这部分开销不在任何计数器里但真实存在。我习惯用一个粗暴但有效的估算方式单次切换直接开销 ≈ 1~3 微秒含底层切换 地址空间切换 简单统计更新 若 CPU 主频 3.0 GHz则 1 微秒 ≈ 3000 个时钟周期 假设每秒切换 20 万次直接开销 200000 × 2μs 0.4 秒/秒 即单核有 40% 的时间在做切换而不是做业务这个算法当然粗糙但足够让产品同学理解线程不是免费这件事。实际数字可以通过观测工具拿到下面会讲具体怎么做。需要注意的是这还没算缓存污染的间接开销真实损失往往比算出来的更大尤其在访存密集的负载上。4. 动手量一次可复刻的切换观测流程4.1 从 procfs 读单任务切换次数最省事的入口是/proc/pid/status里面有两个字段voluntary_ctxt_switches任务主动让出 CPU 的次数睡眠、等待锁、读写阻塞nonvoluntary_ctxt_switches任务被动被剥夺 CPU 的次数时间片用完、被更高优先级抢占这两个数的比例非常有诊断价值。主动占绝大多数说明任务在频繁等东西瓶颈通常是 IO 或锁被动占绝大多数说明任务一直想跑但抢不到 CPU瓶颈是算力或者线程开太多。我在排查CPU 利用率不高但延迟高这类问题时第一眼就是看这个比例。下面这个脚本可以按秒采样某个进程所有线程的切换增量直接复制就能用#!/bin/bash # 用法: ./ctxwatch.sh pid [间隔秒] PID${1:?need pid} INT${2:-1} prev_file$(mktemp) report() { awk {v$1; n$2} END {printf voluntary%d nonvoluntary%d total%d\n, v, n, vn} $1 } snapshot() { for t in /proc/$PID/task/*; do [ -r $t/status ] || continue v$(awk /^voluntary_ctxt_switches/{print $2} $t/status) n$(awk /^nonvoluntary_ctxt_switches/{print $2} $t/status) echo ${v:-0} ${n:-0} done } snapshot $prev_file while sleep $INT; do cur_file$(mktemp) snapshot $cur_file paste $prev_file $cur_file | awk {dv ($3-$1); dn ($4-$2)} END {printf delta: voluntary%d nonvoluntary%d total%d\n, dv, dn, dvdn} mv $cur_file $prev_file done脚本里用了paste逐线程做差比只读主线程准确得多因为很多服务是主线程安静、工作线程疯狂切换的结构。还有一个细节线程可能在采样间隙被创建或销毁导致两个快照行数不一致所以我在paste之后做了容错缺失的按零算。真正严谨的版本应该按 TID 关联但那需要额外记录 TID脚本会变长日常排查用上面这个够用。4.2 系统级切换速率与调用栈归因系统级的总切换速率在vmstat的cs列或者pidstat -w的cswch/s、nvcswch/s两列。这两列的区别和上面两个字段一致一个是主动一个是被动。真正要做归因得看切换发生在谁和谁之间、调用栈是什么。几个能直接用的命令# 全系统切换热点按任务聚合 pidstat -w -p ALL 1 5 # 用 perf 记录调度事件并给出延迟分布 perf sched record -a -- sleep 10 perf sched latency -s max # 全局切换速率 vmstat 1 10 | awk {print $1, $12}perf sched latency的输出特别值得看它会给出每个任务的平均等待时间、最大等待时间以及最大延迟发生在哪次切换。等待时间 从被唤醒到真正上 CPU 的间隔这个指标比切换次数更能反映用户感知到的卡顿。如果手上有bpftrace一行命令就能按进程名统计切换次数bpftrace -e tracepoint:sched:sched_switch { sw[comm] count(); }再进一步统计切换时的调用栈深度分布可以快速区分用户态阻塞导致的主动切换和调度器抢占导致的被动切换bpftrace -e tracepoint:sched:sched_switch /args-prev_state ! 0/ { sleep_stack[kstack] count(); }上面这个只看进入睡眠的路径args-prev_state非零表示旧任务不是单纯被抢占。跑十几秒后按 Ctrl-C输出会告诉你哪些内核函数是睡眠源头通常能直接看到具体的锁或具体的 IO 层。注意bpftrace在高频切换场景下自身有开销采样时间不要太长十几秒足够。另外不要在生产环境的核心业务机器上无脑跑全量栈采样kstack聚合的内存消耗和符号解析开销都不小我吃过一次把观测脚本跑成故障的教训。4.3 一次实测记录与结果解读我在一台 8 核、主频 3.0 GHz 的机器上做过一组对照实验模拟 IO 密集和 CPU 密集两类负载各跑 60 秒记录切换速率和延迟分布。这里用的是自己的模拟程序不涉及任何真实业务。场景线程数cs每秒主动切换占比平均唤醒延迟观察到的现象小包读写模拟64约 18 万92%约 12 微秒CPU 使用率不高但延迟尾巴很长纯计算模拟64约 2 万3%约 3 微秒切换少但单核跑满吞吐受限于核心数纯计算模拟8约 0.3 万2%约 2 微秒切换几乎可忽略吞吐最高小包读写 批处理合并8约 2.2 万88%约 9 微秒切换降一个数量级延迟尾巴明显收窄这组数据里最能说明问题的是第一行和第四行的对比线程数从 64 降到 8同时把每批只处理一个包改成攒几个再处理切换速率掉了接近一个数量级而吞吐基本没掉、延迟还更稳。这就是切换是有价格的价格可以买别的东西的直观体现。反过来看第二行和第三行也很说明问题纯计算负载下64 线程的切换次数是 8 线程的六倍多但吞吐不仅没涨还略微下降了。原因是有序队列上的负载均衡、缓存命中率下降、以及时间片被切碎。所以线程数等于核数这条经验并不是迷信它背后就是切换成本和缓存局部性两笔账。5. 常见问题排查状态异常与切换飙高怎么定位5.1 切换次数飙高的三条排查主线看到切换速率异常我一般按锁 → 唤醒源 → 线程数的顺序排这个顺序是性价比从高到低排的。第一条主线是锁竞争。锁竞争会制造大量睡眠 - 唤醒对每一次都是一次主动切换。判断方法很简单看主动切换占比如果超过 85% 且业务并不以网络 IO 为主那大概率是锁。再看内核栈采样里的睡眠源头如果集中在与锁相关的函数上基本可以确定。解决思路是缩小临界区、拆锁、或者改成无锁结构具体选哪个要看临界区里到底在干什么。第二条主线是过细的唤醒粒度。典型症状是每个事件唤醒一个线程事件频率高但每个事件的处理量很小。定时器密集、小包频繁、每写一条日志就刷一次盘都属于这类。这类问题的解法不是减少线程而是合并事件批量读、批量写、延迟合并、把N 次唤醒压缩成1 次唤醒处理 N 个。上面实验第四行做的就是这件事。第三条主线是线程数超过物理并行度太多。这个最好判断数一下线程数和核数就知道。但要注意不能一律砍线程因为如果线程大量时间在等 IO多一点线程反而能压满 CPU。判断依据是主动切换占比和平均唤醒延迟延迟高、主动占比高说明线程在等延迟低、被动占比高说明在抢。5.2 D 状态进程定位的完整路径D 状态进程是最让人难受的一类因为它不响应信号kill -9无效重启服务也可能因为父进程卡在wait而挂住。定位路径我总结成四步第一步确认它真的在 D 状态而不是 T 或 Z。ps -eo pid,stat,wchan:32,comm一眼看出来wchan列会给出它睡在哪个内核函数上这一列信息量极大很多问题到这一步就有方向了。第二步拿到内核栈。有权限的话直接读/proc/pid/stack能完整看到从唤醒等待点到系统调用入口的调用链。这一步定位的是卡在哪一层。第三步看是不是 IO 层。如果栈里出现块设备层或者文件系统的等待函数就要去看存储侧的状态设备是否响应、队列是否堆积、后端是否限流。这时候往往不止一个进程在 D而是一批ps里会出现明显的聚集。第四步如果栈显示在文件系统层且涉及网络文件系统那基本可以判定是远端无响应。这类等待通常是不可中断的因为中间状态不能被信号打断。处理方式只能是恢复远端或者强制卸载要非常谨慎可能丢数据。如果需要一次性看到全系统所有 D 状态任务的栈可以用系统请求键# 需要 root 权限且内核开启了相应配置 echo w /proc/sysrq-trigger # 输出会进内核日志用 dmesg 查看 dmesg | tail -n 200注意这个操作会打印所有阻塞任务的栈在任务数很多的机器上会产生大量日志可能冲击日志系统。生产环境上先确认日志量和磁盘空间别为了排查制造新故障。我习惯先dmesg -c清一下再触发再看增量。5.3 排查速查表现象优先看什么常见根因处理方向cs高、CPU 不高主动切换占比锁竞争、事件粒度过细缩临界区、批量合并cs高、CPU 也高被动切换占比、线程数线程过多、时间片切碎降到核数量级、绑核延迟尾巴长但均值正常唤醒延迟分布最大值跨核唤醒、负载不均亲和性调整、NUMA 对齐单进程长期 Dwchan、内核栈IO 层阻塞、远端无响应查存储侧、查网络侧大量 Z 状态父进程是否在 wait父进程卡住或未回收修父进程回收逻辑切换少但吞吐上不去缓存命中率、跨 NUMA 访存内存局部性差内存绑定、数据分片6. 减少切换代价的几个实操手段6.1 事件合并与批量处理这是投入产出比最高的一招没有之一。核心思路是把每个事件唤醒一次改成攒一批唤醒一次。网络侧可以用批量收包接口存储侧可以用批量提交日志侧可以攒够一定字节或时间再落盘。具体到实现最关键的参数是攒多久和攒多少。这两个值不能拍脑袋我一般这样算假设单次唤醒处理的固定成本是 2 微秒事件平均间隔是 50 微秒那么每批处理 4 个事件固定成本就被摊薄到 0.5 微秒/事件延迟增加约 150 微秒。如果业务的可接受延迟是毫秒级这个交换非常划算如果业务要求百微秒级延迟那就得用数量或时间先到先触发的双阈值策略。这里有个坑要提醒批量处理会把延迟分布从尖峰形变成台阶形平均值可能不变甚至更好但 P99 会变差。所以上批量之前一定要确认业务的延迟敏感度别拿平均值骗自己。6.2 亲和性与 NUMA 对齐跨核唤醒的代价比同核唤醒高得多因为要跨 CPU 运行队列、可能触发 IPI、还要走缓存一致性协议。把有唤醒关系的任务绑到相近的核心上能省掉一大部分。常用手段是设置亲和性掩码或者用调度器提供的自动聚合特性大多数发行版默认已开但要确认没被关掉。NUMA 上还有第二层内存也可能跨节点。任务跑在节点 0但内存分配在节点 1每次访存都要跨互联延迟高几倍。这种情况下的症状很有欺骗性——切换次数正常、CPU 不高、但吞吐就是上不去。判断方法是看内存分配的节点分布和任务所在节点是否一致不一致就做绑定。我踩过的一个坑为了让负载更均衡手动把亲和性设成了跨两个 NUMA 节点的核心集合结果任务在节点间来回迁移切换和远程访存双高性能比不设还差。教训是亲和性调整前一定要先看清楚物理拓扑别拿逻辑核编号当物理布局用。6.3 调度参数的调整边界/proc/sys/kernel/下面有一批调度参数可以调但我对调参的态度是除非能证明瓶颈确实在调度粒度上否则不要动。原因是这些参数的默认值是针对通用场景长期调出来的改动一个往往会把另一类负载搞坏。如果确实要调值得关注的是这几个方向最小调度粒度控制一个任务在被抢占前至少跑多久。调大会减少切换但会增加交互式任务的延迟。唤醒粒度控制新唤醒的任务在多大程度上可以抢占当前任务。调大偏向吞吐调小偏向延迟。迁移代价估计调度器用它判断把任务迁到别的核值不值。如果任务的实际工作集很大缓存迁移代价高适当调大能减少无谓迁移。调整必须配对照实验一次只改一个改完跑足够长时间看四个指标切换速率、平均延迟、P99 延迟、吞吐。四个指标里有两个变差的直接回退。我在一次调优里把最小粒度调大了三倍切换速率确实降了 30%但 P99 延迟涨了一倍多最后还是回退改从业务侧做批量。6.4 我自己踩过的三个坑第一个坑是用错统计口径。早期我统计切换只看系统级的cs结果某次一个进程疯狂切换被其他进程的减少掩盖了全系统数字看着正常。后来改成先按进程聚合再求和才定位到问题进程。口径这件事粗看和细看结论可以完全相反。第二个坑是把 CPU 空闲当成没负载。有一次线上cs飙高但 CPU 使用率只有 20%团队里有人判断是机器很闲没有及时处理。实际上那 20% 里有一大半在做切换业务线程真正干活的时间很少。指标要组合看单看一个必然误判。第三个坑是观测工具本身成为瓶颈。全量栈采样在高频切换的机器上会产生巨大开销我曾在高峰期跑了一个全量采样直接把一台机器的负载推高了一个量级。现在的习惯是先用轻量计数定位到进程再对该进程做短时采样采样窗口控制在十秒内采完立刻停。6.5 一个值得长期坚持的习惯我现在做任何一个多线程服务都会在启动时打印一份线程数、核数、亲和性、调度策略的基线信息运行中定期采样一次主动/被动切换比。这套东西加起来不到五十行代码但它能在性能问题刚冒头的时候就给出信号而不是等到用户投诉才回头查。尤其是主动/被动切换比这个指标它几乎等价于你的线程在等别人还是在抢资源这个问题的答案。比值的漂移比绝对值更有价值同样是 90% 主动从 90% 漂到 97% 说明等待变重了从 90% 漂到 70% 说明竞争变重了两个方向的应对完全不同。这个指标我用了很多年从没让我失望过。如果还想再往前一步可以给关键路径加上唤醒到上 CPU的延迟打点用时间戳差值统计分布。这个数据比切换次数更贴近用户体验也更容易和业务侧的延迟指标对齐。代价是需要改代码但如果你的服务对延迟敏感这点改动绝对值得。