4. NAPI调度机制
NAPI 的核心调度机制解决了传统中断方式下“中断风暴”导致 CPU 被锁死的问题。其关键思想是:在高速流量下,关闭硬件中断,改用轮询(Polling)方式从 Ring Buffer 中批量取包。本章将深入剖析 napi_schedule() 函数、__napi_schedule() 内部实现、软中断触发流程(NET_RX_SOFTIRQ),以及从硬件中断到 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 轮询处理,再到重新开启中断的完整生命周期:
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: 正在忙轮询
};
状态转换的关键路径:
- 空闲 → 调度中:
napi_schedule_prep()设置NAPI_STATE_SCHED位。 - 调度中 → 处理中:
net_rx_action()调用poll()回调。 - 处理中 → 空闲:
napi_complete()清除NAPI_STATE_SCHED位,并从poll_list中移除。 - 处理中 → 调度中:如果
poll()返回budget(表示还有包),NAPI 实例保留在poll_list中,等待下一次net_rx_action()调用。
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 实现了从硬件中断到软中断轮询的平滑切换。理解这一机制,对于编写高性能网卡驱动、优化网络收包路径至关重要。