4. NAPI调度机制

NAPI 的核心调度机制解决了传统中断方式下“中断风暴”导致 CPU 被锁死的问题。其关键思想是:在高速流量下,关闭硬件中断,改用轮询(Polling)方式从 Ring Buffer 中批量取包。本章将深入剖析 napi_schedule() 函数、__napi_schedule() 内部实现、软中断触发流程(NET_RX_SOFTIRQ),以及从硬件中断到 NAPI 的完整切换过程。

核心理解: NAPI 调度本质上是将数据接收的“驱动力”从硬件中断转移到软中断。硬件中断只负责“唤醒”处理流程,后续的批量数据搬运由软中断在安全上下文中完成。

4.1 调度入口:napi_schedule() 函数

napi_schedule() 是驱动开发者最常调用的 NAPI 调度入口函数,通常在硬件中断处理函数(ISR)的尾部调用。它的职责是:判断当前 NAPI 实例是否已经在调度中,如果不在,则将其加入 CPU 的待处理队列,并触发软中断。

函数原型与核心逻辑如下:

// 文件: net/core/dev.c
static inline void napi_schedule(struct napi_struct *n)
{
    // 1. 检查 NAPI_STATE_SCHED 标志位,确保不会重复调度
    if (napi_schedule_prep(n))
        // 2. 执行实际的调度操作
        __napi_schedule(n);
}

napi_schedule_prep() 是一个关键的内联函数,它执行以下原子操作:

  • 测试并设置 NAPI_STATE_SCHED 标志:使用 test_and_set_bit() 原子操作,防止多个 CPU 同时调度同一个 NAPI 实例。
  • 检查 NAPI_STATE_DISABLE 标志:如果 NAPI 已被禁用(例如设备关闭),则跳过调度。
  • 检查 NAPI_STATE_NO_BUSY_POLL:确保不会与忙轮询(Busy Polling)模式冲突。
驱动开发要点: 在硬件中断处理函数中,务必在调用 napi_schedule() 之前,先关闭该设备的硬件接收中断。否则,在 NAPI 轮询期间,新的数据包会再次触发中断,导致“中断-轮询”反复切换,降低性能。

4.2 内部实现:__napi_schedule() 深度解析

__napi_schedule() 是 NAPI 调度的核心实现,它负责将 NAPI 实例挂载到当前 CPU 的软中断处理队列中。其实现位于 net/core/dev.c:

