聊聊网络收包那点事儿
今天我们来聊一个在 Linux 内核网络世界里至关重要,但又经常被大家"选择性遗忘"的英雄------NAPI。
你可能会问,这是个啥?嗯,这么说吧,如果没有 NAPI,你现在流畅地看着 4K 视频、玩着低延迟的在线游戏,可能都得打个大大的问号。它就像我们网络世界的"交通调度总指挥",默默无闻,但功不可没。
在深入技术细节之前,我们先来打个比方。
场景一:疯狂的门铃 (中断模式)
想象一下,你是个超受欢迎的网红,在网上卖货。快递小哥每次只送一个包裹,每送一个,就按一次你家的门铃。一开始,一天就一两个包裹,你觉得还行,挺有仪式感的。
但突然有一天你爆单了,一秒钟之内有几百个包裹要送达。快递小哥恪尽职守,一秒钟按了几百次门铃。你呢?每次听到门铃响,就得放下手头所有事(比如回粉丝消息、准备直播),跑到门口去拿一个包裹,然后再回来。结果就是,你一天啥正事也干不了,光在门口和客厅之间疲于奔命了,CPU(也就是你)被这些"门铃事件"(也就是硬件中断 )彻底榨干,这在技术上叫中断风暴 (Interrupt Storm)。
这就是早期 Linux 网络收包的窘境。网卡每收到一个数据包,就给 CPU 发一个中断信号,CPU 就得停下手里的活去处理。在低速网络时代(比如 10Mbps),这没问题。但在千兆、万兆网络时代,数据包像洪水一样涌来,CPU 就会被无穷无尽的中断请求淹没,大部分时间都在响应中断,真正用于处理数据的有效时间少得可怜。
场景二:聪明的约定 (NAPI 模式)
现在,你和快递小哥学聪明了。你们约定:
- 快递小哥送第一批 包裹的第一个 到达时,按一次门铃(硬件中断)。
- 你听到门铃后,先把门铃暂时关掉 (屏蔽中断),然后跟小哥说:"行,我知道了,你把这批货都放门口,我自己来拿,拿完之前你别再按了。"
- 然后,你开始从门口一趟一趟地把所有包裹搬进屋里(轮询 Polling),直到把门口这批货全部搬完。
- 搬完后,你再打开门铃(开启中断),告诉小哥:"门口清空了,下一批货来了再按铃吧。"
发现了吗?效率天差地别!NAPI 的核心思想就是这样一种中断+轮询的混合模式。它既利用了中断的低延迟特性(第一时间通知有数据来了),又通过轮询在高流量时批量处理数据,避免了中断风暴。
深入 NAPI 的"五脏六腑"
好了,比喻讲完了,我们现在戴上"X光眼镜",看看 NAPI 在内核里到底是怎么实现的。这部分会有点硬核,但别怕,我会配上图表和代码,帮你理解。
1. 核心数据结构
这几个结构体是 NAPI 机制的基石,它们像齿轮一样互相咬合,驱动着整个流程。
| 结构体 | 核心作用 | 在比喻中是什么? |
|---|---|---|
struct napi_struct |
NAPI 实例的"身份证"和"工作台" | 你家门口那块专门收快递的"临时区域" |
struct net_device |
代表一个网络设备(如 eth0) | 你家的"地址"或"户口本" |
struct softnet_data |
每个 CPU 核心的"网络工具箱" | 你自己(作为处理包裹的人) |
struct sk_buff (SKB) |
内核中代表一个网络数据包 | 一个个具体的"快递包裹" |
struct napi_struct 详解
这是 NAPI 的绝对核心,每个需要使用 NAPI 的网络设备(或者说,每个接收队列)都会有一个或多个这样的实例。
c
// include/linux/netdevice.h
struct napi_struct {
struct list_head poll_list; // 用于将自己挂到 CPU 的 softnet_data->poll_list 上
unsigned long state; // NAPI 状态机,非常重要!(如 NAPI_STATE_SCHED)
int weight; // 单次轮询处理数据包的"权重",通常是 64
int gro_count;
int (*poll)(struct napi_struct *, int); // 核心!驱动提供的轮询处理函数
// ... 其他成员
struct list_head dev_list; // 用于挂到 net_device->napi_list 上
struct net_device *dev; // 指向自己所属的 net_device
};
poll_list: 想象成一个"待办事项"列表的钩子。当 NAPI 被调度时,就是通过这个钩子把自己挂到对应 CPU 的poll_list上,等着被处理。state: 标记 NAPI 当前的状态,比如是否被调度了 (NAPI_STATE_SCHED),是否正在被关闭等。内核通过原子操作来管理这个状态,确保多核环境下的同步。weight: 这是给驱动的一个"建议",告诉它在一次poll调用中,最多处理多少个数据包比较合适。默认是 64,所以也常被称为NAPI_BUDGET_DEFAULT。poll: 函数指针,这是 NAPI 机制的灵魂。当中断处理程序把 NAPI "激活"后,内核最终会调用这个由网卡驱动程序实现的函数,来真正地收包。
struct softnet_data 详解
Linux 为每个 CPU 核心都准备了一个 softnet_data 结构,用来处理这个 CPU 上的网络任务,避免了多核之间的锁竞争。
c
// net/core/dev.c
struct softnet_data {
struct list_head poll_list; // NAPI "待办事项"列表
struct sk_buff_head process_queue; // 等待处理的输入队列
// ...
struct napi_struct backlog; // 一个特殊的 NAPI 实例,用于处理非 NAPI 路径的包
};
poll_list: 这就是我们前面说的"待办事项"列表。所有被调度到这个 CPU 上的napi_struct实例都会被链接到这个链表上。
2. NAPI 工作流程图解
现在,我们把这些零件串起来,看看一个数据包从网卡到内核的完整旅程。
网卡 CPU 中断处理程序 内核软中断(ksoftirqd) 驱动的 poll() 函数 1. 发送硬件中断 (IRQ) 2. 执行中断处理程序 (Top Half) 3. 关闭网卡中断 4. 调用 napi_schedule(&napi) 激活 NAPI 任务完成, 快速退出 5. 在软中断上下文中 执行 net_rx_action() 6. 遍历 softnet_data->>poll_list 调用 napi->>poll() 7. 循环收包 (budget 内) 分配 skb, 送往协议栈 8a. 调用 napi_complete_done() 重新开启中断 8b. 不开启中断 NAPI 保持激活状态 等待下次轮询 alt [任务完成 (budget 未用完)] [任务未完成 (budget 用完)] 网卡 CPU 中断处理程序 内核软中断(ksoftirqd) 驱动的 poll() 函数
流程剖析:
- 数据包到达,中断发生: 网卡收到数据包,向 CPU 发送一个中断信号。
- 中断上半部 (Top Half) : CPU 响应中断,执行网卡驱动注册的中断处理程序 (ISR) 。这个程序必须极快 完成,它的任务不是处理数据包,而是像个秘书一样:
- "关门铃" : 调用
napi_disable()或直接操作硬件寄存器来屏蔽当前网卡队列的后续中断。 - "登记待办" : 调用
napi_schedule()函数。这个函数会检查 NAPI 状态,如果当前未被调度,就把它设置为NAPI_STATE_SCHED状态,并将其poll_list成员添加到当前 CPU 的softnet_data->poll_list链表的末尾,然后触发一个NET_RX_SOFTIRQ软中断。 - 然后,中断处理程序就光速返回了。CPU 得以解放,可以去干别的事情。
- "关门铃" : 调用
- 软中断下半部 (Bottom Half) : 在稍后的一个安全时刻,内核会处理这个
NET_RX_SOFTIRQ软中断,执行net_rx_action()函数。 - 轮询开始 :
net_rx_action()函数的核心工作就是遍历当前 CPU 的softnet_data->poll_list链表,取出每一个被调度的napi_struct实例。 - 调用驱动的
poll方法 : 对每一个napi_struct,内核会调用其poll函数指针,把控制权交给驱动程序。同时传入一个budget参数(通常就是napi_struct->weight的值,如 64)。 - 驱动疯狂收包 : 驱动的
poll函数在一个循环里开始工作,它不断地从网卡的接收环形缓冲区 (Rx Ring) 中收取数据包,为每个数据包分配一个sk_buff,然后将其送往上层网络协议栈(例如,通过napi_gro_receive())。这个循环会一直持续,直到满足以下任一条件:- 处理的数据包数量达到了
budget上限。 - 网卡的接收缓冲区已经没有数据包了。
- 处理的数据包数量达到了
- 结束轮询 :
- 情况A:活干完了 。如果在
budget耗尽前,网卡的数据包就已经被收完了。驱动会调用napi_complete_done()。这个函数会清除 NAPI 的NAPI_STATE_SCHED状态,并重新开启网卡中断("打开门铃")。NAPI 这一轮的工作就结束了。 - 情况B:活没干完 。如果
budget已经用完,但网卡里似乎还有数据。poll函数会直接返回处理过的数据包数量。因为 NAPI 实例的NAPI_STATE_SCHED状态没有被清除,所以它仍然在poll_list中。内核在处理完其他 NAPI 实例后,会在下一次net_rx_action循环中再次轮询它,继续处理剩余的数据包,而不需要等待新的硬件中断。
- 情况A:活干完了 。如果在
这个设计精妙地平衡了延迟和吞吐量。
最简实例:一个"玩具"驱动的核心代码
我们不可能在这里贴一个完整的真实驱动代码(太长了),但我们可以看看一个最简化模型的伪代码,来理解驱动开发者需要做什么。
c
// 假设这是一个网卡驱动文件 my_driver.c
#include <linux/netdevice.h>
#include <linux/ethtool.h>
// 1. 定义驱动私有数据结构,其中包含 napi_struct
struct my_adapter {
struct net_device *netdev;
struct napi_struct napi;
// ... 其他硬件相关的成员,如寄存器地址等
};
// 2. 核心:poll 函数的实现
static int my_poll(struct napi_struct *napi, int budget) {
struct my_adapter *adapter = container_of(napi, struct my_adapter, napi);
int packets_processed = 0;
// --- 轮询收包的核心逻辑 ---
while (packets_processed < budget) {
// 检查硬件的接收队列是否有新的包
if (!has_new_packet(adapter)) {
break; // 没有新包了,退出循环
}
// 分配一个 skb
struct sk_buff *skb = netdev_alloc_skb_ip_align(adapter->netdev, packet_length);
if (!skb) {
// 内存分配失败,稍后重试
break;
}
// 从网卡 DMA 缓冲区拷贝数据到 skb
copy_packet_data_to_skb(adapter, skb);
// 将 skb 送往网络协议栈
napi_gro_receive(napi, skb);
packets_processed++;
}
// --- 判断如何结束 ---
if (packets_processed < budget) {
// 包已收完,budget 没用完
// 调用 napi_complete_done 来结束轮询并重新开中断
napi_complete_done(napi, packets_processed);
}
// 如果 packets_processed == budget,说明可能还有包
// 直接返回 budget,NAPI 状态不变,内核会再次调度我
return packets_processed;
}
// 3. 中断处理函数的实现
static irqreturn_t my_irq_handler(int irq, void *dev_id) {
struct net_device *netdev = dev_id;
struct my_adapter *adapter = netdev_priv(netdev);
// 检查是否是接收中断
if (is_rx_interrupt(adapter)) {
// 是的!有新包来了
if (napi_schedule_prep(&adapter->napi)) {
// 屏蔽网卡中断
disable_rx_interrupts(adapter);
// 调度 NAPI
__napi_schedule(&adapter->napi);
}
}
return IRQ_HANDLED;
}
// 4. 驱动初始化时,设置好 NAPI
static int my_driver_probe(struct pci_dev *pdev, const struct pci_device_id *id) {
// ... 分配 net_device, my_adapter 等
struct net_device *netdev;
struct my_adapter *adapter = netdev_priv(netdev);
// 初始化 NAPI
// 关联 net_device, poll 函数, 和 weight
netif_napi_add(netdev, &adapter->napi, my_poll, 64);
// ... 注册中断处理函数
request_irq(pdev->irq, my_irq_handler, ...);
// 启用 NAPI
napi_enable(&adapter->napi);
// ...
return 0;
}
代码关系图解:
Packet Reception Driver Initialization 注册 poll 函数 注册中断处理函数 调度 NAPI 执行 net_rx_action 实际调用 处理完/budget用完 包含 包含 操作 操作 硬件中断 __napi_schedule 内核软中断 ksoftirqd 调用 napi->poll napi_complete_done / or just return netif_napi_add my_driver_probe my_poll request_irq my_irq_handler struct my_adapter struct napi_struct
这个流程清晰地展示了驱动如何将自己的 poll 和 irq_handler 函数"挂"到内核的 NAPI 框架中,并在运行时被内核回调。
常用工具和 Debug 手段
理论和代码都看了,那在实际工作中,我们怎么观察和调试 NAPI 呢?
1. 观察中断
中断是 NAPI 的起点,观察中断频率是判断是否出现问题的关键。
-
命令 :
cat /proc/interrupts -
解读 :
bash$ cat /proc/interrupts CPU0 CPU1 CPU2 CPU3 ... 128: 15000000 20000000 18000000 17000000 IR-IO-APIC 128-edge eth0-rx-0 129: 14000000 21000000 19000000 16000000 IR-IO-APIC 129-edge eth0-rx-1 ...这个输出显示了每个中断号(IRQ)在各个 CPU 上的触发次数。你可以看到
eth0的接收队列(rx-0,rx-1...)对应的中断。如果某个 CPU 上的中断数在短时间内疯狂增长,远超其他 CPU,那可能就是中断风暴或者中断亲和性设置不当。在 NAPI 正常工作的高流量下,这个数字增长应该是相对平缓的,因为大部分时间中断是关闭的。
2. 观察软中断
NAPI 的轮询发生在软中断上下文中,所以 NET_RX_SOFTIRQ 的计数也是一个重要指标。
-
命令 :
cat /proc/softirqs -
解读 :
bash$ cat /proc/softirqs CPU0 CPU1 CPU2 CPU3 ... NET_TX: 100000 200000 150000 250000 NET_RX: 80000000 90000000 85000000 95000000 <-- 关注这里 ...NET_RX行显示了每个 CPU 处理接收软中断的次数。如果这个数字飞速增长,说明网络接收非常繁忙。如果某个 CPU 的NET_RX远高于其他 CPU,可能意味着流量没有被均匀地分发到所有核心,可以考虑调整 RSS (Receive Side Scaling) 或者 RPS (Receive Packet Steering)。
3. 使用 ethtool
ethtool 是一个强大的网络设备配置工具,也可以用来查看和调整与 NAPI 相关的参数。
-
查看 Coalesce 参数 : 中断合并(Interrupt Coalescing)是硬件层面的优化,它能减少中断的产生。NAPI 工作在中断合并之后。
bash# 查看 eth0 的中断合并设置 $ ethtool -c eth0 Coalesce parameters for eth0: ... rx-usecs: 30 rx-frames: 16 ...rx-usecs表示延迟多少微秒后产生一个中断,rx-frames表示收到多少帧后产生一个中断。这两个条件通常是"或"的关系。调整这些值可以影响 NAPI 被触发的频率。
4. 使用 perf 进行性能分析
如果想深入到函数级别,看看 CPU 时间都花在哪了,perf 是你的终极武器。
-
命令 :
bash# 抓取系统 10 秒内的性能数据 $ perf record -g -a -- sleep 10 # 查看报告 $ perf report -
解读 : 在
perf report的输出中,你可以清晰地看到net_rx_action, 驱动的poll函数(如igb_poll),以及协议栈中的其他函数占用的 CPU 比例。如果poll函数占比极高,说明系统大部分时间都在忙于收包,这可能是正常的(网络负载重),也可能是不正常的(比如poll函数效率低下)。
总结一下
关于 NAPI 的深度游今天就到这里。我们来快速回顾一下:
- 它是什么? NAPI 是 Linux 内核中一种高效的网络包接收机制,采用中断+轮询的混合模式。
- 为什么需要它? 为了解决高速网络下的中断风暴问题,将 CPU 从繁重的中断响应中解放出来。
- 它如何工作?
- 第一次收包 -> 硬件中断。
- 中断处理 -> 关中断 ,调度 NAPI (软中断)。
- 软中断 -> 轮询 调用驱动的
poll方法,批量收包。 - 包收完 -> 开中断,等待下一次"门铃"。
- 包没收完 -> 保持 NAPI 激活,内核下次继续轮询,无需中断。
说到底,NAPI 体现了计算机科学中一个朴素而深刻的哲学:在不同的场景下使用不同的策略。当悠闲时(低流量),我们追求极致的响应速度(中断);当忙碌时(高流量),我们追求极致的处理效率(轮询)。