暗色模式

Linux 网络收包软中断:NET_RX 计数、netdev_budget 与丢包定位

技术教程
2026-10-04
2
0
本文要点
  • 网卡收包不是在硬件中断里干完的:硬中断只做登记并触发软中断,真正把报文搬进协议栈的是 NET_RX 软中断,/proc/softirqs 里能看到它开机以来的累计执行次数。
  • 实测:20000 个 UDP 报文让 NET_RX 从 4079482 涨到 4099491(+20009),而网卡硬中断只 +5——环回流量根本不进网卡,软中断与硬中断是两本账。
  • 同一批报文,应用一个都读不到:InDatagrams 卡在 162806 不动,RcvbufErrors 却涨了 19907——接收缓冲区满,内核直接丢,应用毫无感知。
  • net.core.netdev_budget 是软中断一次运行能处理的上限(默认 300)。把它临时改成 1,30000 个报文让 time_squeeze 涨了 30003,几乎每个报文都撞上预算上限、剩下的交给 ksoftirqd。
  • 三个计数器对应三类故障:softnet_stat 的 dropped 是内核队列丢包(网络侧),RcvbufErrors 是 socket 缓冲区丢包(应用不读),time_squeeze 是软中断来不及处理(预算与 CPU)。

网卡收到包,第一现场不是你的进程

一台服务器每秒收到几十万个包,如果每个包都让 CPU 停下来跑一遍完整的协议栈,任何应用都别想干活。所以 Linux 把「收包」拆成了两段:

  • 硬中断(/proc/interrupts 里那个不断增长的数字):网卡驱动的中断处理函数只做最少的活——把网卡里的描述符记一笔、触发一次软中断,然后立刻返回,让被中断的进程继续跑;
  • 软中断(/proc/softirqs 里的 NET_RX):真正的重活在这里做,从网卡取数据、走 IP/TCP/UDP 协议栈、把报文挂到对应 socket 的接收队列上。

软中断在中断返回的路径上执行(相当于「顺手做完」),如果量大到让某个进程饿死,内核还有一条兜底路径:把剩下的活丢给一个内核线程 ksoftirqd,用普通进程的身份慢慢处理。这也是为什么你会看到 ksoftirqd/0 这个进程——它平时几乎不干活,但你必须认识它。

这篇文章不讲协议栈的实现细节,只做一件事:在演示机上把这三个账本(/proc/softirqs、/proc/interrupts、/proc/net/softnet_stat)的读数对上,然后看看压满网卡软中断到底会发生什么。

/proc/softirqs:软中断的十本账

cat /proc/softirqs
grep virtio4-input /proc/interrupts

演示机的 /proc/softirqs:只有一个 CPU0 列,NET_RX 4079428、TIMER 4578011、RCU 3443228、BLOCK 997638、HRTIMER 224677、TASKLET 511、HI 3、NET_TX 2,IRQ_POLL 与 SCHED 都是 0;/proc/interrupts 里 33 号 virtio4-input.0 中断是 3044438 次

       CPU0
HI:          3
TIMER:   4578011
NET_TX:        2
NET_RX:  4079428
BLOCK:    997638
IRQ_POLL:      0
TASKLET:     511
SCHED:         0
HRTIMER:  224677
RCU:     3443228
  33:      3044438  PCI-MSI 278529-edge      virtio4-input.0

几点解读:

① 每一项都是「开机以来这个软中断被执行了多少次」,每 CPU 一列。 这台机器是 1 核(nproc 为 1),所以只有 CPU0 一列;多核机器上会有 CPU0、CPU1……每列各自计数,排查「单个核被收包打满」时看的就是这些列是否悬殊。

② NET_RX 是主角,NET_TX 几乎为零。 收包 407 万次,发包相关的软中断只有 2 次。这不是说这台机器只收不发(SSH 会话一直在发),而是发包的开销记在发送进程自己身上——sendto 是进程主动发起的系统调用,不需要软中断来「异步接手」。收发不对称是正常的。

③ TIMER(457 万)和 RCU(344 万)比 NET_RX 还高,BLOCK(99 万)也不低。 这三个是后台常规工作:定时器、RCU 宽限期回收、块设备 IO 完成。看到它们数值大不用紧张——机器开了 5 天,这些计数就是这么涨上去的。判断是否有问题要看增量的速率,而不是绝对值。

④ 下半屏的 virtio4-input.0 就是网卡。 它是 33 号中断,累计 304 万次——这是网卡收到包之后触发的硬中断次数。名字里的 virtio4 是设备名,配合 journalctl -k 里那句 virtio_net virtio4 eth0: renamed from ens17 可以确认它就是 eth0。

注意 407 万(NET_RX)和 304 万(网卡中断)这两个数并不相等,而且差额不小。下面用一个受控实验来解释差额从哪来。

