Linux进程状态与上下文切换:从状态机到调度调优
1. 进程状态与切换:一张被反复涂抹的状态机
做内核观测和性能调优这些年,被问到最多的问题里,"这台机器上下文切换怎么这么高"和"D 状态的进程到底卡在哪"这两个基本占了前三。进程切换和进程状态这两个词看着像是教科书前几章的概念,实际上它们是线上排障和调度参数调优绕不开的两根主线:状态决定了任务"想不想跑、能不能跑",切换决定了任务"什么时候真正上 CPU"。把这两件事拆开看,很多看起来玄学的延迟抖动、吞吐上不去、CPU 空转但队列不平的问题,都能落到很具体的一行代码或一个字段上。
这篇东西适合三类人看:写过并发程序、想知道自己那几十个线程到底在内核里怎么排队的人;做过服务端性能调优、看到cs那一列数字心里没底的人;以及正在啃内核调度源码、被__state、on_rq、on_cpu这几个字段绕晕的人。我会从状态机的设计意图讲起,再把一次进程切换拆到寄存器粒度,然后用一套可复刻的观测流程带你亲手量出切换代价,最后给出我实际踩过的坑和一套排查速查表。中间涉及的参数计算、命令、脚本都能直接抄走用。
先抛一个我觉得最值得记住的结论:进程状态描述的是"任务愿不愿意被调度",而不是"任务是不是正在跑"。这两件事在内核里由不同字段表示,混淆了它们,后面所有的分析都会歪。记住这一点,再看ps输出里的R、S、D,你的理解会完全不一样。
2. 拆解状态机:为什么是这几种状态而不是别的
2.1 六种核心状态的职责边界
主流开源内核里,任务的状态用一个位图字段表示(task_struct里的__state,早期版本直接叫state)。它不是枚举,是比特位,因为有些状态需要叠加,比如"可被信号唤醒的睡眠"和"被杀掉的标记"可以同时存在。这一个小设计就决定了后面很多行为。
| 状态标记 | ps显示 | 语义 | 谁负责唤醒 |
|---|---|---|---|
TASK_RUNNING | R | 在运行队列里,随时可被选中 | 无需唤醒,等调度器 |
TASK_INTERRUPTIBLE | S | 睡眠,等条件或信号 | 唤醒源或信号 |
TASK_UNINTERRUPTIBLE | D | 睡眠,只等条件,不理信号 | 仅唤醒源 |
__TASK_STOPPED | T | 被作业控制暂停 | SIGCONT |
EXIT_ZOMBIE | Z | 已退出,等父进程回收 | 父进程 wait |
EXIT_DEAD | X | 正在被回收的最后瞬间 | 无人,即将消失 |
这里最容易被误解的是TASK_RUNNING。名字叫 RUNNING,实际上是"runnable",包含正在 CPU 上执行的,也包含在运行队列里排队的。判断"是不是真的在跑"要看另一个字段on_cpu,或者调度实体的on_rq标志。我在刚开始看调度代码时就栽在这上面:写了个采样脚本统计TASK_RUNNING的进程数,结果发现它恒等于"线程总数减睡眠数",根本不是 CPU 占用数,白折腾半天。
TASK_UNINTERRUPTIBLE(D 状态)之所以存在,核心原因是必须保证某些临界资源在被等待期间不被打断。最典型的是块设备 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, v+n}' "$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, dv+dn}' 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 对齐 |
| 单进程长期 D | wchan、内核栈 | 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"的延迟打点,用时间戳差值统计分布。这个数据比切换次数更贴近用户体验,也更容易和业务侧的延迟指标对齐。代价是需要改代码,但如果你的服务对延迟敏感,这点改动绝对值得。