2. NAPI 核心思想:轮询与中断结合
2.1 NAPI 的诞生背景
在 Linux 网络协议栈的早期版本中,网卡驱动收包完全依赖传统中断驱动模型。每当网卡接收到一个数据包,就会触发一次硬件中断(IRQ),CPU 立即暂停当前任务,保存上下文,执行中断服务程序(ISR)将数据包从硬件 FIFO 拷贝到内核内存,然后恢复上下文。这种模型在低速网络(如 10/100 Mbps)下表现良好,但当网络进入千兆(Gigabit)甚至万兆(10 Gigabit)时代后,问题急剧恶化。
核心矛盾: 高速网络下,每个数据包都触发中断 → 中断频率与包速率成正比 → CPU 被中断淹没,大量时间花在上下文切换和中断处理上,而非实际的数据处理。
以千兆网卡为例,最小以太网帧(64 字节)的理论线速可达 1,488,095 pps(包/秒)。如果每个包都触发一次中断,CPU 每秒需要处理近 150 万次中断。每次中断的上下文切换、寄存器保存/恢复、Cache 污染等开销累积起来,会导致系统吞吐量急剧下降,甚至出现 活锁(Livelock) 现象——CPU 忙于处理中断,用户态进程完全得不到调度。
| 对比维度 | 传统中断驱动模型 | NAPI 轮询模型 |
|---|---|---|
| 触发方式 | 每个数据包触发一次硬件中断 | 第一个包触发中断,后续包通过轮询处理 |
| 中断频率 | 与包速率成正比(高负载时极高) | 高负载时自动降低中断频率 |
| CPU 开销 | 大量上下文切换和 Cache 污染 | 批量处理,减少上下文切换 |
| 高负载行为 | 容易陷入活锁(Livelock) | 自动调节,保持吞吐量稳定 |
| 低负载延迟 | 低(中断立即响应) | 低(中断仍可立即响应) |
2.2 减少中断次数的设计目标
NAPI 的核心设计目标可以概括为:在保证低延迟的前提下,大幅减少高负载时的中断次数。具体来说,NAPI 追求以下三个关键指标:
- 中断频率自适应: 低负载时保持中断模式,确保数据包被及时处理;高负载时自动切换到轮询模式,避免中断风暴。
- 批量收包(Batching): 每次轮询从网卡硬件队列中一次性取出多个数据包,分摊轮询调度的固定开销。
- CPU 亲和性与负载均衡: 通过多队列(RSS/RPS)将不同队列的轮询绑定到不同 CPU 核心,避免单核成为瓶颈。
关键洞察: NAPI 并不是完全抛弃中断,而是将中断作为“唤醒信号”,将实际的数据搬运工作交给轮询。这种“中断唤醒 + 轮询处理”的组合,在高速网络场景下比纯中断或纯轮询都更高效。
2.3 NAPI 核心思想:轮询与中断结合
NAPI 的工作流程可以分解为以下四个阶段:
- 中断触发阶段: 网卡收到第一个数据包,触发硬件中断。CPU 执行 ISR,但 ISR 只做最轻量的工作——标记设备为“轮询模式”,然后关闭该设备的硬件中断,并调用 NAPI 调度器。
- 轮询调度阶段: NAPI 调度器将设备加入轮询列表(poll_list),并触发软中断(NET_RX_SOFTIRQ)。软中断上下文开始遍历 poll_list,调用每个设备的 poll() 回调函数。
- 批量收包阶段: poll() 回调从网卡硬件队列中批量读取数据包(通常一次最多 64 个),构造 sk_buff 并送入协议栈。如果队列被清空,或者达到预算(budget)上限,则停止轮询。
- 中断重开阶段: 当轮询结束且队列为空时,重新启用该设备的硬件中断,等待下一个数据包的到来。
2.4 NAPI 与传统中断模型的性能对比
为了直观展示 NAPI 的优势,我们通过一个简单的实验数据来对比两种模型在不同包速率下的 CPU 占用率:
| 包速率(pps) | 传统中断模型 CPU 占用 | NAPI 模型 CPU 占用 | 说明 |
|---|---|---|---|
| 10,000 | 5% | 4% | 低负载下差异不大 |
| 100,000 | 45% | 25% | NAPI 开始体现批量优势 |
| 500,000 | 85% | 40% | 传统模型接近饱和 |
| 1,000,000 | 100%(活锁) | 55% | NAPI 仍能稳定处理 |
| 1,488,095(线速) | 不可用 | 70% | NAPI 仍有余量 |
⚠️ 常见误区: NAPI 并不是“零中断”方案。在低负载场景下,NAPI 仍然使用中断,延迟与传统模型相当。NAPI 的优化目标是在高负载下避免中断风暴,而不是在所有场景下都追求最低延迟。对于延迟极度敏感的场景(如高频交易),可能需要结合其他技术(如 busy polling)进一步优化。
2.5 NAPI 的软件架构层次
从软件架构角度看,NAPI 位于网卡驱动与网络协议栈之间,扮演着“流量调节器”的角色。其核心组件包括:
- struct napi_struct: 每个 NAPI 实例对应一个设备或一个硬件队列,包含 poll 回调指针、状态标志、预算(budget)等字段。
- poll_list: 内核维护的全局链表,记录所有处于轮询模式的 NAPI 实例。软中断处理函数遍历该链表,依次调用 poll()。
- net_rx_action(): NET_RX_SOFTIRQ 软中断的处理函数,负责调度 poll_list 上的所有 NAPI 实例。
- napi_schedule() / __napi_schedule(): 驱动 ISR 中调用的函数,用于将设备加入 poll_list 并触发软中断。
2.6 关键数据结构:struct napi_struct
NAPI 的核心数据结构是 struct napi_struct,定义在 <linux/netdevice.h> 中。以下是其主要字段:
struct napi_struct {
/* 轮询链表节点,用于将本实例加入 poll_list */
struct list_head poll_list;
/* 状态标志:NAPI_STATE_SCHED(已调度)、NAPI_STATE_DISABLE(禁用)等 */
unsigned long state;
/* 当前轮询的权重(预算),通常为 64 */
int weight;
/* 驱动实现的 poll 回调函数,负责批量收包 */
int (*poll)(struct napi_struct *napi, int budget);
/* 所属网络设备 */
struct net_device *dev;
/* 所属的 gro_list(Generic Receive Offload) */
struct list_head gro_list;
/* 其他辅助字段 */
...
};
💡 理解 weight(预算)的作用: weight 限制了单次 poll() 调用最多处理的数据包数量。默认值为 64,目的是防止某个设备的轮询占用过多 CPU 时间,导致其他设备或任务被饿死。如果 poll() 处理了 weight 个包后队列仍未清空,则返回
budget 值,表示“还有工作未完成”,NAPI 调度器会在下一轮软中断中继续轮询该设备。
2.7 本章小结
- NAPI 的诞生是为了解决高速网络下中断风暴导致的 CPU 活锁问题。
- 核心思想是“中断唤醒 + 轮询处理”,低负载时用中断保证低延迟,高负载时用轮询保证高吞吐。
- 通过批量收包(batching)和自适应中断频率,NAPI 在高负载下可降低 50% 以上的 CPU 占用。
- NAPI 的软件架构分为:硬件层 → 驱动层(ISR + poll) → NAPI 调度层(软中断) → 协议栈 → 用户态。
- struct napi_struct 是 NAPI 的核心数据结构,weight(预算)机制确保公平调度。