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 忙于处理中断,用户态进程完全得不到调度。

传统中断模型 vs NAPI 模型对比
对比维度 传统中断驱动模型 NAPI 轮询模型
触发方式 每个数据包触发一次硬件中断 第一个包触发中断,后续包通过轮询处理
中断频率 与包速率成正比(高负载时极高) 高负载时自动降低中断频率
CPU 开销 大量上下文切换和 Cache 污染 批量处理,减少上下文切换
高负载行为 容易陷入活锁(Livelock) 自动调节,保持吞吐量稳定
低负载延迟 低(中断立即响应) 低(中断仍可立即响应)

2.2 减少中断次数的设计目标

NAPI 的核心设计目标可以概括为:在保证低延迟的前提下,大幅减少高负载时的中断次数。具体来说,NAPI 追求以下三个关键指标:

  1. 中断频率自适应: 低负载时保持中断模式,确保数据包被及时处理;高负载时自动切换到轮询模式,避免中断风暴。
  2. 批量收包(Batching): 每次轮询从网卡硬件队列中一次性取出多个数据包,分摊轮询调度的固定开销。
  3. CPU 亲和性与负载均衡: 通过多队列(RSS/RPS)将不同队列的轮询绑定到不同 CPU 核心,避免单核成为瓶颈。
关键洞察: NAPI 并不是完全抛弃中断,而是将中断作为“唤醒信号”,将实际的数据搬运工作交给轮询。这种“中断唤醒 + 轮询处理”的组合,在高速网络场景下比纯中断或纯轮询都更高效。

2.3 NAPI 核心思想:轮询与中断结合

NAPI 的工作流程可以分解为以下四个阶段:

  1. 中断触发阶段: 网卡收到第一个数据包,触发硬件中断。CPU 执行 ISR,但 ISR 只做最轻量的工作——标记设备为“轮询模式”,然后关闭该设备的硬件中断,并调用 NAPI 调度器。
  2. 轮询调度阶段: NAPI 调度器将设备加入轮询列表(poll_list),并触发软中断(NET_RX_SOFTIRQ)。软中断上下文开始遍历 poll_list,调用每个设备的 poll() 回调函数。
  3. 批量收包阶段: poll() 回调从网卡硬件队列中批量读取数据包(通常一次最多 64 个),构造 sk_buff 并送入协议栈。如果队列被清空,或者达到预算(budget)上限,则停止轮询。
  4. 中断重开阶段: 当轮询结束且队列为空时,重新启用该设备的硬件中断,等待下一个数据包的到来。
NAPI 核心流程:中断唤醒 + 轮询处理 ① 中断触发 网卡收到数据包 触发硬件 IRQ ② 中断关闭 + 调度 ISR 关闭设备中断 触发 NET_RX_SOFTIRQ ③ 轮询收包 poll() 批量读取 构造 sk_buff 送协议栈 队列空? ④ 重开中断 启用硬件中断 是 继续轮询 再次调用 poll() 否 轮询循环(直到队列空或达到预算) 💡 关键设计点: • 中断只负责“唤醒”,不负责“搬运”——ISR 必须轻量,避免在中断上下文做耗时操作 • 轮询阶段关闭硬件中断,避免新的中断干扰正在进行的批量收包

2.4 NAPI 与传统中断模型的性能对比

为了直观展示 NAPI 的优势,我们通过一个简单的实验数据来对比两种模型在不同包速率下的 CPU 占用率:

不同包速率下 CPU 占用率对比(千兆网卡,64 字节包)
包速率(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 并触发软中断。
NAPI 软件架构层次图 用户态应用程序(socket 接口) 网络协议栈(TCP/IP、UDP、RAW) __netif_receive_skb() / netif_receive_skb() NAPI 调度层(软中断上下文) net_rx_action() → poll_list 遍历 → poll() 回调 网卡驱动层(ISR + poll 回调) napi_schedule() → 关闭中断 → 批量读取硬件 FIFO 网卡硬件(FIFO 队列、DMA 引擎)

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(预算)机制确保公平调度。