先从一个现象说起。有段时间线上某系统的一台机器,CPU 的 si(softirq)占比一直徘徊在 40% 上下,业务没有崩溃,但接口耗时偶发飙高。用 top 看进程列表根本看不出端倪,因为软中断这部分消耗不挂在哪个进程头上,它绕过了进程调度器直接跑在 CPU 上。最后还是靠 /proc/softirqs 连续采样,再配合 perf 热点,才定位到是网卡 RX 软中断集中在两个核上。这个经历让我意识到,Linux 软中断如果只停留在“中断下半部”的抽象概念,排查问题时很容易被表象带偏。
这篇文章我不想写成手册式科普,而是想顺着“为什么要有软中断→它到底怎么运行→高频场景里它做了啥→出问题时怎么查”这条线,把 Linux 软中断从设计思想到实战调试整个串一遍。适合刚接触内核中断机制的同学,也适合追过一阵子性能问题但总觉得差层窗户纸的同行。
1. 软中断为什么存在:把“下半部”这件事想透
1.1 中断处理为什么不能全在硬中断里做完
硬件中断处理程序要求“短小精悍”。拿网络收包来说,网卡一收到报文就会触发中断,CPU 要立刻暂停当前工作,进入硬中断上下文,把报文从网卡队列搬进内存,更新统计计数,再决定要不要唤醒协议栈。这个过程如果在硬中断里全部做完,会有两个绕不开的问题:一是在处理期间本地 CPU 的中断基本是被屏蔽的,其他硬件发来的中断只能排队等,时间长了一方面丢失中断,另一方面整体 CPU 被拖成“只能忙于应付打断”;二是硬中断上下文里不允许睡眠,很多协议栈处理、锁竞争、内存操作根本不适合在这个环境里做。
这就是“下半部”机制的由来:硬中断里只做最关键、最紧急的一小段,比如保存寄存器、搬运数据、置一个标志位;剩下可以稍微缓一缓的事情,留到中断被重新打开之后再处理。Linux 里承担这个角色最重要的机制之一,就是软中断(softirq)。名字带 soft,但它并不是“更软的进程”,它仍然运行在中断上下文的边缘,有着严格的限制:不能睡眠、不能长时间占用、必须小心处理与硬件中断的并发关系。
一个比较贴切的生活类比是:硬中断像前台接待,客人来了先登记,不能拉着客人聊半小时;软中断像后台业务员,前台把需求记下来转给它,业务员可以在后台慢慢处理,但依然有下班时间限制,不能拖到第二天。
1.2 从 BH、tasklet 到 softirq:下半部机制的演进逻辑
Linux 早期的下半部机制叫 BH(Bottom Half),实现方式非常朴素:一个全局列表,所有想延迟执行的工作都挂在里面,执行的时候整个列表在一个 CPU 上串行跑完。问题很明显,SMP 时代一来,同一个 BH 列表不可能让多个 CPU 同时执行,扩展性直接被锁死。后来演进出了 tasklet,它挂在 softirq 之上,约束更友好:同一种 tasklet 在任何时刻只能在一个 CPU 上执行,不同的 tasklet 可以在不同 CPU 上并行。写驱动时你不需要自己处理同一个 tasklet 的并发,方便了不少。
但 tasklet 的“同一串行”意味着高吞吐场景吃不满多核。网络子系统这种动不动每秒百万包级别的路径,不可能让同一个收包动作只在单核上转。于是 softirq 被设计成:同一个向量可以在多个 CPU 上同时执行,共享数据需要各子系统自己用 per-cpu 变量或锁来保护。这确实给编码增加了难度,但也换来了性能上限的提升。
从 BH 到 tasklet 再到 softirq,本质上是在「开发者友好」和「扩展性」之间不断做权衡。网络、块设备、RCU 这些对性能和扩展性极度敏感的子系统,最终都选择直接使用 softirq;普通驱动想省事,就用 tasklet。理解了这段演进,再看代码里的 open_softirq()、tasklet_schedule() 就不会觉得是一堆散装的 API 了。
1.3 软中断家族:从 HI_SOFTIRQ 到 RCU_SOFTIRQ
不同架构、不同内核版本里 softirq 向量号的顺序会有细微差别,但主流内核里你只要打开 /proc/softirqs,看到的列名基本就是下面这张表的行。向量号从 0 开始排,数值越小,在 __do_softirq() 里越先被执行。
| 向量名(常见顺序) | 作用 | 典型触发点 |
|---|---|---|
| HI_SOFTIRQ | 高优先级 tasklet | 高优先级延迟任务调 tasklet_hi_schedule() |
| TIMER_SOFTIRQ | 传统定时器到期的处理 | 时钟中断里 raise_softirq(TIMER_SOFTIRQ) |
| NET_TX_SOFTIRQ | 网络发送路径的收尾 | 发送队列重新唤醒、发送完成回调 |
| NET_RX_SOFTIRQ | 网络收包轮询处理 | NAPI 调度 napi_schedule() |
| BLOCK_SOFTIRQ | 块设备 IO 完成回调 | blk_done_softirq() |
| IRQ_POLL_SOFTIRQ | 中断轮询支持 | 块设备 IO 轮询模式 |
| TASKLET_SOFTIRQ | 普通 tasklet | tasklet_schedule() |
| SCHED_SOFTIRQ | 调度器负载均衡与唤醒迁移 | 调度器周期性触发 |
| HRTIMER_SOFTIRQ | 高精度定时器回退处理 | 高精度定时器到期 |
| RCU_SOFTIRQ | RCU 回调处理与宽限期推进 | rcu_pending() 或 raise_softirq(RCU_SOFTIRQ) |
这些向量并不是每个都会在你的系统里有明显消耗。常规 Web 服务上,排在头部的通常就是 TIMER、NET_RX、NET_TX、RCU 这几个;如果跑数据库或重 IO,BLOCK 也会涨。看到 /proc/softirqs 里的列,第一反应应该是对着这张表想“这个向量背后是什么机制在触发”,而不是慌着改参数。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从 raise 到 ksoftirqd:软中断运行的完整路径
2.1 两条触发路径:中断返回现场处理与 ksoftirqd 兜底
软中断的触发入口绝大多数是 raise_softirq()。这个函数做的事情很直接:把对应向量号置入当前 CPU 的 __softirq_pending 位图。关键在后面——如果调用者当前不在中断上下文,它会唤醒本 CPU 的 ksoftirqd 内核线程;如果调用者在硬中断或软中断上下文,它只置位,让中断返回路径去处理。
为什么会这样设计?因为硬中断里可以安全地推迟工作,反正退出硬中断时会有一段统一检查的地方。这个统一检查点就是 irq_exit()。每次硬中断处理函数执行完毕,CPU 即将返回被打断的进程之前,irq_exit() 会看软中断位图。如果有 pending,就直接调用 invoke_softirq() 进入 __do_softirq(),在“当前 CPU 上、以中断返回的时机”把软中断跑了。
第二路径的主角是 ksoftirqd。每个 CPU 都有一个内核线程,名字类似 ksoftirqd/0、ksoftirqd/1。它的存在是为了解决“软中断积压到影响实时任务”的情况。__do_softirq() 每次运行都有限制条件,一旦发现待处理的软中断太多、时间太长,就会主动退出,唤醒 ksoftirqd,把剩余工作放到内核线程里去慢慢消化。因为内核线程是可调度的,所以它不会像中断上下文那样把 CPU 死占着不放。
这两条路径一定要记牢:正常低负载时,软中断是“搭硬中断的便车”直接跑的;高负载时,软中断会被踢给 ksoftirqd 线程。观察 top 时,看到 ksoftirqd/0 飙高,其实说明软中断曾经积压过,或者有代码在非中断上下文频繁主动 raise。
2.2 pending 位图与重入限制:为什么能多核并行又要关抢占
软中断每个 CPU 都维护了一份独立的状态位,表示本 CPU 有哪些 softirq 待处理。置位和检测都是基于本地 CPU 的,没有全局锁,所以多核之间天然隔离。但同一个向量的处理函数可能同时跑在多个 CPU 上,比如 NET_RX_SOFTIRQ,这个向量在 CPU0 和 CPU1 上可以同时执行。因此写软中断处理函数时,共享数据必须要有锁或 per-cpu 变量保护。
在单个 CPU 内部,软中断还有一个人为的“禁入”机制:local_bh_disable()。它通过修改进程抢占计数里的软中断计数位,禁止本 CPU 上继续进入软中断处理流程。这个机制对网络协议栈特别重要,很多路径在操作共享数据结构时会先 local_bh_disable(),防止自己的操作被 softirq 打断、导致死锁。
另外,软中断上下文会关闭内核抢占,但还是会响应更高优先级的硬件中断。也就是说,软中断运行过程中,如果来了一个新硬中断,它会暂停,让硬中断跑完,再回来继续跑软中断。硬中断里如果又 raise 了同一个 softirq,由于位图已经在位,当前 CPU 不会并发重入,但 __do_softirq() 的下一次循环可能还会再处理它一遍。如果你在软中断处理函数里无条件加锁,很可能锁不住预期的临界区,因为这些安全边界和用户态线程完全不同。
2.3 执行循环的退出条件:别让一个软中断霸占 CPU
__do_softirq() 本身就是一个循环。它先把当前 pending 位图一次性取出来,逐个执行对应向量,然后看有没有新来的 pending。如果还有,在满足条件的情况下继续再跑一轮。但这个循环不是无限跑下去,内核设置的退出条件主要有三个:
- 处理轮数达到
MAX_SOFTIRQ_RESTART,常见值是 10,跑太多轮说明积压严重; - 处理总时间超过
MAX_SOFTIRQ_TIME,通常是 2 毫秒级别; - 进程被设置了
need_resched,说明有高优先级任务在等 CPU。
任何一个条件满足,__do_softirq() 就收手,把后续工作交给 ksoftirqd。理解这个循环很重要:很多人调软中断只知道改参数,但改来改去没理解预算。网络子系统的 netdev_budget 只是它内部一轮轮询的处理上限,外层软中断还在用轮数、时间、调度标志来兜底。一旦你手动把某个预算调得巨大,外层时间限制很快会生效,等于把压力转移给了 ksoftirqd,而不是真正解决问题。
3. 高频场景拆解:网络收包、定时器和 RCU 都在忙什么
3.1 NET_RX_SOFTIRQ 和 NAPI:收包时软中断到底在忙什么
网络收包是软中断最典型的应用场景。报文到达网卡,硬中断处理程序只做一件事:把报文从 DMA ring 搬到 sk_buff,然后调用 napi_schedule(),把 NAPI 实例挂到当前 CPU 的 poll 列表上,raise 一个 NET_RX_SOFTIRQ。真正的协议栈处理,比如 IP 层、TCP/UDP 层、socket 接收队列,全部放到 net_rx_action() 里执行。
NAPI 的核心思想是轮询与中断结合:高吞吐时减少中断次数,让软中断主动去网卡队列里搬包。软中断处理时,net_rx_action() 会遍历 poll 列表,逐个执行驱动的 poll 回调。为了防止一次中断把 CPU 吃满,内核给网络收包设定了两个预算:一个是每次软中断最多处理的报文数,对应 net.core.netdev_budget;另一个是时间预算,对应 net.core.netdev_budget_usecs。预算用完后如果还有包,NAPI 会重新排列 poll 列表,并再次触发 NET_RX_SOFTIRQ,让下一轮继续。
排查网络软中断偏高时,首先要区分是“小包多”还是“包很大导致 CPU 处理慢”。小包场景下,硬中断频率高、NAPI 轮询次数多,softirqs 里 NET_RX 涨得飞快;大包场景则往往表现为内存拷贝内存分配开销大。同一个 si 高,背后的优化方向完全不一样。所以第一反应不该是拼命调 netdev_budget,而是要搞清楚到底是频率高还是单次处理成本高。
3.2 TIMER_SOFTIRQ 与 HRTIMER_SOFTIRQ:时间到了以后的事
定时器是另一种特别容易被忽略的高频软中断。每个 CPU 都有自己的定时器队列,传统 timer_list 定时器在时钟中断里到期后,会通过 raise_softirq(TIMER_SOFTIRQ) 让软中断去执行回调函数。如果你在应用层大量使用短周期定时器,或者内核组件里有频繁的 jiffies 轮询,TIMER 向量就会明显上涨。
高精度定时器(hrtimer)的情况稍微不一样。早期 hrtimer 的回调大多直接在硬中断上下文执行,后来为了保证系统不会因为个别超长回调卡死,也引入了 HRTIMER_SOFTIRQ。所以你在 /proc/softirqs 里看到 HRTIMER 增长,通常说明高精度定时器回调通过软中断回转执行了。
排查 TIMER 高,最直接的办法是用 perf 看热点符号。如果热点集中在 run_timer_softirq() 或 __run_timers(),再去查是哪个模块或设备注册了大量定时器。常见的坑是内核驱动退避重试、网络 ARP 过期表扫描、文件系统日志定时刷新,这些都会让 TIMER 在特定业务下暴涨。
3.3 RCU_SOFTIRQ:读多写少同步机制为何依赖软中断
RCU(Read-Copy Update)是一种读多写少场景下的同步机制。它不需要读端拿锁,代价是写端要等宽限期(grace period)结束后才能回收旧数据。宽限期的推进和回调函数的执行,很多情况下不能立刻做,于是 Linux 把 RCU 的部分工作放到了软中断的 RCU_SOFTIRQ 向量里。每当 CPU 发现本地有 RCU 回调需要处理时,就会 raise RCU_SOFTIRQ。
RCU 软中断的出现频率和三个因素关系很大:一个是 CPU 上 RCU 回调积压数量;一个是 CPU 在 idle 和 busy 状态之间切换的频率;还有一个是内核是否配置了抢占 RCU。生产环境常见的“rcu_sched self-detected stall”报错,本质是某个 CPU 长时间没处理 RCU 软中断,宽限期无法推进。这种情况往往不是因为 RCU 本身坏了,而是因为那个 CPU 上硬中断被长时间关闭、或者被高优先级任务饿到无法进入软中断处理。
因此看到 RCU_SOFTIRQ 高,不要盲目调 RCU 参数。先确认 CPU 是否处于 overload、是否有隔离核、是否被某些驱动的关中断代码拖住。RCU 是内核基础设施,随便一个进程行为的变化都会让它波动,单靠优化 RCU 自己往往治标不治本。
3.4 BLOCK_SOFTIRQ 和 TASKLET_SOFTIRQ:那些容易被忽略的角落
块设备 IO 完成也有自己的软中断向量。传统块设备驱动在硬中断里完成 IO 后,往往会触发 BLOCK_SOFTIRQ,把块层回调、IO 完成通知放回软中断上下文执行。这样做的目的是减少硬中断里的锁竞争,让块层能更从容地批量处理完成事件。数据库服务器上,BLOCK 向量的增量通常和每秒 IO 完成次数强相关。
TASKLET_SOFTIRQ 则是一个兼容面非常广的向量,很多老的驱动把卸载、复位、状态检查这类工作丢给 tasklet。它的优点是使用简单,缺点也很明显:同一类型的 tasklet 不能并行。如果某个驱动在 tasklet 里做了非常重的操作,比如遍历一个大链表、长时间自旋,那么所有挂在同一个 tasklet 上的工作都会被卡住。
碰到 TASKLET 高,最好先通过 perf 找到具体的 tasklet action 函数,再看是不是某厂商驱动实现不合理。如果业务允许,尽量把高吞吐路径上的 tasklet 改成专属 softirq 或 workqueue,避免一个慢 tasklet 拖累全局。
4. 实战排查:从 CPU 的 si 列一路查到根因
4.1 第一板斧:用 /proc/softirqs 做增量采样
一旦发现 CPU 的 si 占用偏高,第一步一定是打开 /proc/softirqs。这个文件实时展示每个 CPU 上各 softirq 向量的累计触发次数。注意是累计次数,不是速率,直接看绝对值没有任何意义,必须要做增量采样。
我常用的方法非常土,但很管用:先把当前状态抓一份,等 5 到 10 秒再抓一份,两份相减,得到这段时间内各 CPU 的新增次数。执行命令大概是:
bash复制cat /proc/softirqs > /tmp/sirq_before
sleep 5
cat /proc/softirqs > /tmp/sirq_after
然后打开两个文件对比。重点关注两件事:第一,哪个向量涨得最快;第二,这个向量是不是集中在某一个或某几个 CPU 上。比如 NET_RX 增量全部砸在 CPU0 和 CPU1,而 CPU2、CPU3 几乎没有,那就是典型的收包软中断分配不均。top 里看到两个核 si 高、其余核 si 低,常常就是这种结果。
还可以用:
bash复制watch -n1 -d cat /proc/softirqs
让变化的地方高亮出来。虽然它不显示增量数值,但能快速看出哪些 CPU、哪些列在持续跳,适合第一轮粗筛。
4.2 第二板斧:mpstat、perf 与 bpftrace 立体定位
/proc/softirqs 告诉我们“哪个向量在涨”,但还没告诉我们“处理代码在干什么”。第二步用 mpstat 看瞬时 CPU 状态:
bash复制mpstat -I CPU 1 5
关注 softirq 和 intr 两列。softirq 列高说明软中断开销大,intr 列高说明硬中断本身超载。很多网络小包场景,intr 和 softirq 同时高,因为硬中断负责进场,软中断负责处理。
第三步用 perf 看热点函数。最简单的:
bash复制perf top -F 99
看有没有 net_rx_action、process_backlog、run_timer_softirq、rcu_core 这些符号。符号对应哪个向量是明确的:net_rx_action 对应 NET_RX,run_timer_softirq 对应 TIMER,等等。如果热点扎在内存分配或锁上,那就不是“软中断机制”本身的问题,而是某个处理路径开销过大。
想统计各 softirq 的触发次数,可以用内核 tracepoint。系统里通常有 irq:softirq_entry、irq:softirq_exit、irq:softirq_raise 这些事件。有 bpftrace 的环境可以直接跑:
bash复制bpftrace -e 'tracepoint:irq:softirq_entry { @[args->vec] = count(); }'
跑完会输出每个向量编号的触发次数,结合前面的向量表就能对上。如果没装 bpftrace,去 /sys/kernel/debug/tracing/events/irq/ 下看 softirq_entry 的格式也一样能实现。这一步的价值是量化,而不是继续凭感觉猜。
4.3 第三板斧:中断亲和性、RPS/RSS 与预算参数
定位到向量之后,就要开始“动手”。网络场景最常见的修复路径有三条。
第一条是硬件层分流,也就是 RSS。多队列网卡可以通过 ethtool -L eth0 combined 4 开启多个队列,每个队列有独立中断号。用 cat /proc/interrupts 找到网卡的中断号,然后写 /proc/irq/<中断号>/smp_affinity 把不同中断绑定到不同 CPU。要注意 smp_affinity 是十六进制掩码,比如 echo 2 > /proc/irq/75/smp_affinity 是绑定到 CPU1。如果你的系统开了 irqbalance,它可能定期改写这块,低延迟场景想完全掌控可以临时关掉,但关之前要有数据支撑。
第二条是软件层分流,也就是 RPS。单队列网卡或者队列数少于 CPU 数量时,RPS 能模拟多队列效果。方法很简单:
bash复制echo f > /sys/class/net/eth0/queues/rx-0/rps_cpus
这样把收到的包在 CPU0 到 CPU3 之间做软件分发。RPS 会引入额外的 IPI 和远端 CPU backlog 处理,未必适合所有业务,需要前后对比。
第三条是调整网络软中断预算。net.core.netdev_budget 和 net.core.netdev_budget_usecs 控制单次软中断里 net_rx_action 最多处理多少报文、多长时间。调大能让单次软中断处理得更久,减少反复进入软中断的次数;调小能更公平地让其他向量插进来,但可能造成收包吞吐下降。具体默认值不同内核版本有差异,不要照搬网上的数值。
4.4 案例复盘:一次 NET_RX 软中断抬升的排查记录
这里用一个脱敏后的实际排障过程来说明整套流程。某项目代号 X,线上是一台 16 核的 nginx 网关,最近流量高峰时 CPU 的 si 长时间超过 30%,接口 p99 明显变差。
第一轮采样 /proc/softirqs,发现 NET_RX 增量巨大,而且分布非常偏:CPU0 每秒涨约 80 万次,CPU1 涨约 60 万次,CPU2 到 CPU15 基本只有零星的网络发送。同时 mpstat -I CPU 显示 CPU0 和 CPU1 的 softirq 几乎打满。到这里,问题基本收敛到“收包软中断分配不均”。
第二轮看 /proc/interrupts,网卡是四队列的,但四个收发队列的中断号全部落在了 CPU0 和 CPU1 上,另外两个队列绑到了同一个核。接着用 ethtool -L 确认队列开启状态,发现队列 0 和队列 1 的 RSS 哈希配置没问题,是系统里的 irqbalance 在动态迁移时把队列都挤到了低编号 CPU。
第三轮处理:先临时关掉 irqbalance,手动把四个网卡队列的中断掩码分别设为 1、2、4、8,对应 CPU0 到 CPU3。改完后不要立刻看结果,等了一分钟流量平滑后再次采样 /proc/softirqs,四个 CPU 的 NET_RX 增量不再是一边倒,整体 si 占比降到 10% 以内,p99 恢复。
这个案例最有价值的不是“分散中断”这个操作,而是整个定位链条:先看向量的分布,再看中断号的分布,最后做一比一的映射。没有前面两步,直接去改 smp_affinity 就是蒙。
5. 常见脑洞与避坑清单
5.1 误区一:软中断一定比硬中断“轻”
很多人以为软中断既然叫“软”,就应该比硬中断更省事、更安全。实际上,软中断只是在“允许中断被打断”这一点上比硬中断放松了,它仍然是运行在内核上下文里的紧急处理路径。一个设计不良的软中断处理函数,完全可以吃掉整个 CPU。更危险的是,软中断上下文不能睡眠,如果某段代码在里面自旋等待,那整个 CPU 上的其他软中断都会被拖下水。
系统里出现的所谓“软中断 CPU 占用高”,很多时候不是机制选型的问题,而是某个回调干得太多。只有把热点符号挖出来,才能判断是“该用软中断但写复杂了”,还是“根本不该用软中断,应该换成工作队列线程化处理”。
5.2 误区二:看到 ksoftirqd 高就急着关 irqbalance
ksoftirqd 全高,不代表“软中断线程太多”,更不是“irqbalance 把它搞坏了”。它只是表示软中断曾经积压过,或者有非中断上下文主动 raise softirq。如果你一上来就 systemctl stop irqbalance,在大部分带多队列网卡的服务器上,反而可能让中断更加集中在几个核上,si 更高。
正确姿势是先在 /proc/softirqs 里确认到底是哪个向量积压,再看这个向量对应的硬中断是不是分配不均。ksoftirqd 是一个结果指标,不是一个治疗开关。
5.3 避坑一:在虚拟化环境里看 si 要有物理 CPU 视角
虚拟机上观察 si 波动时需要额外小心。云主机看到的 vCPU,底层可能是多个虚拟机共享的物理核,一旦宿主机的超线程或邻居虚拟机抢占资源,软中断处理会表现得非常不稳定。这时 mpstat 里的 %steal 往往很高,但 /proc/softirqs 的增量可能不高,因为很多时间是在等待物理资源。
优先级应该是:如果 %steal 高,先处理资源竞争,而不是急着调内核参数。否则你调了半天,流量一波动又回去了。
5.4 避坑二:永远先采样再动手
我见过太多人看到 si 高,第一个动作就是调 netdev_budget、关 irqbalance、改 RPS,一顿操作后看起来好像好了,但过了半小时又复发。其实根本原因是某个应用每整点刷缓存,导致突然的流量尖峰。没有基线数据,任何判断都可能是错的。
正确做法是在现场留一个后台脚本,每 5 秒记录一次 /proc/softirqs、/proc/interrupts 和 mpstat 输出,存到本地文件。等复现后再对比前后差异,能少走很多弯路。
最后说说我自己的习惯。每次遇到软中断相关的问题,我会先确认当前机器有没有足够的采样数据,没有就先补数据,而不是急着打开工具。排查过程中每改一个参数,都要重新采样一轮,只有数据变化和业务变化能对上,才说明这一刀切在点子上。软中断问题往往很反复,带着数据下结论,比带着经验猜根因靠谱得多。
