做后端和基础设施的同学,大概率都被“软中断”这个词折磨过。它看不见摸不着,却能在某个晚高峰把 CPU 的一个核直接打满,让业务接口的 P99 延迟飙到几百毫秒。软中断在内核里的定位,是硬中断和进程上下文之间的“下半场”:网卡收包、定时器、RCU 回调,这些高频但又不适合在硬中断里完成的任务,都由软中断接住。这篇文章只聊两件事:它的设计思想为什么长这样,以及线上出问题时怎么一步步把它揪出来。适合被 si 和 ksoftirqd 困扰过的开发者,也适合刚入手内核网络栈、想建立直观认知的读者。
1. 软中断到底解决什么问题:设计思想拆解
1.1 硬中断为什么不适合做重活
中断本质上是一种异步事件通知。硬件需要服务时,把 CPU 正跑的任务停掉,跳转到中断处理函数。硬中断上下文有两个天然限制:第一,不能睡眠;第二,要尽量短。不能睡眠的根本原因是中断没有完整的“任务”概念,它依附于被打断的那个上下文,调度器不会像一个普通线程那样去恢复它。要短是因为硬中断执行期间,本地中断通常处于关闭状态,至少临界区里是关着的;一旦处理时间过长,其他硬件中断(时钟、磁盘、网卡)都会被挡在门外。
举一个最常见的例子:万兆网络下,64 字节小包的理论速率大约 148 万包/秒。如果每个包都要靠一次硬中断触发,并且每次中断处理只需要 1 微秒,单核每秒就要消耗 1.48 秒的 CPU 时间,显然一个核是扛不住的。而真实的中断进入/退出开销远不止 1 微秒,还有缓存污染、寄存器保存恢复这些成本。所以硬中断里只能做最紧急的“应答式”工作:读设备状态、搬运少量数据、清除中断标记,其余的都延后处理。
中断处理里也不能调用阻塞锁和很多内核 API,因为一旦睡眠或阻塞,整个系统都可能卡死。于是内核把中断处理拆成“上半部”和“下半部”:上半部是硬中断,下半部处理可以延后的事务。软中断就是下半部的核心实现之一。它把收包后继续处理协议栈、定时器到期、RCU 回调这些逻辑,放到一个比用户进程更优先、但不阻塞硬中断的上下文里执行。
1.2 下半部机制的演进:从 BH 到 softirq 再到 workqueue
早期 Linux 内核使用 BH(bottom half)机制。BH 的模型非常简单:全局一个位图,一次只允许一个下半部在系统里跑,而且有比较粗的锁保护。单核时代问题不大,到了 SMP 时代就成了明显的瓶颈——网络包稍微多点,所有 CPU 都在争同一个 BH。
后来内核引入了 softirq,每个 CPU 维护自己的 pending 位图,不同软中断向量可以在不同 CPU 上并行执行。同一个软中断在同一 CPU 上不会重入,但不同 CPU 之间可以同时跑,这就给了网络栈在多核系统里扩展的基础。NET_RX_SOFTIRQ 和 NET_TX_SOFTIRQ 就是专门为网络收发预留的独立向量,因为它们太高频,不能和其他逻辑混在一起。
tasklet 则是基于 softirq 实现的更受限版本。它挂在 TASKLET_SOFTIRQ 向量下,批量调度,同一个 tasklet 全局保证同一时刻只能在一个 CPU 上执行。对普通驱动来说,tasklet 比直接操作 softirq 简单安全,所以很多驱动用 tasklet;但网络收包路径对性能要求高,不走 tasklet,而是直接使用独立的 NET_RX 向量。
再往后还有 workqueue,它把工作放到内核线程里,运行在进程上下文,可以睡眠,适合做更慢的后台任务。选型规律可以概括成一句话:硬中断处理紧急且短的事情,软中断处理比较紧急但不能睡眠的事情,workqueue 和内核线程处理可以慢、可以阻塞的事情。理解了这个分工,后面看软中断热函数时才不会慌。
1.3 十种软中断向量,暴露了系统里谁在排队
不同内核版本里软中断向量的定义会略有差异,但主流系统上 /proc/softirqs 能看到的大致是这些:
| 向量 | 名称 | 主要用途 |
|---|---|---|
| 0 | HI_SOFTIRQ | 高优先级 tasklet |
| 1 | TIMER | 传统内核定时器,时间轮回调 |
| 2 | NET_TX | 网络发送路径 |
| 3 | NET_RX | 网络接收路径,协议栈处理大头 |
| 4 | BLOCK | 块设备完成回调 |
| 5 | IRQ_POLL | 中断轮询相关 |
| 6 | TASKLET | 常规 tasklet |
| 7 | SCHED | 调度器周期处理、负载均衡 |
| 8 | HRTIMER | 高精度定时器相关 |
| 9 | RCU | RCU 宽限期与回调处理 |
不要死记编号,不同内核编译配置会有出入。实际排查时看 /proc/softirqs 列出的名字就够了。服务器上最常见的大户是 NET_RX、NET_TX、TIMER、SCHED 和 RCU。NET_RX 高代表网络收包量大;TIMER 高可能是某个驱动或协议栈在频繁设置定时器;RCU 高通常是 RCU 回调积压,说明系统长期过载或者配置不合适。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 软中断是怎么被调度执行的:机制全流程
2.1 收包路径上,软中断是如何被“点亮”的
以网卡收包为例,完整路径大致是:数据包通过 DMA 写入网卡 ring buffer,网卡向 CPU 发一个硬中断。硬中断处理程序里,驱动先关掉该队列的中断,把 napi 实例挂到当前 CPU 的 softnet_data 的 poll 列表上,然后调用 napi_schedule,里面执行 __raise_softirq_irqoff(NET_RX_SOFTIRQ),把当前 CPU 本地 pending 位图的 NET_RX 位置 1。
注意,__raise_softirq_irqoff 只是一个设置位的操作,开销极低。之后硬中断返回,内核在 irq_exit() 里检查 local_softirq_pending(),如果发现有软中断被点亮,就调用 invoke_softirq() 进入 __do_softirq() 执行。也就是说,软中断的执行时机并不在硬中断函数内部,而是统一在中断返回路径上。这样做的好处很明显:硬中断可能短时间内来了很多个,但软中断 pending 位只需要置一次,后续到达的事件会合并到同一轮处理里,这就是中断合并的“软”层面。
执行 __do_softirq() 时,内核会通过 local_bh_disable() 把当前 CPU 的软中断禁用计数加一,这样同一个 CPU 上的软中断不会自己打断自己。软中断处理函数运行在一个特殊上下文里,既不是普通进程上下文,也不是硬中断上下文,通常叫软中断上下文。它在当前 CPU 上关闭了抢占,不能睡眠,但可以被新的硬中断打断。正因为如此,它的数据结构不需要考虑重入,代价是任何一个软中断处理耗时过长,都会拖住同一 CPU 上的其他软中断。
2.2 ksoftirqd 什么时候登场,为什么要它登场
如果网络包非常多,__do_softirq() 会一直从 pending 位图里取新的软中断执行,用户进程可能长时间得不到 CPU。为了解决这个问题,内核给软中断执行设了预算:从进入 __do_softirq() 开始记录时间,大约 2 毫秒时间预算,同时限制最多重启若干次(不同版本略有差异,常见是 10 次左右)。每处理完一轮,如果还有 pending、时间预算没到、当前进程没有被标记为需要调度,就再处理一轮;否则就调用 wakeup_softirqd(),把后续工作交给每个 CPU 专属的 ksoftirqd 内核线程。
ksoftirqd 的设计意图不是让软中断处理函数可以睡眠,而是让软中断有机会被调度器管理。当软中断在中断返回路径上长时间执行时,当前用户进程几乎无法被抢占回去;交给 ksoftirqd 后,它作为一个内核线程参与调度,用户进程至少有机会在时间片层面被调度到。很多人看到 [ksoftirqd/0] 是个线程,就以为里面的代码可以随意阻塞,这是常见的误解。ksoftirqd 虽然运行在进程上下文,但它在执行 do_softirq() 时同样处于软中断禁用状态,处理函数依然不能调用睡眠 API,这一点在写驱动或看内核代码时一定要分清。
另外,当内核开启 CONFIG_PREEMPT_RT,或者启动参数里带 threadirqs 时,软中断会被强制线程化,基本上都在 ksoftirqd 里执行。这是为了实时性做的取舍,换来的是调度可控,牺牲的是中断路径的低延迟。
2.3 软中断与硬中断的亲和性要一起看
软中断有一个很重要的特性:谁在当前 CPU 上 raise_softirq,软中断就在哪个 CPU 上执行。硬中断发生在哪个 CPU,对应软中断就从哪个 CPU 冒出来。这个设计的优点是对 cache 友好:刚处理完硬中断的 CPU,相关数据结构很可能还在 cache 里,接着处理软中断命中率高。
缺点也很明显:如果所有网卡中断都落在 CPU0,那么所有收包软中断都堆积在 CPU0 上,单核直接被 NET_RX 打满,其他核心在边上围观。网卡支持多队列时,可以通过 RSS 把不同队列的硬中断分散到不同 CPU;查看方式就是 cat /proc/interrupts,看对应网卡队列的中断数分布。修改亲和性的方式是把 CPU 掩码写到 /proc/irq/<中断号>/smp_affinity,比如 echo 2 > /proc/irq/88/smp_affinity 把 IRQ 88 定向到 CPU1。
如果网卡只有一个队列,硬件中断没法分散,此时软件层的 RPS(Receive Packet Steering)就派上用场。RPS 在驱动收包后,根据流哈希把 NET_RX_SOFTIRQ 通过 IPI 抛到其他 CPU 上执行,相当于在软件层做了一次负载均衡。它和硬中断亲和性要放在一起看,排查单核软中断 100% 时,两个都要检查。
3. 从现象到结论:软中断问题的排查路径
3.1 第一站:/proc/softirqs 怎么读才有效
很多人拿到 cat /proc/softirqs 的输出不知道看什么。其实这里只有计数器,从开机累计到现在,绝对值本身没有意义,有意义的是两次采样之间的差值。
code复制watch -n1 -d 'cat /proc/softirqs'
watch -d 会高亮变化的列,适合快速看哪个 CPU 在疯狂增长。更准确一点,可以手动隔几秒采两次,对比某个向量每个 CPU 的增量:
code复制cat /proc/softirqs
sleep 5
cat /proc/softirqs
重点关注 NET_RX 和 TIMER。假设 CPU0 的 NET_RX 五秒涨了 300 万,而 CPU1 只涨了 30 万,那基本可以断定收包软中断分配极不均衡。再看 TIMER,如果所有核的 TIMER 都在涨,可能是系统全局定时器负载高;如果单个核涨,通常是某个内核线程或驱动把定时器都压在了一个 CPU 上。
有的发行版上 /proc/softirqs 前几行是 HI、TIMER,后面才是 NET_TX、NET_RX。版本不同字段顺序会有变化,但名字基本一致。不要拿网上老文章里的固定顺序去套,直接看字段名最稳妥。
3.2 第二站:top 里的 si 和 ksoftirqd 占用
top -1 可以看到每个 CPU 的 us、sy、si、hi。其中 si 就是 CPU 花在软中断上的时间占比。如果某个核 si 长期超过 40%,基本可以判断软中断是这台机器的重要瓶颈之一。hi 是硬中断时间占比,一般很小;如果 hi 也高,说明硬中断本身太频繁,需要从中断合并角度考虑。
接着看 ksoftirqd 的 CPU 占用:
code复制pidstat -u -p "$(pgrep -d, ksoftirqd)" 1
正常情况下 ksoftirqd 几乎不占 CPU,因为大部分软中断在中断返回路径上就处理完了。如果某个 ksoftirqd/3 持续占用 50% 以上,说明 CPU3 上的软中断长期处于饱和状态,已经到了“延迟做不完,只能排队等线程来捞”的程度。
这里有个判断技巧:如果 si 高但 ksoftirqd 不高,说明软中断是短促但高频地执行,每次都能在中断返回路径里清掉;如果 ksoftirqd 也高,说明软中断执行时间已经超过了预算,系统在持续透支。前者可能是小包太多,后者可能是单包处理过重,两者后续手段不一样。
3.3 第三站:perf 把热函数从“黑盒”里捞出来
只看计数器只能知道软中断多,不知道时间花在哪。用 perf 采样是最直接的:
code复制perf top -g -a
如果想保留现场,可以:
code复制perf record -g -a -- sleep 10
perf report --stdio
看火焰图或 perf report 时,优先找 __do_softirq 下面的子函数。常见热点有这么几类:
net_rx_action下面的驱动 poll 函数、process_backlog、__netif_receive_skb_core,之后是ip_rcv、tcp_v4_rcv等协议栈函数。这类热点说明收包路径确实在消耗大量 CPU。run_timer_softirq高,说明定时器回调多,要回头查是谁在频繁设置定时器。rcu_process_callbacks高,多半是 RCU 回调积压,常见诱因是 CPU 长期过载或者内核配置与负载不匹配。- 如果
queued_spin_lock_slowpath出现且占比较高,说明软中断路径里有锁竞争,不是单纯算力不够。
perf 采样到的函数名可能因为内核版本、驱动不同而略有差异,但 __do_softirq 这个入口是稳定的。只要能先定位到这棵子树,后面的排查就不会漫无目的。
4. 精细调试:ftrace、bpftrace 与内核参数调整
4.1 用 ftrace 拿到软中断的真实耗时
/proc/softirqs 和 perf 都看不到“单次软中断执行了多久”,这两个问题用 ftrace 的事件机制可以补上。内核提供了 irq:softirq_entry、irq:softirq_exit、irq:softirq_raise 这几个 tracepoint。先挂载 tracefs:
code复制mount -t tracefs tracefs /sys/kernel/tracing
echo 1 > /sys/kernel/tracing/events/irq/softirq_entry/enable
echo 1 > /sys/kernel/tracing/events/irq/softirq_exit/enable
echo 1 > /sys/kernel/tracing/events/irq/softirq_raise/enable
echo 1 > /sys/kernel/tracing/tracing_on
sleep 3
echo 0 > /sys/kernel/tracing/tracing_on
cat /sys/kernel/tracing/trace
输出里能看到类似:
code复制<idle>-0 [003] ..s. 1234.567: softirq_entry: vec=3 [action=NET_RX]
<idle>-0 [003] ..s. 1234.570: softirq_exit: vec=3 [action=NET_RX]
两个时间戳相减就是单次 NET_RX 软中断的执行时间。如果单次经常超过几百微秒甚至毫秒级,说明软中断里做了太多事。生产环境不建议长时间开启全部 softirq 事件,会产生海量日志;可以先用 filter 只过滤某个向量:
code复制echo 'vec == 3' > /sys/kernel/tracing/events/irq/softirq_entry/filter
echo 'vec == 3' > /sys/kernel/tracing/events/irq/softirq_exit/filter
如果系统里有 trace-cmd,用起来更省事:
code复制trace-cmd record -e irq:softirq_entry -e irq:softirq_exit -a sleep 5
trace-cmd report
trace-cmd 会保存二进制格式的数据,解析起来比直接抓 trace 文件更稳,适合现场取证。
4.2 用 bpftrace 做在线频次和延迟统计
ftrace 准确但笨重,生产环境想做一个短时间内的统计,bpftrace 是更好的选择。最简单的用法是统计每个软中断向量触发的次数:
code复制bpftrace -e 'tracepoint:irq:softirq_entry { @count[args->vec] = count(); }'
跑一段时间 Ctrl-C,就能看到哪个向量被触发得最多。这个信息配合 /proc/softirqs 的增量观感会更强。
如果想知道每个向量的延迟分布,可以把 entry 和 exit 的时间戳配对:
code复制bpftrace -e '
tracepoint:irq:softirq_entry { @start[args->vec] = nsecs; }
tracepoint:irq:softirq_exit { @latency[args->vec] = hist((nsecs - @start[args->vec]) / 1000); }
'
输出会给出每个向量延迟的直方图,能明显看出大多数软中断是几十微秒内结束,还是存在一批“长尾”跑到几百微秒甚至毫秒级。如果 args->vec 在某个内核版本上不可用,先看 /sys/kernel/tracing/events/irq/softirq_entry/format 里的字段名再改。bpftrace 只在内核态做计数和直方图,开销比 ftrace 小很多,可以短时间在线采。
4.3 调优参数要改对地方:中断合并、RPS/RFS 与 budget
软中断问题不能只靠“调大某个数”解决,参数必须对着瓶颈改。
先看网卡队列数:
code复制ethtool -l eth0
如果 Combined 队列数少于 CPU 数,且驱动支持调整,可以在低峰期改成多队列:
code复制ethtool -L eth0 combined 4
再看中断合并参数:
code复制ethtool -c eth0
ethtool -C eth0 rx-usecs 32 tx-usecs 32
rx-usecs 指收到包后最多等多少微秒再触发硬中断。调大可以减少硬中断次数,但会增加单包延迟;时延敏感场景要调小,吞吐优先且 CPU 吃紧时可以适度调大。
如果网卡确实只有单队列,就启用 RPS。RPS 的作用是把一个队列的收包软中断通过软件分到多个 CPU:
code复制echo f > /sys/class/net/eth0/queues/rx-0/rps_cpus
f 表示 CPU0 到 CPU3 的掩码。配合 RFS 可以让同一流的数据包尽量在同一个 CPU 上处理,减少缓存失效:
code复制echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt
echo 4096 > /proc/sys/net/core/rps_sock_flow_entries
RPS 不是没有代价,它通过 IPI 把工作抛到别的 CPU 上,跨 CPU 调度会增加延迟。所以 RPS 掩码应该放到相对空闲的 CPU,而不是把所有核心都塞进去。
最后是 netdev_budget 和 netdev_max_backlog。/proc/net/softnet_stat 里的 time_squeeze 列持续增长,说明每次 net_rx_action 还没处理完就被预算耗尽打断了;如果 CPU 还有余量,可以适度调大 net.core.netdev_budget 和 net.core.netdev_budget_usecs。如果 CPU 已经打满,调大只会让软中断单次执行更久,进一步拖慢用户进程。每次只调一个参数,改完观察 si、ksoftirqd、softnet_stat 三个指标再决定下一步。
5. 高频陷阱与避坑心得
5.1 单核软中断 100%,不一定是“软中断疯了”
最典型的故障是:一台多核服务器,某个核的 si 到 100%,其他核全闲着,业务服务整体变慢。很多人第一反应是“软中断太多了”,其实往往是分布问题,而不是总量问题。
第一步先看硬中断分布:
code复制cat /proc/interrupts | grep eth0
如果网卡支持多队列,但所有队列的中断都落在同一个 CPU 上,就手动改亲和性:
code复制echo 2 > /proc/irq/81/smp_affinity
echo 4 > /proc/irq/82/smp_affinity
注意先确认这个中断号确实是网卡队列的,别把磁盘或别的设备的中断也改了。生产环境如果有 irqbalance 在跑,手动设置可能被覆盖;要么停掉它,要么按它的规则写配置。
如果硬中断分散了,但 /proc/softirqs 里 NET_RX 还是集中在单核,多半是这个问题:网卡队列只有一个,或者 RPS 没开。单队列网卡必须靠 RPS 把收包处理分到多个 CPU。还有一个坑:RPS 掩码如果把正在跑业务的繁忙 CPU 也包含了,IPI 会把收包软中断打到业务 CPU 上,结果是业务更卡。RPS 掩码要放给空闲 CPU,或者专设一组 isolcpus 给软中断。
5.2 软中断里的锁竞争:为什么别的软中断也被拖慢
同一个 CPU 上,软中断执行期间 local_bh_disable 生效,所有软中断向量串行。这意味着只要 NET_RX 在某一次执行里耗时过长,排在后面的 TIMER、RCU、SCHED 都得等着。表现上可能就是某个核 si 不高,但系统的整体延迟变得不稳,时钟相关处理也出现毛刺。
更隐蔽的是锁竞争。软中断在不同 CPU 上可以并行执行,但它们可能访问同一个全局数据结构,比如 qdisc、netfilter 表项、socket 锁。一个 CPU 持有锁后,其他 CPU 的软中断都在自旋等待。perf 火焰图里如果 queued_spin_lock_slowpath 占比很高,不是流量真的多到算不过来,而是大家被锁卡住了。这种问题调大 budget 没有用,反而会更糟。
遇到锁竞争,优先考虑让每个 CPU 有独立的队列或独立的数据路径,比如多队列网卡和每 CPU 的 qdisc;其次检查是否有不必要的全局规则在软中断路径里被反复匹配。RPS 虽然能分散负载,但如果分散后大家抢同一把锁,还不如集中到一个 CPU 上。这个取舍需要实测才能定。
5.3 别把重活塞进软中断,这是设计底线
最后一条是设计层面的底线。软中断出现的本意是处理“高频但轻量”的工作,它不是一个适合做重活的上下文。ksoftirqd 确实救了软中断的调度公平性,但它救不了“执行时间过长”的问题——处理函数不能睡眠,一旦在里面做重量级操作,整个 CPU 的软中断队列都会跟着遭殃。
实际踩过的坑里,最常见的重活包括:软件 IPsec 加解密、复杂的包过滤规则、conntrack 大表查找、没有开启校验和卸载时的网络包校验计算,以及某些驱动在 tasklet 里做了过于复杂的逻辑。看到 perf 里这些函数出现在软中断路径上,就要考虑优化方案:能卸载给硬件就卸载,比如 TSO、GRO、checksum offload;能挪到用户态处理就挪到用户态;异常流量和 DDoS 可以用 XDP 在驱动层直接丢掉,不要进入软中断。
最后分享一个我自己的检查顺序:先看 mpstat -P ALL 1 的 si 分布,再看 /proc/softirqs 的增量,然后 perf top -g 找热点,最后用 bpftrace 验证延迟。这个组合拳我用了很多年,绝大多数“奇怪”的软中断问题都能在中间某一步现出原形。软中断这层窗户纸捅破之后,你再看到 si 高就不会慌了——它只是一个信号,真正要回答的是:谁在排队、为什么慢、能不能把它挪走。