实验:20000 个环回报文,看谁在动

实验设计:往本机环回地址发 20000 个 1024 字节的 UDP 报文。接收端只绑定端口、从不读取——这样既能制造真实的内核收包路径,又能观察「应用不读」的后果。为了让增量看得清楚,每项指标都在发送前后各取一次值:

awk '/NET_RX/{print "NET_RX:", $2}' /proc/softirqs
awk '/^Udp:/ && $2 ~ /^[0-9]+$/ {print "InDatagrams:", $2, "RcvbufErrors:", $6}' /proc/net/snmp
grep virtio4-input /proc/interrupts
python3 - <<'PY'
import socket
rx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
rx.bind(('127.0.0.1', 9099))
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for _ in range(20000):
    tx.sendto(b'x' * 1024, ('127.0.0.1', 9099))
PY
awk '/NET_RX/{print "NET_RX:", $2}' /proc/softirqs
awk '/^Udp:/ && $2 ~ /^[0-9]+$/ {print "InDatagrams:", $2, "RcvbufErrors:", $6}' /proc/net/snmp
grep virtio4-input /proc/interrupts

环回压力前后的四组数字:NET_RX 从 4079482 涨到 4099491(+20009);InDatagrams 保持 162806 不变;RcvbufErrors 从 98025 涨到 117932(+19907);virtio4-input.0 中断只从 3044470 涨到 3044475(+5)

NET_RX: 4079482
InDatagrams: 162806 RcvbufErrors: 98025
  33:      3044470  PCI-MSI 278529-edge      virtio4-input.0
NET_RX: 4099491
InDatagrams: 162806 RcvbufErrors: 117932
  33:      3044475  PCI-MSI 278529-edge      virtio4-input.0

四组数字,每一组都说了话:

① NET_RX 涨了 20009。 20000 个报文,多出来的 9 个是这次 SSH 会话自己的报文(前后几次命令的输出、TCP 确认都是在同一个时间窗口里收发的)。在环回这种极端场景下,几乎一个报文对应一次软中断执行——这是这台机器、这个批量下的实测比例,不是通用规律(真实网卡走 NAPI 轮询,一次软中断会批量处理很多个报文)。所以请把 NET_RX 理解成「软中断跑了多少次」,而不是「收了多少个包」——想数包应该看 /proc/net/dev 的字节数或应用侧计数器。

② 网卡硬中断只涨了 5。 这 5 次还是 SSH 自己的流量——环回的报文根本不经过网卡,自然也不触发 virtio4-input.0。这就是上一节里 NET_RX(407 万)比网卡中断(304 万)多出 100 万的原因之一:环回、虚拟接口、隧道这些路径上的报文一样要走 NET_RX 软中断,却不进物理网卡的中断统计。「软中断计数暴涨但网卡中断纹丝不动」并不矛盾,两者是不同层的账。

③ RcvbufErrors 涨了 19907,InDatagrams 一个都没涨。 这是这次实验最有价值的一条。19907 个报文被内核直接丢掉,原因是接收端 socket 的缓冲区满了——应用从头到尾没有调用 recvfrom,缓冲区塞满之后,内核不会排队等应用,而是立刻丢弃并记一笔。

反推一下:20000 个报文里只有 93 个没被丢(20000 − 19907),默认大小的接收缓冲区大约就装得下 93 个 1KB 报文(内核里每个 skb 的真实内存开销远大于数据本身的 1024 字节)。而 InDatagrams 的口径是「已交付到用户态」——报文只是躺在缓冲区里没人读,所以这个数字纹丝不动。

这三个数字合起来,是「应用不读」最典型的指纹:内核侧丢包计数暴涨,应用侧统计为零,应用自己完全不知道发生过什么。 如果你的服务出现了莫名的请求丢失,而 RcvbufErrors 在涨,问题不在网络,在这个没及时读 socket 的进程。

softnet_stat:内核自己的收包账本

/proc/net/softnet_stat 是软中断这一层的专用账本,一行一个 CPU,所有数字都是十六进制。这台 1 核机器只有一行:

cat /proc/net/softnet_stat
00359e10 00000000 00000167b1 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000

一行 13 列,本文只用到前 3 列(它们的含义已经被上面的实验证实):

列名称含义这台机器的值
1processed软中断这一层处理过的报文数0x359e10 = 3513872
2dropped内核收包队列满而丢掉的报文数0
3time_squeeze软中断因耗尽预算/时间而被中断的次数0x167b1 = 92081

一定要换算十六进制。 00000167b1 看着人畜无害,换算成十进制是 92081——这个数字是下面那次实验留下的,也正是它的名字「time squeeze(时间被挤没了)」的含义:软中断想一次多处理一些报文,但预算用完了,只能先让出去,剩下的活留给别人。