void __napi_schedule(struct napi_struct *n)
{
    unsigned long flags;

    // 1. 获取当前 CPU 的软中断数据结构
    struct softnet_data *sd = &__get_cpu_var(softnet_data);

    // 2. 获取锁,保护链表操作
    local_irq_save(flags);

    // 3. 将 NAPI 实例添加到 poll_list 链表的尾部
    list_add_tail(&n->poll_list, &sd->poll_list);

    // 4. 设置 NAPI 所属的 CPU
    n->napi_id = sd->napi_id;

    // 5. 释放锁
    local_irq_restore(flags);

    // 6. 触发 NET_RX_SOFTIRQ 软中断
    __raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

该函数的关键步骤解析:

步骤 操作 说明
1 获取 softnet_data 每个 CPU 都有一个独立的 softnet_data 结构体,用于管理该 CPU 上的 NAPI 实例和接收队列。
2 关闭本地中断 使用 local_irq_save() 防止在链表操作期间被硬件中断打断,保证数据一致性。
3 链表尾部插入 将 NAPI 实例的 poll_list 节点添加到 sd->poll_list 尾部。软中断处理时会遍历此链表。
4 设置 CPU ID 记录该 NAPI 实例当前绑定的 CPU,用于后续的负载均衡和调试。
5 恢复中断状态 使用 local_irq_restore() 恢复之前的中断状态。
6 触发软中断 调用 __raise_softirq_irqoff() 设置 NET_RX_SOFTIRQ 位,通知内核在合适的时机执行接收软中断处理函数。
性能陷阱: local_irq_save() 和 local_irq_restore() 虽然保证了原子性,但会短暂关闭本地 CPU 的中断。在极端高频率的 NAPI 调度场景下,频繁的中断开关操作会引入微秒级的延迟。对于 100Gbps 网卡,建议使用 napi_schedule_irqoff() 变体,它假设调用者已经关闭了中断。

4.3 软中断触发流程:NET_RX_SOFTIRQ

NET_RX_SOFTIRQ 是网络接收软中断的编号,其处理函数是 net_rx_action()。当 __raise_softirq_irqoff(NET_RX_SOFTIRQ) 被调用后,内核会在以下时机执行 net_rx_action():

  • 硬件中断返回时:在 irq_exit() 函数中,内核会检查是否有待处理的软中断,如果有则调用 invoke_softirq()。
  • ksoftirqd 内核线程:如果软中断处理时间过长,或者软中断被频繁触发,内核会唤醒每个 CPU 上的 ksoftirqd 线程来执行剩余的软中断处理。
  • 显式调用:某些内核路径(如网络协议栈处理)会主动调用 do_softirq() 来加速处理。

net_rx_action() 的处理流程如下:

static void net_rx_action(struct softirq_action *h)
{
    struct softnet_data *sd = &__get_cpu_var(softnet_data);
    unsigned long time_limit = jiffies + 2;  // 时间预算:2 个 jiffies
    int budget = netdev_budget;              // 预算:默认 300 个包

    // 遍历当前 CPU 的 poll_list 链表
    while (!list_empty(&sd->poll_list)) {
        struct napi_struct *n;

        // 从链表头部取出 NAPI 实例
        n = list_first_entry(&sd->poll_list, struct napi_struct, poll_list);

        // 调用 NAPI 实例的 poll 方法
        work_done = n->poll(n, budget);

        // 根据 work_done 判断是否继续轮询
        if (work_done < budget) {
            // 包已取完,从链表移除,重新开启硬件中断
            list_del(&n->poll_list);
            napi_complete(n);
        } else {
            // 包未取完,保持 NAPI 在链表中,下次继续轮询
            // 但需要检查时间预算和总预算
        }

        // 检查预算和时间限制
        budget -= work_done;
        if (budget <= 0 || time_after(jiffies, time_limit))
            break;
    }

    // 如果链表非空,重新触发软中断
    if (!list_empty(&sd->poll_list))
        __raise_softirq_irqoff(NET_RX_SOFTIRQ);
}
预算机制: budget 和 time_limit 是 NAPI 防止 CPU 被锁死的两个关键保护机制。即使网络流量再大,net_rx_action() 也不会无限期占用 CPU,它会在消耗完预算或超时后退出,将 CPU 让给其他任务。

4.4 从硬件中断到 NAPI 的完整切换流程

下图展示了从网卡硬件中断触发,到 NAPI 轮询处理,再到重新开启中断的完整生命周期:

从硬件中断到 NAPI 的完整切换流程 ① 硬件中断到达 网卡触发 IRQ ② ISR 执行 关闭本设备中断 ③ napi_schedule() 设置 SCHED 标志 ④ __napi_schedule() 加入 poll_list 链表 ⑤ 触发 NET_RX_SOFTIRQ 设置软中断位 ⑥ net_rx_action() 遍历 poll_list ⑦ n->poll() 回调 批量取包处理 ⑧ napi_complete() 清除 SCHED 标志 ⑨ 重新开启中断 使能硬件 IRQ 还有包? work_done == budget 重新触发软中断 是 NAPI 调度结束 否 关键说明: • 步骤 ② 中关闭中断是为了防止在 NAPI 轮询期间再次触发相同设备的中断。 • 步骤 ⑨ 重新开启中断后,下一个数据包会再次触发硬件中断,形成完整的“中断-轮询”循环。 • 如果流量持续很高,步骤 ⑥ 会反复执行,直到预算耗尽或时间超时。

4.5 关键数据结构与状态转换

NAPI 调度涉及的核心数据结构是 napi_struct,其关键字段如下:

字段 类型 作用
poll_list struct list_head 用于将 NAPI 实例链接到 CPU 的 softnet_data->poll_list 链表中。
poll poll_func 指向驱动实现的轮询函数指针,由 netif_napi_add() 注册。
state unsigned long 状态标志位,包括 NAPI_STATE_SCHED、NAPI_STATE_DISABLE 等。
weight int 每次 poll 调用的最大处理预算,通常为 64。
dev struct net_device * 指向所属的网络设备。

NAPI 实例的状态转换是调度机制的核心:

// NAPI 状态标志位定义 (include/linux/netdevice.h)
enum {
    NAPI_STATE_SCHED,      // Bit 0: 正在调度中(在 poll_list 中)
    NAPI_STATE_DISABLE,    // Bit 1: 被禁用
    NAPI_STATE_NPSVC,      // Bit 2: 不可抢占(用于调试)
    NAPI_STATE_HASHED,     // Bit 3: 已加入哈希表
    NAPI_STATE_NO_BUSY_POLL, // Bit 4: 禁止忙轮询
    NAPI_STATE_IN_BUSY_POLL, // Bit 5: 正在忙轮询
};

状态转换的关键路径:

  1. 空闲 → 调度中:napi_schedule_prep() 设置 NAPI_STATE_SCHED 位。
  2. 调度中 → 处理中:net_rx_action() 调用 poll() 回调。
  3. 处理中 → 空闲:napi_complete() 清除 NAPI_STATE_SCHED 位,并从 poll_list 中移除。
  4. 处理中 → 调度中:如果 poll() 返回 budget(表示还有包),NAPI 实例保留在 poll_list 中,等待下一次 net_rx_action() 调用。
性能调优实战: 在 40Gbps 以上的高速网卡驱动中,建议将 weight 值从默认的 64 调整为 128 或 256。更大的 weight 意味着每次 poll 可以处理更多数据包,减少了软中断的触发次数,从而降低 CPU 开销。但需要注意,过大的 weight 会导致其他网络流量的延迟增加(公平性问题)。

4.6 中断与 NAPI 的切换条件

驱动开发者需要理解何时使用中断模式,何时切换到 NAPI 模式。以下是典型的切换策略:

场景 模式 原因
低流量(< 1000 pps) 纯中断 中断开销小,延迟最低。
中等流量(1000 - 10000 pps) NAPI 混合模式 中断触发 NAPI,NAPI 处理完立即切回中断。
高流量(> 10000 pps) NAPI 持续轮询 中断关闭,NAPI 持续从 Ring Buffer 取包,直到包取完。
极端流量(线速) NAPI + 多队列 RSS 每个 CPU 独立运行 NAPI,配合硬件多队列分流。

驱动中实现切换的典型代码模式:

// 硬件中断处理函数
static irqreturn_t my_nic_isr(int irq, void *dev_id)
{
    struct my_nic_adapter *adapter = dev_id;
    struct napi_struct *napi = &adapter->napi;

    // 1. 读取中断状态寄存器,确认是接收中断
    u32 status = readl(adapter->ioaddr + INT_STATUS);
    if (!(status & RX_INT_PENDING))
        return IRQ_NONE;

    // 2. 关闭接收中断(防止中断风暴)
    writel(0, adapter->ioaddr + RX_INT_ENABLE);

    // 3. 调度 NAPI
    if (napi_schedule_prep(napi)) {
        // 4. 将 NAPI 加入 poll_list 并触发软中断
        __napi_schedule(napi);
    }

    return IRQ_HANDLED;
}

// NAPI poll 回调函数
static int my_nic_poll(struct napi_struct *napi, int budget)
{
    struct my_nic_adapter *adapter = container_of(napi, struct my_nic_adapter, napi);
    int work_done = 0;

    // 从 Ring Buffer 批量取包
    while (work_done < budget) {
        struct sk_buff *skb = my_nic_get_packet(adapter);
        if (!skb)
            break;
        napi_gro_receive(napi, skb);  // 送入 GRO 处理
        work_done++;
    }

    // 判断是否还有包
    if (work_done < budget) {
        // 包已取完,完成 NAPI,重新开启中断
        napi_complete(napi);
        writel(RX_INT_ENABLE, adapter->ioaddr + RX_INT_ENABLE);
    }

    return work_done;
}
常见错误: 在 poll() 回调中忘记调用 napi_complete() 会导致 NAPI 实例永远停留在 poll_list 中,软中断会无限循环调用 poll(),最终导致 CPU 软锁(soft lockup)。务必在包取完后调用 napi_complete() 并重新开启中断。

4.7 本章小结

NAPI 调度机制是 Linux 网络高性能收包的核心。通过 napi_schedule() → __napi_schedule() → NET_RX_SOFTIRQ → net_rx_action() → poll() 的完整链路,NAPI 实现了从硬件中断到软中断轮询的平滑切换。理解这一机制,对于编写高性能网卡驱动、优化网络收包路径至关重要。