操作系统内功 · 第 14 篇|一次网络收包的内核之旅:从网卡 DMA 到 epoll 返回

一场只涨一个核的事故

深夜告警:核心服务的 P99 从 8ms 爬到 200ms,QPS 没变,机器 CPU 总使用率不到 40%。登上去 top 一看,真相藏在细节里——整体 idle 很高,但其中一个核的 %si(softirq)接近 100%,其余核纹丝不动。再看 /proc/net/softnet_stat,对应 CPU 那一行的第三列(time_squeeze)正以每秒数千的速度增长。

这是「单核软中断瓶颈」的标准画像:流量没被均摊,全部压在处理这张网卡中断的那一个 CPU 上。要理解它为什么发生、发生在哪一站,就得把收包路径完整走一遍——从网卡把字节写进内存,到 epoll_wait 返回,中间每一站都有明确的成本、计数器和可调参数。本系列第 5 篇讲过 epoll 的事件循环,第 7 篇讲过 TCP 的协议行为,第 11 篇讲过中断如何打断程序;这一篇补上它们之间的数据通路:一个包,到底是怎么进来的。

一次网络收包的内核之旅:从网卡 DMA 到 epoll 返回(手绘信息图)

全景:一个包的十站旅程

先给全景图,后文逐站展开:

flowchart TD
    A["网卡收到帧"] --> B["DMA 写入 RX Ring"]
    B --> C["硬中断:只做两件事"]
    C --> D["napi_schedule:关中断挂轮询"]
    D --> E["软中断 NET_RX:net_rx_action"]
    E --> F["驱动 poll 批量取包"]
    F --> G["GRO 合并小包"]
    G --> H["协议栈接力:IP 到 TCP"]
    H --> I["socket 接收队列"]
    I --> J["唤醒 epoll_wait"]

一句话概括设计哲学:硬中断要短,批量活交给软中断慢慢干。下面每一站都围绕同一个问题——这一步的成本是什么,出事时用哪个计数器发现它。

第一站:网卡与 DMA——中断不该为每个包而响

现代网卡有一组接收描述符环(RX Ring):内核预先分配、网卡通过 DMA 直接写入的环形缓冲区。每个描述符指向一块内存缓冲区,包到达时网卡绕过 CPU 把帧直接写进去,再更新描述符状态。CPU 的活儿从「搬字节」变成了「处理已经就位的包」。ethtool -G 能看到的 rx ring 4096 这类数字,就是这个环的深度——常见默认 256/512,调到 4096 意味着软中断来不及处理时,网卡有更大余量继续 DMA。

Ring 满了是第一类丢包:网卡找不到空描述符,新到的帧直接被丢在硬件里,根本不会进入内核协议栈。这类丢包要看网卡的硬件计数器——ethtool -S 输出里带 missed、drop、discard 字样的项。Cloudflare 在「每秒接收一百万包」的实验里就撞到过这一站:所有包灌进一个 RX 队列、绑定的 CPU 满载之后,Solarflare 网卡的 rx_nodesc_drop_cnt 以每秒约 45 万的速度上涨——网卡想交付,内核没有描述符可给。

第二件要知道的事是中断合并(interrupt coalescing):网卡攒一小批包、或等几十微秒再触发一次中断,用一点延迟换大幅降低的中断频率,ethtool -c 可查看与调整。延迟敏感型负载把它关小,吞吐型负载把它开大——这是收包路径上第一个「延迟换吞吐」的旋钮。

小结:这一站的关键词是「余量」。Ring 深度与中断合并策略,决定了突发流量到来时系统还剩多少缓冲余地。

第二站:硬中断只做一件事——NAPI 的关中断与轮询