第 2 列 dropped 在这台机器上自始至终是 0(我进机器时专门确认过基线的 dropped=0、time_squeeze=17,开机 5 天来没有丢过包)。它和 RcvbufErrors 的区别很关键:dropped 是内核还没走到 socket 就丢了(收包队列 netdev_max_backlog 溢出,问题在网络侧或内核侧),RcvbufErrors 是已经交付到 socket 但应用没读(问题在应用侧)。排查的方向完全不同。

顺便给一个基准感受一下量级:这台机器闲置着、只有我的 SSH 会话在跑时,NET_RX 每 5 秒只涨 32 次。所以要判断「有没有异常流量」,看的是这个速率的量级变化,而不是计数器的绝对值。

把 netdev_budget 改成 1:让每个报文都撞墙

time_squeeze 平时几乎不涨,怎么让它涨起来?控制它的旋钮叫 net.core.netdev_budget——软中断一次运行最多处理多少个报文,默认 300(同一个控制面上还有 net.core.netdev_budget_usecs,默认 8000 微秒,以及收包队列上限 net.core.netdev_max_backlog,默认 1000)。

下面把预算临时压到 1(每个报文处理完就必须让出),再发 30000 个报文,观察 time_squeeze 的变化,最后把预算改回 300:

python3 -c "f=[int(x,16) for x in open('/proc/net/softnet_stat').read().split()]; print('time_squeeze before:', f[2])"
sysctl -qw net.core.netdev_budget=1
python3 - <<'PY'
import socket
tx = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
for _ in range(30000):
    tx.sendto(b'x' * 1024, ('127.0.0.1', 9099))
PY
sysctl -qw net.core.netdev_budget=300
python3 -c "f=[int(x,16) for x in open('/proc/net/softnet_stat').read().split()]; print('time_squeeze after :', f[2])"
cat /proc/net/softnet_stat

把 netdev_budget 压到 1 再发 30000 个报文:time_squeeze 从 62078 涨到 92081(+30003),随后的 softnet_stat 原始行第三列 00000167b1 换算成十进制正好是 92081

time_squeeze before: 62078
time_squeeze after : 92081
00359e10 00000000 00000167b1 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000

30000 个报文,time_squeeze 涨了 30003——几乎每个报文都撞上了预算上限。预算被压到 1 之后,软中断处理一个报文就得让出 CPU,剩下的报文由内核线程 ksoftirqd 接手处理(这次实验里接收端已经不存在了,端口无人监听,报文会走完协议栈后回 ICMP 端口不可达,但收包路径一步不少)。

这次折腾的代价有多大?看一下 ksoftirqd/0 这个进程:它的累计 CPU 时间是 13.58 秒,而进程年龄是 454307 秒(约 5.26 天,等于开机时长)——占 CPU 的 0.003%。哪怕刚做过「每个报文都撞墙」的压力,它也没有出现可观测的跳变。

这个结论在小机器上很有用:收包软中断本身的 CPU 开销极低,它的可怕之处不在于「贵」,而在于它不需要你的许可就能抢占所有进程的时间——真到了扛不住的时候,表现不是 ksoftirqd 很忙,而是整台机器的延迟一起变差。

顺带说一句,sysctl -w 只在运行时生效、重启即恢复,做实验不怕忘;但即便如此,我在实验结束后立即把它改回了 300 并回读确认。演示机上的原则是:任何临时改动,当场还原。

三个计数器,三类故障

把上面所有测量结果整理成一张对照表——这是这篇文章最想留下的东西:

计数器位置实测现象谁的问题
dropped/proc/net/softnet_stat 第 2 列本机 5 天一直是 0内核收包队列满(netdev_max_backlog),网络侧/内核侧
RcvbufErrors/proc/net/snmp 的 Udp: 行第 6 字段20000 个报文丢 19907应用侧:socket 缓冲区满,应用读得太慢或根本没读
time_squeeze/proc/net/softnet_stat 第 3 列预算压到 1 后涨了 30003软中断来不及处理:预算太小或 CPU 不够

再补一条定位经验:判断流量到没到内核,看 /proc/softirqs 的 NET_RX 增量;判断它有没有进物理网卡,看 /proc/interrupts 的网卡中断增量。 前者涨、后者不涨,就是环回、隧道或虚拟接口的流量(本文的实验正是这种情况);两者一起涨,才是网卡真的在收包。

一套可复制的排查顺序

计数器全都是「开机以来的累计值」,正确的用法永远是「间隔取样、算差值」,而不是看绝对值。推荐顺序:

第一步,先看应用侧有没有丢。 UDP 服务优先看 RcvbufErrors,TCP 服务看队列溢出与重传:

awk '/^Udp:/ && $2 ~ /^[0-9]+$/ {print "InDatagrams:", $2, "RcvbufErrors:", $6}' /proc/net/snmp

确认是应用不读之后,处理方式是改应用(及时 recvfrom/多线程收包)或调大 net.core.rmem_default / net.core.rmem_max,而不是去动网络参数。Linux TCP 连接诊断:从 ss 到内核计数器 里有 TCP 侧的一整套计数器与 ss 用法,可以和这篇配套看。

第二步,看内核这一层丢没丢。 取 softnet_stat 的前三列(记得当十六进制读):

python3 -c "f=[int(x,16) for x in open('/proc/net/softnet_stat').read().split()]; print('processed=%d dropped=%d time_squeeze=%d' % (f[0], f[1], f[2]))"

dropped 持续增长说明收包队列在溢出(高 PPS 场景),要考虑调大 netdev_max_backlog、检查网卡多队列与 RPS 配置;time_squeeze 持续增长说明 CPU 侧的软中断处理跟不上,方向是加 CPU、开多队列分流,或者干脆用 SO_REUSEPORT 让多个进程分担。

第三步,确认流量路径。 /proc/softirqs 与 /proc/interrupts 各取一次差值,对照上面的经验判断报文是从物理网卡来的还是从环回/虚拟接口来的;用 tc + netem 复现丢包场景时,也可以用它来确认流量确实进了内核,参考 Linux 网络模拟:tc 与 netem 注入延迟、抖动与丢包。

第四步,回到 CPU 总账。 软中断的开销在 /proc/stat 的 cpu 行里单独占一列:

cpu  218056 195506 123247 44712525 12488 0 2984 96991 0 0

按 user nice system idle iowait irq softirq steal ... 的顺序读:这台机器开机以来软中断共 2984 个 tick ≈ 29.84 秒,硬中断 0,而被宿主偷走的时间(steal)是 96991 个 tick ≈ 969.91 秒——在这台 NAT 小机上,「被偷走的 CPU」比「所有软中断加起来」多 30 倍。这个对比很说明问题:很多「服务器变慢」的锅,最后还是得回到 CPU 时间的构成上找,详见 Linux CPU 使用率是怎么算出来的:从 /proc/stat 到 steal 窃取时间。

几个容易误读的点

  • NET_RX 不是收包数量,是软中断执行次数。 本文实验里两者接近 1:1,是因为环回路径上一个报文就触发一次;真实网卡走 NAPI 轮询,一次软中断处理一批报文,比例完全不同。数报文请用 /proc/net/dev。
  • 软中断与硬中断是两本账。 环回、隧道、虚拟接口的流量计入 NET_RX,不计入物理网卡的中断计数;反过来网卡中断多也不代表协议栈处理得多。
  • softnet_stat 的数字是十六进制。 不换算会严重低估问题规模(本文的 00000167b1 是 92081,不是一万六)。
  • dropped 与 RcvbufErrors 的责任方不同。 前者是内核没接住,后者是应用没拿走——看到丢包先去分清是哪一个,再决定动网络参数还是动程序。
  • ksoftirqd 忙不忙不能作为「软中断压力大」的证据。 它只是兜底路径;预算足够时软中断在中断返回路径上就做完了,ksoftirqd 全程闲着——本机 5.26 天只用了 13.58 秒 CPU。

小结

  • 收包路径被拆成「硬中断登记 + NET_RX 软中断干活」,ksoftirqd 是预算耗尽时的兜底线程。
  • 实测 20000 个环回 UDP 报文:NET_RX +20009,网卡硬中断只 +5(那 5 次还是 SSH 自己的流量)——环回不走网卡,但照走软中断。
  • 同一批报文应用一个都没拿到:InDatagrams 不变,RcvbufErrors +19907,这是「应用没读 socket」的典型指纹。
  • netdev_budget 从 300 压到 1、再发 30000 个报文,time_squeeze +30003,几乎每个报文都撞上预算上限;实验后已还原为 300。
  • 本机 softnet_stat 的 dropped 开机 5 天始终为 0;ksoftirqd/0 累计 CPU 13.58 秒 / 5.26 天 = 0.003%——收包软中断平时几乎不花钱,它的问题从来是「抢时间」而不是「花时间」。

相关阅读

本文的全部实验都在一台 1 核 1.9G 内存、无 swap 的 NAT 小机上完成,没有安装任何软件包——压力脚本是系统自带 python3 的几行 socket 代码,全部流量走环回地址,发送端与接收端都在本机。实验结束后的状态:net.core.netdev_budget 已回读确认为 300,临时脚本已删除,没有残留进程与监听端口,系统文件自始至终没有被改动。

发表评论

暂无评论,快来抢沙发吧!