尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

深入解析Linux内核NAPI机制:从中断风暴到轮询收包

发布时间:2026/9/26 1:22:04

资讯中心
01
ARTICLE

深入解析Linux内核NAPI机制:从中断风暴到轮询收包

深入解析Linux内核NAPI机制:从中断风暴到轮询收包
从一次网卡中断说起为什么 Linux 收包路径里会有 NAPI 这种“奇怪”的存在以及它到底解决了什么核心问题。如果你做过嵌入式网络的性能调优或者看过别人抓包时软中断全挤在一个 CPU 上大概率已经听说过 NAPINew API这套机制。这篇文章不绕弯子直接从 Linux 内核源码的角度把 NAPI 的机制拆开讲清楚会涉及驱动注册、收包调度、poll 循环、budget 配额以及与之配套的 GRO/中断合并等概念。内容更适合已经具备一定 Linux 网络基础、想深入理解内核收包链路的读者也适合正在准备内核或网络方向面试的人参考。很多资料会把 NAPI 解释成“中断和轮询的混合体”这个说法方向是对的但不够精确。真正的 NAPI 更像是一个“受控的中断压制机制”高流量时主动屏蔽网卡中断让内核以软中断轮询的方式批量取包低流量时则恢复中断通知保证延迟足够低。这种动态切换的能力让它在吞吐和延迟之间找到了一个相对理想的平衡点。下面我们进入正题从设计思路上先做整体拆解。1. 整体设计思路拆解为什么传统中断收包不够用1.1 中断收包的“风暴”问题传统网卡驱动收包的模式很简单网卡每收到一个数据包就向 CPU 触发一次硬件中断中断处理程序把包从硬件队列搬到内核的接收队列然后唤醒后续处理。这条路径在包量不大时没什么问题单包延迟很低响应非常及时。但一旦网络流量增大一串串的小包持续到达CPU 就会陷入一种忙乱状态每次收包都要经历“硬件中断 → 保存/恢复上下文 → 进入驱动处理 → 退出中断”这样一套完整过程。包越多中断频率越高CPU 大量时间都耗在中断现场切换上真正用于处理数据的比例反而很低。这就叫中断风暴interrupt storm。打个比方这就像一个服务员每次只接一位顾客的订单然后在后厨和前台之间来回跑。顾客少时这样服务的确很周到但顾客一多服务员绝大部分力气都花在跑腿上真正传菜的时间反而少得可怜。中断收包模式在高 PPS每秒数据包数面前就是这么崩溃的。除了 CPU 开销上升中断风暴还带来了另一个麻烦缓存污染。每次中断都要访问网卡寄存器、搬移数据导致 CPU 缓存频繁被刷新而网络数据处理又特别依赖缓存命中率。缓存一旦被反复打穿性能劣化会更加明显形成恶性循环。1.2 NAPI 的解题思路用轮询池化收包NAPI 的核心思路是当包到达速率超过阈值时驱动先把网卡的中断关掉把设备的收包状态挂到一个“轮询列表”poll list上然后触发一次软中断softirq。在软中断里内核会统一遍历这个轮询列表驱动通过 poll 回调函数一次性把硬件队列里的包批量取走直到取够一定数量budget或者队列空了为止。只有队列被清空后驱动才会重新打开中断并把设备从轮询列表上移除。这个过程的关键点在于中断不再是“每个包触发一次”而是“一批包处理完后再重新打开”。这样既保证了高流量下 CPU 以批量模式高效工作又在低流量下让每个包都能立刻触发中断不增加延迟。这套设计里有几个名词你需要先放在脑子里napi_struct是驱动和协议栈之间的“轮询句柄”poll是驱动实现的取包回调函数budget是一次轮询允许处理的最大包数poll list是当前所有等待轮询的设备队列。1.3 NAPI 在收包路径中的位置在完整的内核收包路径中NAPI 刚好卡在“驱动层收包”和“协议栈入栈”之间。传统路径是网卡硬件中断 → 驱动中断处理函数 → 直接把skb传给netif_receive_skb→ 协议栈。NAPI 路径是网卡硬件中断 → 驱动中断处理函数只是标记设备有包 → 触发软中断NET_RX_SOFTIRQ→net_rx_action遍历轮询列表 → 驱动poll批量取包 → 逐个送入netif_receive_skb→ 协议栈。两条路径最终都到协议栈但中间的工作方式和性能特征完全不同。NAPI 路径中真正耗费 CPU 的驱动收包和协议栈处理都发生在软中断上下文中而且是以批量轮询的方式统一处理的。2. NAPI 核心机制拆解必须吃透的几个关键点2.1 先从napi_struct这个结构体看起内核源码里napi_struct的定义在include/linux/netdevice.h我见过很多驱动工程师对这个结构体一知半解实际上它才是 NAPI 机制的“操作面板”。这里挑核心字段解释一下struct napi_struct { struct list_head poll_list; /* 挂在当前 CPU 的 poll list 上 */ unsigned long state; /* NAPI_STATE_SCHED 等状态位 */ int (*poll)(struct napi_struct *, int); /* 驱动实现的取包函数 */ int weight; /* 单次 poll 允许处理的最大包数 */ unsigned int gro_count; /* 挂在该 napi 上的 GRO 流数量 */ struct sk_buff *skb; /* 用于暂存 GRO 聚合中的 skb */ ... };state字段里的NAPI_STATE_SCHED位非常关键它表示这个 napi 当前已经处于“被调度”状态。如果这个位已经是 1驱动再调用napi_schedule时就会直接失败不会重复把设备挂进轮询列表。这个位其实就是一把隐形的锁确保同一个设备不会在同一个 CPU 上被重复调度多次。weight字段由驱动在初始化时设置通常就是 64。它并不直接限制驱动一次最多收多少包而是一个“建议配额”。真正起限制作用的还有软中断里的全局budget后面会细讲。驱动里的poll函数返回值也很有讲究返回值为 0 表示“我已经把队列取空了可以让我休息了”返回非 0通常是返回剩余配额表示“我这边还有包请继续调度我”。如果不理解这个返回值协议写出来的驱动会要么收包收不干净要么陷入死循环。2.2 核心流程从napi_schedule到net_rx_actionNAPI 的触发入口是驱动在硬件中断处理中调用napi_schedule。顺着源码往下追实际执行路径是这样的static inline void ____napi_schedule(struct softnet_data *sd, struct napi_struct *napi) { list_add_tail(napi-poll_list, sd-poll_list); __raise_softirq_irqoff(NET_RX_SOFTIRQ); }这里有两个动作第一把当前设备的napi_struct挂到当前 CPU 的软中断数据结构softnet_data的轮询列表尾部第二触发NET_RX_SOFTIRQ软中断。注意“当前 CPU”这个词——NAPI 的调度是 per-CPU 的中断发生在哪个 CPU 上这个设备就会被挂到哪个 CPU 的轮询列表里。这也是后来很多人做 RPSReceive Packet Steering时踩坑的起点中断绑核之前和之后软中断的分布会有很大的区别。当软中断被触发后内核会执行net_rx_action这个函数就是整个 NAPI 轮询的总控制器定义在net/core/dev.c里。它的核心逻辑是一个while循环int budget netdev_budget; int time_limit jiffies 2; while (!list_empty(sd-poll_list)) { struct napi_struct *napi; ... napi list_first_entry(sd-poll_list, struct napi_struct, poll_list); budget - napi_poll(napi, budget); ... if (unlikely(budget 0 || time_after_eq(jiffies, time_limit))) { need_reschedule true; break; } }net_rx_action有两个限制条件budget和time_limit。budget是全局的总配额默认值是 300表示每次软中断处理最多允许处理 300 个包这是旧的权重计算方式不同内核版本略有出入time_limit是一个两毫秒的时间窗到期后即使配额没用完也会让出 CPU。这两个条件共同保证了软中断不会霸占 CPU 太久给其他任务留出执行窗口。napi_poll会在内部调用驱动注册的poll回调。这里有个容易误解的细节驱动poll函数拿到的配额是“全局剩余预算”和“设备自身 weight”中较小的那个。这样设计是为了防止某个设备占用全部配额导致同一 CPU 上的其他设备饿死。2.3 poll 回调驱动怎么配合 NAPI 工作驱动实现 NAPI 的核心是poll函数。让我们虚构一段典型收包代码来理解它的工作模式int my_poll(struct napi_struct *napi, int budget) { struct my_priv *priv container_of(napi, struct my_priv, napi); int work_done 0; while (work_done budget) { struct sk_buff *skb my_rx_ring_dequeue(priv); if (!skb) break; napi_gro_receive(napi, skb); /* 经过 GRO或直接调用 netif_receive_skb */ work_done; } if (work_done budget my_rx_ring_is_empty(priv)) { napi_complete_done(napi, work_done); /* 重新开启中断 */ return 0; /* 返回值 0 表示收完了 */ } return work_done; /* 返回非 0软中断会继续调度我 */ }这段代码里有几个地方值得挑出来解释。第一work_done budget判断条件隐含了一个事实如果队列里持续有包poll 会一直取到配额上限才停止然后返回非零值让net_rx_action把它再次挂入轮询调度第二只有队列为空驱动才会调用napi_complete_done这个函数会清除NAPI_STATE_SCHED位并重新启用中断第三驱动收下来的包是通过napi_gro_receive进入协议栈的而不是直接调用老的netif_receive_skb。有一个低级错误需要特别提醒很多人在写 poll 时忘记了只有work_done budget时才能认为队列空了。如果消费了满配额返回 0会导致明明还有包但设备却被移出轮询并重新开启了中断。这样中断很快会再次触发结果就是“中断风暴依旧轮询形同虚设”。这是新手驱动工程师最容易犯的问题我曾经在调试自己写的虚拟网卡驱动时被这个问题折磨过一晚上。2.4 关闭和重新开启中断的时机NAPI 的精髓在于中断的开关时机而这个时机是和 poll 的返回值强绑定的。当流量突然增大时网卡中断触发驱动驱动调用napi_schedule把设备挂上轮询列表然后应该立刻关闭网卡中断。注意这个关中断的动作通常不是由通用内核代码完成的而是驱动自己在中断处理函数中完成的。也就是说NAPI 框架负责“轮询调度”中断开关这步是驱动的责任。很多驱动在初始化时就会注册一个poll函数和napi_struct在中断处理函数中做两步关中断 napi_schedule。重新开中断的工作则包在napi_complete_done里。它会调用驱动的ndo_poll_controller对应机制的底层最终使能设备中断。我在源码里看到过很多人困惑为什么napi_complete_done里会有对GRO状态、NAPI_STATE_NO_BUSY_POLL的一堆判断其实就是为了确保在正确场景下才重新打开中断避免在 busy poll 期间反复被中断打扰。3. 实操环节源码级流程走读与参数选择3.1 对齐版本不同内核里 NAPI 的差异在真正去翻源码之前建议你先确认自己看的内核版本。NAPI 在 2.6 时代定型但进了 4.x、5.x、6.x 之后细节变化非常多。比如旧版本里netif_receive_skb是必经之路新版本里引入了__netif_receive_skb_core和skb_gro_receive的复杂协作。net_rx_action里对budget的处理方式也有微调旧版本里只用包数量配额新版本还叠加了time_limit。我建议直接读代码不要死记网上的流程图。打开net/core/dev.c搜索net_rx_action对照你手头内核版本实际读一遍。再打开一个简单驱动的源码比如drivers/net/ethernet/broadcom/bnxt/bnxt.c这种大型商业驱动可能太复杂建议看虚拟设备比如drivers/net/tun.c或者drivers/net/veth.c。虚拟设备的 NAPI 实现简化了很多硬件细节把轮询、队列、中断模拟这几个概念隔离开更容易读懂。3.2 核心路径源码注释与讲解我把net_rx_action的路径拆解成几个阶段来说明。第一阶段是初始化记账状态读取本 CPU 的softnet_data初始化budget和时间限制。第二阶段是主循环只要 poll list 非空就取第一个 napi、调用napi_poll、将返回值从预算中扣除。第三阶段是退出判断如果预算耗尽或时间到了就重新触发一次NET_RX_SOFTIRQ软中断让这个 CPU 稍后继续处理剩余的轮询设备。这个第三阶段很值得玩味。它并不是让设备等待太久而是利用软中断机制的自然调度把处理过程延后到下一个合适的时机。也就是说即使在一次软中断里没处理完NAPI 也不会丢失设备——它只是在当前预算耗尽后中断处理通过再次 raise softirq 来保证后续继续收。这是 NAPI 能自适应的一个关键设计。如果你继续深入napi_poll内部会看到一层trace_napi_poll的跟踪点。这个跟踪点为性能分析提供了很大的方便。用 ftrace 或者 perf 去捕获napi:poll事件可以直接知道每个 napi 实例每次被调用时处理了多少包、耗时多少。我之前排查一个 40G 网卡多队列收包分布不均匀的问题时就是靠这个 tracepoint 定位到两个队列的中断被挤在同一个 CPU 上。3.3 参数选择weight、budget 和合并策略从实际调优的角度NAPI 体系里有几个参数值得关注。首先是驱动初始化时设置的weight通常取 64。对一些高吞吐场景有人会把weight调大比如 128 或 256让单次 poll 吃掉更多包。但这只是驱动侧的值最终还要和全局netdev_budget配合。在 sysctl 中可以通过net.core.netdev_budget调整全局预算默认值随版本不同而不同常见的是 300 或 600。如果你的业务是“高吞吐、低延迟敏感度低”的大包转发可以适当调大这个值如果是小包高频交易场景反而需要维持较小的 budget防止软中断长时间占核。其次是中断合并interrupt coalescing这个不在 NAPI 框架本身里但和 NAPI 配合非常紧密。很多网卡允许设置一个中断延时定时器比如每 10 微秒或者每积累 32 个包才触发一次中断。这样 NAPI 的调度频率会下降批量效果更好。但副作用是延迟上升。我见过一些人只调大了网卡的合并参数忘记调netdev_budget结果单次中断进来后预算不够用反复触发软中断反而比默认参数更差。这两个参数是联动的调的时候必须一起考虑。还有个现代内核里必须提到的概念是 GROGeneric Receive Offload。GRO 位于 NAPI poll 流程的上游napi_gro_receive会先尝试把相同流的数据包合并成一个更大的skb再交给协议栈。这对吞吐的帮助非常大尤其是小包场景。其实 GRO 能成功的前提就是 NAPI 给它提供了“一批包的聚合窗口”。如果单纯靠中断一次一个包GRO 根本凑不齐足够的包做合并。所以 NAPI 不只是收包模式的改变它也为更上层的聚合优化创造了条件。3.4 一个实测案例从延迟抖动到参数修正之前我在调试一个网关设备时遇到过一个问题物理机转发小包时PPS 已经接近 80 万但延迟偶尔飙升到几毫秒。抓软中断日志后发现NET_RX_SOFTIRQ非常频繁地被触发而且每个 softirq 实例实际处理的包数极少说明 poll 经常“取空”后立刻重新开中断。由于小包到达是突发的中断重新打开后瞬间又有包进来于是又触发中断形成高频的中断-软中断摇摆。后来我做了两个调整一是把驱动中断合并阈值加大让硬件在中断触发前多攒一些包二是把netdev_budget从默认值调高到 1000确保单次软中断能消化掉合并后的一整批包。调整后软中断触发频率明显降低PPS 保持在 80 万左右延迟抖动也回到了几十微秒的量级。这个案例里 NAPI 本身没有改一行代码纯粹是摸清了它的调度参数和硬件中断之间的配合逻辑。4. 常见问题与排查技巧实录4.1 “为什么我的多队列网卡软中断都集中在一个 CPU 上”这个问题几乎每隔一段时间就会有人问一次。NAPI 的调度是 per-CPU 的中断落在哪个核napi 就挂到那个核的 poll list 上。如果你没有做中断绑核IRQ affinity或者 BIOS 的 NUMA 拓扑导致中断都发给了一个核那软中断自然就集中在一个核上。注意即使你看到了多个 RX queue每个 queue 有自己的中断号也并不代表它们一定会被分发到不同核上。用cat /proc/interrupts看每个中断号在各个 CPU 上的计数分布再结合smp_affinity绑定是比较标准的排查路径。如果已经做了中断绑核还不均衡就需要看 RPSReceive Packet Steering。RPS 在软件层面把数据包从接收 CPU 分发到其他 CPU 的处理队列中相当于“软中断负载均衡器”。但 RPS 的粒度是流级别的需要计算哈希并查表本身也有 CPU 开销在多队列网卡硬件已经支持 RSS 的情况下通常优先做 RSS 中断亲和性而不是 RPS。只有虚拟设备比如 veth 或 tap才值得认真考虑 RPS。4.2 “poll 返回非 0 导致 CPU 占用过高正常吗”如果你用 perf 看到net_rx_action占比特别高千万别直接断定是 NAPI 的 bug。先确认流量本身是不是真的很大。如果确实大包量持续进入poll 返回非 0 让设备继续留在轮询列表是合理行为net_rx_action会一直循环处理直到 budget 或时间窗消耗完。此时 CPU 占用高恰恰说明系统在高效批量收包而不是在低效空转。不过如果 CPU 占用高而吞吐没有匹配上去就要怀疑轮询循环里是不是有锁竞争或者skb分配开销过大的问题。比较常见的隐藏性能杀手是sk_buff的内存分配。NAPI 高频轮询时napi_alloc_skb会尝试从 per-CPU 的 skb 缓存池里拿内存但如果驱动没有正确使用这些缓存感知接口而是一味用dev_alloc_skb就可能频繁触发 slab 分配和释放性能会肉眼可见地下降。这里我建议在驱动开发中尽量用 NAPI 专属的缓存感知函数而不是通用分配函数。4.3 一套 NAPI 相关问题的面试速查因为热词里很多人在搜索 Linux 内核面试题我顺手整理几个高频的 NAPI 问题。这些问题不仅面试用得上平时和技术同行交流也容易踩到。问题关键回答要点NAPI 和传统中断收包的核心区别是什么NAPI 用“中断唤醒 轮询批量处理”替代“每包中断处理”通过关闭中断的方式让驱动批量取包降低中断开销napi_schedule的完整流程是什么设置NAPI_STATE_SCHED位、把 napi 挂到当前 CPU 的 poll list、触发NET_RX_SOFTIRQ软中断poll 函数返回值 0 和非 0 的含义0 表示队列已空可重新开启中断并移除设备非 0 表示还有未处理完的包软中断循环会继续调度该设备NAPI 是怎么防止中断风暴的一次中断后关闭中断通过软中断轮询消耗预算直到队列清空再重新开启中断什么是 RPS/RSS它们和 NAPI 的配合方式是什么RSS 是硬件多队列分发RPS 是软件分发二者都通过把包分散到多个 CPU 来缓解 NAPI 的单核轮询压力另外一个容易混淆的概念是 busy poll它和 NAPI 的原理正好相反。NAPI 是“中断唤醒后关中断批量轮询”busy poll 是“用户态或内核态主动忙等轮询设备队列”目的是为了更低延迟代价是 CPU 占用极高。在一些低延迟场景中开发者会启用SO_BUSY_POLL让应用在 socket 上主动轮询而不依赖中断唤醒。这种做法和 NAPI 可以说是互补的一个是延迟优先一个是吞吐优先。4.4 查看和验证 NAPI 运行状态的方法如果你写了一套驱动或者想观察现有驱动的 NAPI 行为可以用/proc/net/softnet_stat。每一行表示一个 CPU其中各列数据里比较关键的是第二列表示因为 budget 耗尽而被迫退出 softirq 的次数。如果这个数字增长很快说明netdev_budget不够用或者 poll 返回非 0 过于频繁。还有一个指标是第三列的time_squeeze表示因为时间窗用完而被迫退出。这两个指标侧重点不同前者偏吞吐受限后者偏调度延迟受限。如果你在用 ftrace可以直接跟踪napi:poll事件。命令大概是echo napi:poll /sys/kernel/tracing/events/enable cat /sys/kernel/tracing/trace_pipe每条记录会输出napi_struct地址、设备名、预算、实际处理的包数、耗时。这些数据能帮你精确定位哪台设备在哪个 CPU 上消耗了多少处理时间。我在实战中经常用它对比多队列之间的均衡性以及同一队列在前后修改中断合并参数后的行为变化。5. 经验体会NAPI 调优时最值得记住的三件事写到这里该总结一些真正从实操中沉淀下来的东西了。我不打算搞什么宏观展望就说几个我反复踩过的坑。第一永远不要只调一个参数。很多人看到软中断高就盲目调大netdev_budget结果软中断占用更猛延迟更差。NAPI 的吞吐能力和中断频率、驱动 weight、GRO 聚合、RSS 队列数都是一整套协同关系。调任何参数之前先花时间把softnet_stat和perf的数据采集齐让数据告诉你瓶颈在哪而不是靠感觉。第二设备中断绑核比任何 NAPI 参数都重要。我曾经碰到过一个问题用户说 NAPI 性能很差ping 延迟不稳定查了半天发现两个 RX 队列的中断都落在了同一个 CPU 上。用smp_affinity把两个队列分别绑到两个不同的物理核后延迟立刻恢复。NAPI 是 per-CPU 调度的所以 CPU 的分布就决定了软中断处理能力的上限。先把绑核做好再讨论预算调优。第三驱动开发时严格遵守 poll 返回值协议。这是 NAPI 机制能正确工作的基石。一个return 0和return work_done的区别可以导致完全不一样的收包行为。写驱动时我会在 poll 的末尾强制用一个注释标清楚当前分支下的队列状态就是怕日后自己回来看代码时搞混。内核开发很多 bug 都是这种“看起来没毛病但逻辑只对了一半”的代码引起的。最后再分享一个小技巧遇到 NAPI 收包异常时别急着去看复杂的协议栈先用perf top看看net_rx_action和napi_poll占了多大比例。如果比例高而吞吐低大概率是轮询循环内部有问题如果比例低而吞吐高说明真正的工作已经下沉到了协议栈后续处理比如 IP 层和 TCP 层那就要换一个排查方向了。把问题定位的边界画清楚内核网络调试就能少走不少弯路。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。