如果没有特殊机制,每个包都触发一次硬中断,10G 线速的小包流量意味着每秒上千万次中断,CPU 会在中断上下文里被打死。Linux 2.4 时代引入的 NAPI(内核文档特意注明,它最初就叫「New API」,如今这名字已不代表任何单词)改变了游戏规则,核心是一套「关中断、改轮询」的状态机:

  1. 网卡触发一次硬中断,驱动的中断处理函数只做两件事:更新中断节流寄存器、调用 napi_schedule();
  2. napi_schedule() 把这个 NAPI 实例挂到当前 CPU 的轮询列表,并 raise 一个 NET_RX 软中断——此后驱动保持中断屏蔽,内核文档明确要求「调度 NAPI 之后应保持中断屏蔽」,因为轮询期间中断纯属浪费;
  3. 软中断阶段,内核调用驱动的 ->poll() 方法批量收包;
  4. poll 的返回值小于预算,说明环已清空,驱动调用 napi_complete_done() 释放轮询权,然后才重新打开中断;返回值恰好等于预算则说明还有积压,内核会立即再次调用 poll。

单个 NAPI 实例每次 poll 最多取多少包由 weight 决定,默认 64——这正是 sysctl 参数 net.core.dev_weight 的默认值(内核 sysctl 文档与 igb 驱动注册 poll 时传入的 64 双源一致)。「中断只敲一次门,剩下的自己搬」,这个设计是 Linux 网络栈能撑住高 pps 的根基。

小结:硬中断的贵不在线性时间,而在它不可打断、不可重入。NAPI 把不可控的中断风暴,变成了预算可控的轮询。

第三站:软中断的预算制——300 个包与 2000 微秒

NET_RX 软中断的处理函数是 net_rx_action(),它遍历本 CPU 轮询列表上的 NAPI 实例逐个调 poll。这个循环受双重预算约束,两个参数都有主流发行版官方文档口径的默认值:

参数 默认值 含义
net.core.netdev_budget 300 一次软中断周期,所有网卡合计最多处理的包数
net.core.netdev_budget_usecs 2000 一次软中断周期的最长时长,单位微秒

(300 见 Rocky Linux 官方调优文档与 packagecloud 对内核源码的解读;2000 见 Rocky Linux 官方文档。周期在包数与时间任一耗尽时结束。)

为什么要给软中断设预算?因为软中断优先于任何进程代码执行——如果这一次 NET_RX 不清空队列不罢休,本 CPU 上的进程、定时器乃至 RCU 回收都会被饿死。预算制保证软中断「干一段就歇」,剩下的活排队再来。

代价就是事故现场的那个计数器。/proc/net/softnet_stat 每行对应一个 CPU(十六进制),关键列如下:

列 计数器 含义
1 processed 本 CPU 处理的帧数
2 dropped backlog 队列溢出丢弃的帧数
3 time_squeeze 预算耗尽但仍有积压的次数
10 received_rps 收到 IPI 被唤醒处理包的次数

第 3 列持续增长,含义是「这个 CPU 每次都被预算掐断,活永远干不完」——单核软中断瓶颈的直接证据。第 2 列增长则是另一回事:包被转发到别的 CPU 的 backlog 队列(RPS 场景)后队列满被丢。两个计数器指向完全不同的病灶,这是排查时最容易被混为一谈的一对信号。

预算耗尽时剩下的活由谁接手?每 CPU 一个的 ksoftirqd/<n> 内核线程。top 里 %si 高的核旁边往往有个同编号的 ksoftirqd 在吃 CPU,它就是软中断的「加班形态」。

第四站:GRO 与协议栈接力——把小包拼成大包

驱动 poll 取出的帧先过 GRO(Generic Receive Offload):把同一条流里「足够相似」的一串 TCP 段,合并成一个 64KiB 量级的超级段再上送协议栈(LWN 对这套机制的解读给出了 64KB 大包的来龙去脉,GRO 是发送方向 GSO/TSO 的镜像)。协议栈的固定成本——查找、锁、拷贝、唤醒——都是按包计费的:每秒一百万个小包和每秒两万个大包,对协议栈是 50 倍的开销差,GRO 直接把单价打了下来。早期内核对同时参与合并的流数量有限制(3.13 内核源码中的 MAX_GRO_SKBS = 8,见 packagecloud 的解读),现代内核已按流组织合并队列,但「合并省的是每包固定成本」这个逻辑从未变过。

