本文要点
- 网卡收包不是在硬件中断里干完的:硬中断只做登记并触发软中断,真正把报文搬进协议栈的是 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
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
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_stat00359e10 00000000 00000167b1 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000一行 13 列,本文只用到前 3 列(它们的含义已经被上面的实验证实):
| 列 | 名称 | 含义 | 这台机器的值 |
|---|---|---|---|
| 1 | processed | 软中断这一层处理过的报文数 | 0x359e10 = 3513872 |
| 2 | dropped | 内核收包队列满而丢掉的报文数 | 0 |
| 3 | time_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
time_squeeze before: 62078
time_squeeze after : 92081
00359e10 00000000 00000167b1 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 0000000030000 个报文,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%——收包软中断平时几乎不花钱,它的问题从来是「抢时间」而不是「花时间」。
相关阅读
- Linux TCP 连接诊断:从 ss 到内核计数器
- Linux CPU 使用率是怎么算出来的:从 /proc/stat 到 steal 窃取时间
- Linux 网络模拟:tc 与 netem 注入延迟、抖动与丢包
- Linux 系统实时监控:从 htop 到 iostat、mpstat 与 ss 的性能观测工具
- Linux MTU 与 IP 分片:从 ping -M do 到路径 MTU 发现与黑洞排查
- Linux 连接跟踪 conntrack:为什么表是空的、满了会怎样
本文的全部实验都在一台 1 核 1.9G 内存、无 swap 的 NAT 小机上完成,没有安装任何软件包——压力脚本是系统自带 python3 的几行 socket 代码,全部流量走环回地址,发送端与接收端都在本机。实验结束后的状态:net.core.netdev_budget 已回读确认为 300,临时脚本已删除,没有残留进程与监听端口,系统文件自始至终没有被改动。
评论 (0)
暂无评论,快来抢沙发吧!