
去年冬天有台跑消息消费的机器出了件怪事CPU 使用率长期趴在 60% 上下离打满还远但消费速率掉了三成队列积压越滚越大。磁盘不忙网络不忙GC 日志也干净怎么看都不像瓶颈。最后我盯着vmstat 1看了五分钟发现cs那一列从平时的两万上下跳到了三十多万——进程切换次数不正常。顺着这条线挖下去问题落在几个热点线程的锁竞争上大量线程在就绪态和阻塞态之间反复横跳把 CPU 时间全烧在了保存现场和恢复现场上真正干活的指令没跑几条。这件事之后我把进程状态和进程切换这两块从头捋了一遍因为它们是同一个硬币的两面状态决定了谁该上 CPU切换负责把这件事落地。你只要把状态机想明白切换的成本结构自然就清楚了反过来只盯着切换次数看不知道那些任务当时处于什么状态排查方向很容易跑偏。这篇东西适合两类读者一类是刚开始做 Linux 性能排查、看到cs那一列或者D状态进程数不知道怎么读的人另一类是数字会看了但说不清这些数字从哪儿来、为什么这个量级就算大、切换一次到底花了多少时间的人。下面我按自己踩坑的顺序讲从状态语义讲到切换的硬件代价再讲怎么把开销算成能跟业务方交代的数字。1. 从那次CPU 不忙却卡顿说起状态字段到底编码了什么1.1 一个整数里的位图不是一个枚举很多人第一次读内核代码会以为进程状态是个枚举0是运行、1是就绪、2是阻塞干净利落。实际不是。Linux 里task_struct的那个状态字段老版本叫state新版本叫__state本质上是个位图多个标志位可以同时置位。你在ps里看到的R、S、D、T、Z只是内核把位图翻译成人能看的形式翻译逻辑在fs/proc/array.c里那段task_state_array查表代码。为什么要用位图因为有些状态天生是叠加的。比如不可中断睡眠这个状态它同时隐含了我不该被算进系统负载这个属性于是内核引入了一个TASK_NOLOAD标志和TASK_UNINTERRUPTIBLE按位或在一起就得到了我们在ps里看到的Iidle状态。一个枚举做不到这种组合位图天然支持。我见过有同学在监控脚本里用state 1这种硬编码判断就绪态升级内核之后发现判断失效了就是因为位图语义变了。判断状态要用掩码不要用等值比较这是第一个容易踩的坑。ps显示内核标志含义是否计入 loadavgRTASK_RUNNING正在 CPU 上跑或者在就绪队列里排队是STASK_INTERRUPTIBLE可中断睡眠等事件能被信号叫醒否DTASK_UNINTERRUPTIBLE不可中断睡眠通常在等 IO 或内核锁是ITASK_IDLE空闲内核线程不可中断但不计负载否T__TASK_STOPPED被 SIGSTOP 之类信号暂停或被调试器挂起否t__TASK_TRACED被 ptrace 跟踪否ZEXIT_ZOMBIE已退出等父进程收尸否XEXIT_DEAD彻底消亡任务结构即将释放否1.2TASK_RUNNING的双重身份是理解切换的第一道坎R这个状态最容易被误读。它同时覆盖了正在 CPU 上执行和已经在就绪队列里、随时可能被调度上去两种情况。你在ps里看到十个R不代表有十个核在同时跑这十个任务可能只有一个在跑另外九个在红黑树里按vruntime排序等着。内核为什么不做这个区分因为当前是否真的在 CPU 上这个信息更新太频繁了。如果每次调度都要改一次状态字段这个字段本身就会变成缓存行上的热点多核之间争抢同一个 cache line代价比省下的那点语义清晰度大得多。所以内核把谁在跑这件事交给运行队列的curr指针去表达task_struct里不重复记录。这个设计带来的直接后果是你不能靠采样状态去算 CPU 利用率。想算利用率得看/proc/stat里按 CPU 累计的 jiffies或者用schedstat里记录的运行时间。采样法在状态字段上采出来的东西只能告诉你这个任务当时是否可运行告诉不了你它跑了多久。1.3 就绪队列长什么样CFS 的红黑树与 RT 的位图状态从不可运行翻到可运行之后任务得找个地方待着。普通任务进的是 CFS 的红黑树键是vruntime也就是虚拟运行时间。树最左边那个节点就是下一个该跑的任务取出来的复杂度是 O(log n)n 是就绪任务数。这个结构选得挺讲究既要快速取最小值又要支持任意节点的插入和删除链表取最小是 O(1) 但插入要遍历堆能取最小但删除任意节点麻烦红黑树两头都兼顾。实时任务走的是另一条路每个优先级一个链表外加一个位图记录哪些优先级非空。取最高优先级任务就是找位图里最高的那个置位用find_first_bit之类的指令几个周期就出结果。这也是为什么实时任务的调度延迟能压得比较低——它的数据结构就是为最快找到下一个设计的。1.4D状态为什么会让负载虚高有个现象很多人第一次遇到会懵机器负载 20但 CPU 使用率只有 5%top里一堆D状态进程。这不是 bug是loadavg的统计口径决定的。内核算平均负载的时候把TASK_RUNNING和TASK_UNINTERRUPTIBLE两类任务都算进去了TASK_IDLE因为带了TASK_NOLOAD标志被排除在外。把D状态算进负载是有道理的一个卡在磁盘 IO 上的任务虽然不消耗 CPU但它确实想干活干不了从系统吞吐的角度看和排队等 CPU 是一回事。但这也就意味着负载高不等于 CPU 忙你要结合vmstat的b列不可中断睡眠进程数和wa列一起看。如果b列很大而ussy很低方向就该往存储或者内核锁上找而不是去查 CPU 争抢。2. 谁在推着状态往前走调度点、中断与唤醒的完整链路2.1 状态迁移不是自发的每一个箭头背后都有一个触发者画状态机图很容易画完你会发现箭头一堆但真正有价值的问题是这个箭头是谁画的。从运行态掉到阻塞态一定是因为任务自己执行了某个会睡眠的操作——读一个还没准备好的管道、等一把 mutex、发起一次同步写。从阻塞态回到就绪态一定是有人在外面把它叫醒——IO 完成中断、另一个任务unlock之后调用唤醒、定时器到期。这两类触发者的执行上下文完全不同排查时的手法也不一样。主动睡眠是我知道我要等内核栈上能看到清晰的调用链cat /proc/PID/stack一眼就能看出卡在哪个函数。被动唤醒则涉及跨 CPU 的 IPI 或者队列操作出问题的时候往往表现为唤醒延迟而不是卡死。我一般会先分清当前是哪种情况再决定往下查什么。分不清就瞎查容易在错误的方向上耗掉半天。2.2 自愿切换与非自愿切换是两个完全不同的问题pidstat -w会给你两列cswch/s和nvcswch/s。前者是自愿上下文切换任务主动让出 CPU后者是非自愿的任务还想跑但时间片到了或者被更高优先级的任务抢了。这两列数字背后的病因天差地别自愿切换高任务在频繁等待。等锁、等 IO、等条件变量。这类问题通常伴随吞吐下降但 CPU 不满因为时间都花在等待上了。往锁粒度、IO 模型、连接池大小这些方向查。非自愿切换高任务被强行剥夺 CPU。要么是就绪任务数远超核数要么是有高优先级任务在抢要么是时间片设得太短。这类问题通常 CPU 是满的往核数配置、优先级设置、cgroup 配额上查。我踩过一个坑某个服务nvcswch/s特别高我一开始以为是线程数开多了扩了核之后发现没改善。后来才注意到同一个 cgroup 里还跑着另一批带实时优先级的任务普通任务一直在被抢。非自愿切换高先看有没有人比你优先级高再看是不是核不够。2.3 抢占点在哪里返回用户态那一刻才是真正的分水岭内核默认是不可抢占的除非开了PREEMPT系列配置这意味着一个任务进了系统调用之后只要不主动睡眠它可以一直霸着 CPU 直到返回用户态。所以真正的抢占时机集中在几个位置中断返回、系统调用返回、以及显式调用preempt_schedule()的地方。中断返回时的检查逻辑大致是这样中断处理完了看当前任务的thread_info里TIF_NEED_RESCHED标志有没有置位置了就调preempt_schedule_irq()走一次调度。这个标志是时钟中断处理程序在发现当前任务时间片耗尽时设置的。理解了这一点你就明白为什么内核态长循环是个大问题一个任务在内核里转圈不返回时钟中断打得再勤也没法把它换下去只能等它自己让出来。老版本内核对这种情况基本没辙现在有了cond_resched()在可能长时间执行的循环里插一句主动检查一下是否需要让出。写驱动或者改内核代码的时候长循环里不加这个很容易把整台机器的调度延迟拉爆。2.4 唤醒路径从wake_up到目标 CPU 的就绪队列一个任务被唤醒的完整链路比想象中长。发起方调用wake_up_process()进到try_to_wake_up()这里要做几件事把任务状态从睡眠改回TASK_RUNNING选一个目标 CPU把任务塞进那个 CPU 的就绪队列如果目标 CPU 上正在跑的任务优先级比它低还要触发一次抢占。目标 CPU 怎么选很有讲究。默认策略叫 wake affine倾向于唤醒到上次运行的 CPU或者和唤醒者有共享缓存的 CPU目的是让任务醒来之后还能命中之前留在 cache 里的数据。这个策略在大多数场景下是对的但在某些绑定场景下会帮倒忙如果你把网络中断绑在 0 号核把业务线程绑在 1-7 号核中断处理程序在 0 号核上唤醒业务线程affine 策略可能把线程往 0 号核塞导致 0 号核过载而其他核闲着。跨 CPU 唤醒要发 IPI这个开销不小几十到几百纳秒不等取决于拓扑。所以高频唤醒的场景比如每包一次的收包处理唤醒次数本身就是个瓶颈这也是后来各种批量收包、轮询模式出现的原因之一。3. 上下文切换的两层账本寄存器只是小头Cache 与 TLB 才是大头3.1 直接开销从switch_to到栈切换的那几十条指令内核里真正完成切换的是switch_to这个宏。以 x86-64 为例它做的事是把当前 CPU 的通用寄存器、栈指针、指令指针存进prev的thread_struct再从next的thread_struct里把对应的值恢复出来。核心就一个__switch_to_asm几十条汇编指令。这里有个设计细节值得说一下switch_to接受三个参数prev、next、last。为什么需要last因为切换完成之后代码是在next的栈上继续跑的此刻prev这个局部变量所处的栈帧已经不属于当前执行流了直接读它是未定义行为。内核的解决办法是用一个寄存器把prev的值带过来函数返回后通过last这个输出参数拿到。这是内核里少数几个必须靠汇编约定才能正确工作的地方读代码的时候很容易看漏。单个任务的thread_struct里存的东西不多栈指针、指令指针、几个 callee-saved 寄存器、浮点/SIMD 状态如果是懒加载模式可能是延迟保存的、以及一些调试寄存器。现代 CPU 上这部分保存恢复的开销大概在几百纳秒到一两微秒之间。3.2 地址空间切换比寄存器切换贵一个数量级如果切换的两个任务是不同进程还要换页表。x86 上是往CR3写新值ARM 上是写TTBR0。写这个寄存器的代价不只是写本身更麻烦的是附带效应。传统做法是写完CR3整个 TLB 就失效了因为 TLB 里的条目没有地址空间标签换页表之后旧条目全是错的。TLB 一空接下来的每一条访存指令都可能触发页表遍历二级页表意味着最多两次额外访存如果两级都 miss代价能到上百个周期。现代 CPU 用 PCIDProcess Context Identifier来解决这个问题TLB 条目打上 12 位的地址空间标签切换时只换 CR3 的值不清空 TLB不同进程的条目可以共存。同理 ARM 上有 ASID。这个优化对切换密集型负载效果非常明显我在一台跑大量短连接的机器上对比过开启 PCID 前后cs相同的情况下sy时间能差出十几个百分点。线程切换就便宜多了。同进程的两个线程共享同一个地址空间switch_mm直接跳过只做寄存器切换。这也是为什么很多高并发服务的线程模型宁愿用线程池也不愿意起多进程——切换成本差着一个量级。3.3 间接开销Cache 污染才是真正难量化的一块直接开销能数出来间接开销只能估。切换之后新任务开始跑它的工作集在不在 cache 里大概率不在。原来那个任务好不容易预热好的 L1、L2可能被挤掉大半。这个代价取决于几个因素新任务的工作集大小、cache容量、两个任务的地址空间是否有重叠。工作集很小比如只有几 KB 的热点数据预热很快代价可以忽略工作集几十 MB每次切换都要从内存里重新拉一遍那就相当可观了。有个经验性结论上下文切换的直接开销通常在 1 到 3 微秒间接开销可以到几十微秒具体取决于工作集和内存延迟。这个数量级差异意味着如果你只按直接开销算账很容易低估真实成本。我一般这么估先假设每次切换 2 微秒直接开销算出一个下限再看工作集大小如果单个任务的热点数据超过 L2 容量的一半就按 10 微秒往上估。虽然粗糙但跟实际观测到的性能下降能对上。切换类型涉及的保存恢复内容典型直接开销间接开销主要来源同进程线程切换寄存器组、栈指针、内核栈亚微秒级L1/L2 数据与指令污染跨进程切换有 PCID上述内容 页表基址 部分 TLB 失效1 到 2 微秒上述内容 TLB 未命中跨进程切换无 PCID上述内容 全量 TLB 刷新2 到 3 微秒上述内容 TLB 全冷启动跨 NUMA 节点切换上述内容 远端内存访问视情况翻倍远端内存延迟百纳秒级4. 把切换开销换算成能交代的数字观测链路与估算方法4.1 从系统级到进程级三层观测口径排查切换问题我习惯按三层看系统整体、进程维度、单次事件。系统整体看vmstat 1的cs列或者直接读/proc/stat里的ctxt字段做差。这一层只告诉你多还是少不告诉你为什么多。判断多没有绝对标准得跟自己的基线比。一台 8 核机器每秒两万次切换算正常二十万次就该警觉了。进程维度用pidstat -w -p ALL 1它把每个进程的自愿和非自愿切换分列出来一眼能看出是谁在贡献主要切换量。这一步往往就能定位到嫌疑对象。单次事件用perf sched record或者 ftrace 的sched_switch事件。perf sched latency能给出每个任务的调度延迟统计平均、最大sched_switch的原始 trace 能看到完整的前后任务名和优先级适合抓偶发问题。4.2schedstat被严重低估的一个文件/proc/PID/schedstat里三个数字含义依次是在 CPU 上累计运行时间纳秒、在就绪队列里累计等待时间纳秒、被调度上 CPU 的次数。需要内核开启CONFIG_SCHEDSTATS很多发行版默认是开的。这三个数字的组合价值很高。运行时间除以调度次数得到平均时间片等待时间除以调度次数得到平均排队延迟。如果排队延迟从几微秒涨到几百微秒说明就绪队列在变长CPU 供应不足或者有人在霸占。如果平均时间片从 10 毫秒掉到 200 微秒说明任务频繁主动让出大概率是锁或者 IO 的问题。我排查那次消费积压就是靠这个文件把范围从整个服务缩到三个线程的。那三个线程的平均时间片只有一百多微秒而正常应该是几毫秒说明它们每次上 CPU 没跑多久就让出去了。4.3 一个可以拿去汇报的估算公式给非技术背景的人解释切换太多有多严重光说次数没用得换成 CPU 时间的账。估算方式是这样单次切换直接开销按 2 微秒算每秒切换 N 次那么每秒被切换吃掉的 CPU 时间是N × 2μs。二十万次切换就是 0.4 秒也就是 0.4 个核被完全占掉。8 核机器上这相当于把 5% 的总算力烧在了切换上但如果这些切换集中在两三个热点核上那两三个核的实际可用算力可能只剩 60%。# 采集 10 秒的切换总量算平均值 ctxt_before$(awk /^ctxt/{print $2} /proc/stat) sleep 10 ctxt_after$(awk /^ctxt/{print $2} /proc/stat) total$((ctxt_after - ctxt_before)) echo 平均每秒切换次数: $((total / 10)) echo 按单次 2us 估算每秒占用 CPU: $(echo scale3; $total * 2 / 1000000 / 10 | bc) 秒这个算法当然粗糙忽略了很多因素但足够让业务方理解为什么 CPU 没满却变慢了。真要精确归因还是得上perf sched看实际分布。4.4 用 ftrace 抓一次完整切换序列需要看具体某一段时间发生了什么的时候sched_switch事件最直接# 只抓 sched_switch抓 5 秒 trace-cmd record -e sched:sched_switch -T sleep 5 trace-cmd report | head -50 # 或者用原生 tracefs echo 0 /sys/kernel/debug/tracing/tracing_on echo /sys/kernel/debug/tracing/trace echo 1 /sys/kernel/debug/tracing/events/sched/sched_switch/enable echo 1 /sys/kernel/debug/tracing/tracing_on sleep 5 echo 0 /sys/kernel/debug/tracing/tracing_on head -50 /sys/kernel/debug/tracing/trace输出里每一行能看到前一个任务的prev_comm、prev_state切出去时处于什么状态、后一个任务的next_comm、next_prio优先级。prev_state这一列特别有用R表示它是被抢走的非自愿S或D表示它是自己睡着的自愿。一眼就能分辨切换的性质。注意prev_state显示的是位图值经过转换后的符号表示如果看到R这种带加号的说明有额外的标志位同时置位需要对照源码确认具体含义别当成异常。5. 状态与切换联动时的几个典型坑位5.1D状态堆积负载报高、CPU 空闲、还杀不掉这是最迷惑人的一类故障。进程卡在D状态kill -9都杀不掉负载数字一路上涨实际业务早就没响应了。原因在于D状态的设计语义它表示正在等待一个不能被打断的事件通常跟硬件或者内核关键路径相关比如等待磁盘 IO 完成、等待 NFS 响应、等待某些内核锁。处在这个状态下信号是不能被处理的因为打断之后内核的数据结构可能处于不一致状态。排查这类问题第一步是找它卡在哪# 看卡点调用栈 cat /proc/pid/stack # 或者用 sysrq 看所有 D 状态任务的调用栈 echo w /proc/sysrq-trigger dmesg | tail -100我遇到过两种情况一种是底层存储真的慢stack里能看到明确的 IO 等待函数另一种是更隐蔽的——某个内核模块在持有锁的情况下做了阻塞操作导致其他任务全堵在那把锁上。后一种情况看stack的时候会发现好几个任务卡在同一个锁上这就是明显的信号。5.2 僵尸进程状态已经是终点了资源还没还回来Z状态的任务已经执行完exit绝大部分资源都释放了只剩一个task_struct和一个内核栈占着等着父进程调wait()来收尸。如果父进程一直不收这些东西就一直占着。单个僵尸进程占的内存不多但进程号是有限的。僵尸堆满会导致新进程起不来错误信息是fork: Cannot allocate memory或者Resource temporarily unavailable看起来像内存问题实际是 PID 耗尽。# 统计僵尸进程数量 ps -eo stat,pid,ppid,comm | awk $1 ~ /^Z/ {print} | wc -l # 找它们的父进程 ps -eo stat,pid,ppid,comm | awk $1 ~ /^Z/ {print $3} | sort | uniq -c | sort -rn根因基本都在父进程要么是代码里fork之后没wait要么是信号处理里漏了SIGCHLD。容器环境下还有一种情况PID 1 进程没有正确实现回收逻辑把孤儿进程全变成僵尸这时候要检查容器的 init 配置。5.3 惊群与虚假唤醒一次事件叫醒一堆任务多个任务阻塞在同一个事件上事件发生时全部被唤醒但只有一个能拿到资源其余的发现没自己的份又睡回去。这来来回回的切换全是白费的。典型场景是一个监听套接字上挂了多个工作线程各自accept新连接到来时全部被唤醒。老版本的accept确实有这个问题后来的内核通过WQ_FLAG_EXCLUSIVE之类的机制做了优化让唤醒时只叫一个。自己写代码的时候也有类似陷阱条件变量用broadcast而不是signal或者while循环检查条件时没处理好都会产生大量无效唤醒。这类问题在pidstat -w上表现为cswch/s异常高但每个任务的有效工作量很低。5.4 优先级反转高优先级任务在等低优先级任务放锁三个任务高中低三个优先级。低优先级持锁中优先级就绪高优先级要拿锁。结果中优先级把低优先级抢了低优先级没机会放锁高优先级一直等。这就是优先级反转。通用内核里靠优先级继承来缓解持锁的低优先级任务在有人等它的锁时临时被提到跟等待者相同的优先级。但这套机制只在rt_mutex上生效普通的mutex走的是另一套逻辑。我在一个带实时任务的场景里踩过这个坑一个普通优先级的线程拿着一把锁做了一次慢速 IO一个实时线程在等这把锁结果实时线程被卡住了几十毫秒触发了业务侧的超时。后来把慢速 IO 移出临界区问题消失。实时线程要访问的临界区里绝对不能有阻塞操作这条比什么优化都管用。6. 压低切换代价的几种手段以及各自的适用边界6.1 减少唤醒次数批处理与合并是最直接的一招既然切换有成本最直接的办法就是少切几次。网络收包这块变化最明显早期每来一个包触发一次中断、唤醒一次处理线程小包场景下切换次数直接跟包数挂钩。后来有了 NAPI 的轮询模式、中断合并、GRO/GSO 这些机制本质都是把多次事件攒起来批量处理把切换开销摊薄。应用层能做的类似事情也不少。日志同步写改成异步批量刷盘每写一条就唤醒一次磁盘 IO 变成攒够一批再唤醒消息消费端不用一条一条确认改成累积一批统一提交。这些改动的共同点是接受一点延迟换取切换次数的成倍下降。代价要看业务能不能接受。实时性要求高的场景批量就意味着延迟抖动得做权衡。我的经验是先把批量窗口设得很小比如 1 毫秒看切换降幅如果效果明显再逐步放大窗口直到延迟指标开始报警。6.2 线程与核的比例开多少线程不是拍脑袋定的线程数远超核数就绪队列拉长切换自然上去。但线程数开得太少IO 等待时又浪费 CPU。这个平衡点跟任务类型强相关。CPU 密集型任务线程数开到核数的 1 到 1.5 倍就够开多了纯属浪费。IO 密集型任务理论上可以开到核数的很多倍因为大部分时间在等。但等待的方式很关键如果是阻塞 IO 加线程池线程数就得上百如果是多路复用加少量线程线程数可以压到很低切换量自然小。我做过一次调整某个服务从 400 个阻塞线程改成 32 个线程跑事件循环cs从每秒十几万降到一万多吞吐反而涨了两成。切换省下来的 CPU 时间全变成了有效算力。当然这个改动的前提是业务逻辑能拆成非阻塞形式有些同步库绕不过去就只能保持线程模型。6.3 绑核与中断亲和把切换的战场收敛到局部CPU 亲和性设置能显著改变切换的分布。默认情况下任务会在多个核之间迁移每次迁移都可能带来 cache 冷启动和跨核唤醒的开销。绑核之后任务固定在一个核上热数据一直在这个核的 L1/L2 里切换代价小很多。# 把进程绑到 2-5 号核 taskset -cp 2-5 pid # 查看当前亲和性 taskset -cp pid # 中断亲和把网卡中断绑到固定核 cat /proc/interrupts | grep 网卡名 echo 4 /proc/irq/irq号/smp_affinity但绑核不是无脑绑。以前我把网络中断和业务线程绑在同一组核上想着减少跨核通信结果中断处理抢占了业务线程的时间非自愿切换暴增。后来把中断单独隔出两个核业务线程绑在剩下的核上两边都安稳了。中断核和业务核要分开这是踩过才知道的一条。6.4 大页与内存布局降低 TLB 未命中的连带成本切换之后 TLB 冷启动的代价跟页表项数量直接相关。同样大小的内存4KB 页需要 512 个页表项才能覆盖的范围2MB 大页一个就够了。TLB 项数有限典型 L1 TLB 几十到一百多项用大页能覆盖的工作集大得多未命中率明显下降。打开透明大页是一行命令的事cat /sys/kernel/mm/transparent_hugepage/enabled echo madvise /sys/kernel/mm/transparent_hugepage/enabled但要注意透明大页的分配和合并本身有开销对延迟敏感的场景可能带来毛刺。always模式省心但偶有卡顿madvise模式需要应用显式标注哪些区域该用大页控制力更强。对切换密集型的服务我一般推荐madvise在关键数据结构上用madvise(MADV_HUGEPAGE)标一下。6.5 谨慎使用实时优先级它解决的是延迟不是吞吐把任务设成实时优先级SCHED_FIFO或者SCHED_RR确实能大幅降低它的调度延迟——没人能抢它。但这把双刃剑的另一面是它一旦跑起来除非自己让出否则别的任务别想上 CPU。我用过一次某个对延迟极敏感的数据处理任务设了SCHED_FIFO前期的效果非常好P99 延迟从 8 毫秒降到 1 毫秒以内。后来业务量上来这个任务在某次处理大批数据时跑了将近一秒没让出把同核上的其他任务全饿死了其中就包括负责健康检查的线程触发了探活失败导致实例被重启。教训是实时优先级只该用在执行时间确定且很短的任务上并且一定要配合 CPU 隔离isolcpus使用把它放到专门的核上别跟普通任务混在一起。执行时间不确定的任务上了实时优先级等于给自己埋雷。排查这类问题的思路我在那次之后固定下来了先看cs有没有异常再看pidstat -w分自愿非自愿接着用schedstat缩到具体任务最后ftrace抓现场。这条链路走了很多次基本没有失手的时候。唯一需要额外注意的是切换次数正常但性能依然差的情况那多半不是切换的问题得往内存、锁、甚至硬件降频那边找。数字好看不代表没问题得跟基线比跟自己历史数据比。