合并后的包经 netif_receive_skb() 进入协议栈接力:IP 层查路由、过 netfilter,TCP 层按四元组查到 socket,把载荷挂进 socket 的接收队列。到这里,网络栈的工作基本结束,剩下的属于 socket 层。如果应用迟迟不读,接收缓冲区涨到上限后再来的包会被丢弃——netstat -s 里的 RcvbufErrors 记录的就是这类丢包,Cloudflare 实验里它曾以每秒约 12 万的速度增长,那是「内核收下了、应用收不动」的第三类丢包。

至此三类丢包可以立此存照:网卡没交付(ethtool -S)、backlog 溢出(softnet_stat 第 2 列)、socket 缓冲满(RcvbufErrors)——病灶分别在网卡侧、CPU 侧与应用侧。

第五站:唤醒读者——epoll 为什么会醒

数据挂进接收队列后,内核唤醒在这个 socket 上等待的读者。若进程正阻塞在 epoll_wait(),唤醒链把它从等待队列拉起,epoll 把就绪事件放进就绪链表,epoll_wait 返回——这才是应用看见「可读」的时刻。从网卡 DMA 到这里,一个包走完内核之旅,本系列第 5 篇讲的事件循环只是这条链的最后一环。

这条唤醒链的长度,就是收包延迟的下限。低延迟场景有一组「用 CPU 换延迟」的选项:SO_BUSY_POLL 让内核在等待时直接轮询驱动而不睡等中断(全局开关 net.core.busy_poll/net.core.busy_read,默认关闭,内核文档提醒「会增加功耗」);新内核还能把 NAPI 从软中断挪进专用内核线程(threaded NAPI,线程名 napi/<接口名>-<id>,官方建议绑在处理中断的同一个 CPU 上),甚至让 epoll 直接驱动 NAPI 忙轮询。这些机制全部写在内核 NAPI 文档里,代价无一例外是 CPU 占用。

多进程共享监听 socket 时还有「惊群」问题:一个连接到达唤醒一整队 worker,只有一个能 accept 成功,其余白醒,EPOLLEXCLUSIVE 标志就是为抑制它而设。收包路径的性能账,最后往往结在唤醒模型上。

第六站:多核分流——RSS、RPS 与 RFS

单核瓶颈的治本方案是把流量摊到多个 CPU。这套体系有四层,内核文档《Scaling in the Linux Networking Stack》是权威出处:

机制 层次 原理 关键配置
RSS 硬件 网卡按哈希把包分到多队列,每队列独立 MSI-X 中断 ethtool -X 间接表、IRQ 亲和性
RPS 软件 协议栈处理前按哈希把包转到别的 CPU 的 backlog rps_cpus 位图
RFS 软件 + socket 按「消费这条流的应用在哪个 CPU」分发,提高缓存命中 rps_sock_flow_entries
aRFS 硬件 + socket RFS 的硬件加速版,驱动直接把流导向目标队列 ntuple 过滤

几个值得记住的细节:RSS 间接表建议至少是队列数的 4 倍,内核文档给出的量级是「4 倍表带来约 16% 的队列间不均衡」;RPS/RFS 进入内核是在 2.6.35(作者 Tom Herbert),发送方向的 XPS 在 2.6.38;RPS 默认关闭,rps_cpus 全 0 时包就在处理中断的本地 CPU 上走完全程。RPS 启用后,跨 CPU 派发要发 IPI(处理器间中断),目标 CPU 的 backlog 队列上限就是 net.core.netdev_max_backlog——它只在「包需要被送到别的 CPU」时才有意义,这就是为什么没开 RPS 的机器调它常常无效。

