去年冬天有台跑消息消费的机器出了件怪事: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里看到的I(idle)状态。一个枚举做不到这种组合,位图天然支持。
我见过有同学在监控脚本里用state == 1这种硬编码判断就绪态,升级内核之后发现判断失效了,就是因为位图语义变了。判断状态要用掩码,不要用等值比较,这是第一个容易踩的坑。
ps显示 | 内核标志 | 含义 | 是否计入 loadavg |
|---|---|---|---|
| R | TASK_RUNNING | 正在 CPU 上跑,或者在就绪队列里排队 | 是 |
| S | TASK_INTERRUPTIBLE | 可中断睡眠,等事件,能被信号叫醒 | 否 |
| D | TASK_UNINTERRUPTIBLE | 不可中断睡眠,通常在等 IO 或内核锁 | 是 |
| I | TASK_IDLE | 空闲内核线程,不可中断但不计负载 | 否 |
| T | __TASK_STOPPED | 被 SIGSTOP 之类信号暂停,或被调试器挂起 | 否 |
| t | __TASK_TRACED | 被 ptrace 跟踪 | 否 |
| Z | EXIT_ZOMBIE | 已退出,等父进程收尸 | 否 |
| X | EXIT_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列很大而us+sy很低,方向就该往存储或者内核锁上找,而不是去查 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 用 PCID(Process 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 "scale=3; $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抓现场。这条链路走了很多次,基本没有失手的时候。唯一需要额外注意的,是切换次数正常但性能依然差的情况,那多半不是切换的问题,得往内存、锁、甚至硬件降频那边找。数字好看不代表没问题,得跟基线比,跟自己历史数据比。