队列与 CPU 的配比也有讲究:Rocky Linux 官方文档提醒,每个队列占一个 IRQ 和一个 APIC 向量,而每个 CPU 可用向量约 200 个,128 队列的网卡配上 64 核机器就在逼近上限;队列数对齐物理核数(而非超线程逻辑核)、并绑定到网卡所在 NUMA 节点,是通用起点。irqbalance 适合做兜底,IRQBALANCE_BANNED_CPULIST 可以把关键核让给应用。

Cloudflare 的实验给这套理论标了价:绑核后单核稳定在每秒 36–37 万包;同一 NUMA 的邻核表现最好,超线程兄弟核只有约一半,跨 NUMA 最差出现 4 倍惩罚;最后靠 SO_REUSEPORT 四个进程分食多个队列,才把吞吐推过每秒 110 万包。分流方案错了,后面的所有调优都是在给单核修天花板。

丢包发生在哪:一张分层定位表

把前文的计数器按路径顺序排开,任何一层出位的数字都会告诉你包死在哪一站:

层 现象 看哪里 典型病因
网卡 包没进内核 ethtool -S 含 drop/missed 项 ring 满、PCIe 与 NUMA 瓶颈
驱动 poll 包取不过来 softnet_stat 第 3 列 单核预算耗尽
RPS backlog 包转发后丢失 softnet_stat 第 2 列 backlog 太小或目标 CPU 慢
socket 内核收下应用没读 netstat -s 的 RcvbufErrors 应用阻塞、缓冲太小

一个常见的误区要专门点名:backlog 溢出时盲目调大 netdev_max_backlog 往往是掩耳盗铃。阿里云与 Rocky 的官方文档都把诊断指向同一个分岔:dropped 增长是队列问题,time_squeeze 增长是 CPU 瓶颈——后者调大队列只是把丢包推迟到内存消耗与延迟恶化之后。先确认软中断分布是否失衡,再动队列参数,顺序不能反。

工程清单

照例收尾。收包路径的日常排查与调优,按这个顺序走:

# 1. 找出软中断失衡的核:%si 单核飙高即单核瓶颈
mpstat -P ALL 1 5

# 2. 每个 CPU 的收包计数:第2列 dropped、第3列 time_squeeze(十六进制)
cat /proc/net/softnet_stat

# 3. 软中断分布是否均匀:NET_RX 各核次数
grep -A1 NET_RX /proc/softirqs

# 4. 硬件计数器:ring 溢出与网卡级丢包
ethtool -S eth0 | grep -Ei 'drop|missed|discard'

# 5. socket 层丢包:应用读不过来
netstat -s | grep -i 'receive buffer errors'

# 6. 队列数与中断在各核的分布
ethtool -l eth0
cat /proc/interrupts

# 7. 预算参数现状(默认 300 / 2000)
sysctl net.core.netdev_budget net.core.netdev_budget_usecs

调优的原则排序:先分流(RSS 队列数、IRQ 亲和、RPS/RFS),再看预算(netdev_budget 300 到 600 一类的微调),最后才动队列与缓冲。每改一项,回 softnet_stat 看计数器是否转向——收包路径每一站都有账本,调优不过是让账本上不再出现新的欠账。

参考资料

  1. NAPI — Linux Kernel Documentation:NAPI 调度、轮询与 complete 的语义,threaded NAPI 与 busy polling 的官方出处。
  2. Scaling in the Linux Networking Stack:RSS/RPS/RFS/aRFS/XPS 权威文档,含间接表与流表的配置数字。
  3. IRQs and kernel packet drops — Rocky Linux 10:netdev_budget=300、netdev_budget_usecs=2000 默认值与 softnet_stat 诊断,发行版官方口径。
  4. Monitoring and Tuning the Linux Networking Stack: Receiving Data — packagecloud:以 igb 驱动逐行解读收包路径与 softnet_stat 列语义。
  5. How to receive a million packets per second — Cloudflare:单核 pps 上限、SO_REUSEPORT 分流与 NUMA 惩罚的实测数字。
  6. Generic receive offload — LWN.net:GRO/TSO 大包机制的原理与 64KB 量级的出处。
← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?