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

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

发布时间:2026/9/26 1:54:39

资讯中心
01
ARTICLE

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

深入理解Linux内核NAPI机制:从中断风暴到轮询收包
如果你维护过一台流量峰值很高的Linux服务器多半见过这种诡异现象流量刚上来网卡跑得飞起但CPU核心先红了吞吐量反而不升反降。这通常不是业务代码不行而是收包路径被打爆了。十多年前的Linux内核就饱受这类问题困扰解决方案是一个叫NAPI的机制。到今天几乎所有主流网卡驱动都靠它收包理解NAPI就是理解现代Linux网络收包体系的钥匙。这篇文章不打算只停留在“NAPI就是中断加轮询”这种概念层面我会直接从源码出发把关键数据结构、硬中断到软中断的调度链路、poll函数的返回值语义逐个拆给你看顺带把线上常见的budget、weight、time_squeeze这些调参与排查手段也讲透。无论你是想深入内核网络栈、在做嵌入式网卡驱动还是准备Linux内核面试这篇文章应该都能帮你省下不少翻源码的时间。1. 纯中断收包为什么扛不住流量高峰1.1 中断风暴每个包都是一次“打断”在NAPI出现之前Linux收包走的是纯中断驱动模式网卡每收到一个数据包就向CPU发一次硬件中断CPU打断当前正在做的事情保存现场、跳到中断处理函数、把包从DMA环形缓冲区搬出来、处理完后恢复现场继续干活。这个模式在低速网络下没什么问题但一旦流量上来问题就暴露得极其明显。一个万兆网卡每秒能收上百万个包如果你每个包都打断CPU一次CPU几乎所有时间都在做中断上下文切换而不是在真正处理包。更麻烦的是硬件中断的优先级高于软中断如果高优先级的中断持续不断地来低优先级的收包软中断可能一直无法运行包在缓冲区里堆积甚至溢出丢包。这种现象有个专业名词叫活锁。CPU看着忙到100%实际吞吐量却低得可怜。你可以把它类比成快递员每送一个包裹都要按一次门铃结果门铃响个不停快递员只能一直跑向门口开门根本腾不出手去搬货。这就是纯中断模式的死穴——收包开销跟包数量成正比而不是跟字节量成正比。1.2 NAPI的破题思路中断敲门轮询搬货NAPI的全称是New API最早在接近2.4.20的内核版本被引入在2.6内核里逐步完善核心思想非常朴素平时流量低网卡用中断来唤醒CPU处理包一旦流量变大进入所谓的高负载状态驱动就主动把中断关掉CPU改为轮询网卡接收队列批量“搬货”。这套思路之所以能解决活锁是因为它把“中断通知”和“包搬运”拆开成两件事。中断只负责举手示意真正大批量的搬货动作交给轮询循环完成单位时间内每个包分摊的CPU开销被大大压低。而且NAPI的设计还给驱动留下了一个关键决策点什么时候切换成轮询、什么时候从轮询退回中断。这个决策并不是由内核强制规定的而是由驱动在poll函数里根据当前收包情况自己判断。理解了这一点后面看驱动代码时就不会觉得绕。2. 核心数据结构从napi_struct到softnet_data2.1 napi_struct是驱动与内核之间最重要的“接口”要理解NAPI你首先得认识napi_struct这个结构体。在内核源码中它长这样我做了简化去掉了调试和统计相关的字段struct napi_struct { struct list_head poll_list; // 用于挂到当前CPU的softnet_data-poll_list unsigned long state; // 核心状态标志位 int weight; // 一次poll最多处理的包数 int (*poll)(struct napi_struct *, int); // 驱动收包的入口函数 struct hlist_node dev_list; // 挂到netdevice上的napi链表 };其中最关键的就是state字段。这里面有几个标志位你需要记住首当其冲的是NAPI_STATE_SCHED。它表示这个napi实例已经被调度到某个CPU的轮询队列里了。这个位不是简单设一下就行它承担着“防止同一个napi实例被重复调度”的互斥职责。还有一个常见的NAPI_STATE_DISABLE表示该napi已被显式禁用驱动卸载时或网卡down的时候会用到。poll函数指针就更关键了。它指向驱动自己实现的收包函数内核的软中断处理在轮询时调用的就是这个回调。你在e1000、ixgbe、virtio-net这些驱动里看到的xxx_poll就是注册进来的。2.2 softnet_data与poll_list每CPU一个分发中心NAPI的调度不是全局的而是每CPU一套。每个CPU都有一个struct softnet_data里面维护着一个poll_list链表。当一个napi实例被调度时它会被挂到当前CPU对应的softnet_data的poll_list上。这个设计初看会有点疑惑为什么说“当前CPU”因为硬中断发生时它一般只打断正在运行的那个CPU所以网卡中断触发的napi调度基本都发生在中断所在的那个CPU上。为了不引入锁竞争NAPI让每一个CPU只操作自己的poll_list这样软中断在处理时就不需要和别的CPU抢同一个链表。也正是这个每CPU的性质引出了一个现代网卡的常见做法多队列网卡会让每个硬件队列对应一个napi实例同时把不同队列的中断绑定到不同CPU核上。这样napi实例天然分散到不同的poll_list每个CPU并行收自己的队列吞吐量成倍增长。如果只开单队列单中断哪怕机器有32个核所有包也都必须由同一个CPU的软中断处理核再多也白搭。2.3 驱动注册与启停netif_napi_add不是可选项驱动在初始化网卡时必须把一个napi实例注册进内核调用的是netif_napi_add。不同内核版本的签名略有差异早期版本还有自动默认weight为64的行为后来逐步收敛为调用者必须显式传入weight。大致流程是netif_napi_add(adapter-napi, rx_ring-napi, ixgbe_poll, 64); napi_enable(rx_ring-napi);注册时除了把napi实例和网卡设备关联还要把poll函数指针填好。之后napi_enable会清除NAPI_STATE_DISABLE让这个napi进入可调度状态。对应地网卡down或驱动卸载时调用napi_disable确保不会再有新的调度进来。这里有个容易忽略的点netif_napi_add必须在网卡up之前完成而napi_enable通常在开启中断之前调用。如果中断早就开了但napi还没enable就可能出现中断来了却调度失败的情况轻则丢包重则中断处理里直接错乱。3. 调度链路全解从硬中断到poll函数3.1 硬中断里只做两件事上报和关闸当网卡收到数据包并完成DMA写内存后会触发硬件中断。以ixgbe驱动为例中断处理函数里核心逻辑简化为static irqreturn_t ixgbe_msix_clean_rings(int irq, void *data) { struct q_vector *q_vector data; // 通知硬件暂时不要再发中断配合NAPI的轮询 if (!napi_schedule_prep(q_vector-napi)) return IRQ_HANDLED; // 真正挂入本CPU的poll_list并触发NET_RX_SOFTIRQ软中断 __napi_schedule(q_vector-napi); return IRQ_HANDLED; }实际驱动代码里还会先读中断状态寄存器、清中断等但收包这条主线就这两步。napi_schedule_prep内部实际是一个test_and_set_bit(NAPI_STATE_SCHED, n-state)如果之前没有置位说明这个napi还没在轮询队列里可以安全加入如果本来就已经置位了说明网卡已经处于NAPI轮询模式、或者软中断还没执行完就什么都不做直接返回。这里你会看到调度函数在不同内核版本里有个微妙区别中断上下文里推荐用napi_schedule_irqoff变体普通上下文用napi_schedule。主要是前者内部假定本地中断已经被关闭、不需要再做额外的禁止中断保护性能更高。硬中断处理函数天然满足这个前提所以用irqoff版本是更严谨的写法。你在很多老驱动里能看到另一种写法那是因为当时的API还不分这么细。__napi_schedule再往下走核心动作就是两个把napi节点挂到当前CPU的softnet_data-poll_list尾部然后__raise_softirq_irqoff(NET_RX_SOFTIRQ)标记软中断。这里有个细节值得品一下挂链表和触发软中断必须一气呵成中间不能被打断否则可能出现软中断被触发了但链表中还没有节点或者节点挂上去了但软中断已经错过时机的情况。3.2 软中断的net_rx_action真正“搬货”的主循环硬中断处理完之后CPU在合适的时机进入软中断流程执行NET_RX_SOFTIRQ对应的net_rx_action函数。这个函数是NAPI的收包主循环简化的核心逻辑如下static void net_rx_action(struct softirq_action *h) { struct softnet_data *sd this_cpu_ptr(softnet_data); unsigned long time_limit jiffies usecs_to_jiffies(budget_usecs); int budget READ_ONCE(net_hotdata.max_backlog); // 默认300 local_irq_disable(); while (!list_empty(sd-poll_list)) { struct napi_struct *napi list_first_entry(sd-poll_list, ...); int work napi-poll(napi, napi-weight); budget - work; // ... if (work napi-weight) { // 包没处理完把napi挪到链尾继续轮询 list_move_tail(napi-poll_list, sd-poll_list); } else { // 包处理干净了清除NAPI_STATE_SCHED允许后续中断再次调度 napi_complete_done(napi, work); } if (budget 0 || time_after_eq(jiffies, time_limit)) break; } // ... }这里有几个非常关键的点。首先是budget也就是net.core.budget默认300。它的意思是当前这个CPU在这一次net_rx_action调用里最多搬运300个包。这个预算是全局共享的不管poll_list上挂了多少个napi实例总共只给300个包的预算。如果napi实例很多排队靠后的实例可能这轮一个包都轮不到得等到下一轮软中断再处理。第二个是weight。每个napi实例注册时都带了一个weight通常驱动传64。它表示这个napi的poll函数一次最多被要求处理64个包。poll函数返回实际处理的数量如果返回值和weight相等就说明“我还能干但预算用完了”内核会把napi挪到poll_list尾部下轮继续。如果返回值小于weight说明驱动认为当前的接收队列已经被搬空了这次可以告一段落进入napi_complete_done清除调度标志。第三个是时间限制net.core.budget_usecs默认2000微秒。哪怕包数量没到300如果这一轮轮询已经持续了超过2毫秒也要强制退出。这是为了防止软中断霸占CPU过久导致其他任务饿死。这个参数很多人会忽略但它恰恰是线上调优的关键点之一。3.3 poll函数返回值的约定驱动在跟内核说什么理解了主循环后再回来看驱动poll函数就会豁然开朗。以老牌的e1000驱动为例它的poll函数骨架是这样的static int e1000_poll(struct napi_struct *napi, int budget) { struct adapter *adapter container_of(napi, struct adapter, napi); int work_done 0; // 先清理发送完成队列再收包收包预算不能超过budget e1000_clean_tx_irq(adapter); work_done e1000_clean_rx_irq(adapter, budget); if (work_done budget) { // 说明ring里没有更多新包了本轮收干净 napi_complete_done(napi, work_done); // 重新开启网卡硬件中断 if (adapter-flags IFF_UP) e1000_configure_irq(adapter); return work_done; } // 收满了budget但可能还有包返回budget让内核继续调度本napi return budget; }注意驱动这里实际上是把收到的包数和budget本身在比较。e1000_clean_rx_irq最多收budget个包如果收满了说明接收队列很可能还有货那就返回budget内核会把napi放到轮询队列尾部继续如果没满说明当前这一批已经掏空了返回的work_done少于budget内核会进入napi_complete_done清除NAPI_STATE_SCHED并置新包到达时重新触发中断的预期。所以poll函数返回值并不只是通告“我处理了多少包”它同时向内核传递一个关于队列状态的预测是这轮先歇口气还是继续加班。这层约定你一定要记住因为很多刚接触驱动源码的人看到return budget会一脸懵这函数不是返回处理数量吗怎么返回了总预算其实这是驱动在主动要求再次被调度。3.4 一条网络包从网线到协议栈的完整旅程把上面几段串起来一个完整的收包生命周期就出来了。网卡收到数据后由DMA把包写入内存中的接收ring buffer网卡触发MSI-X中断CPU进入中断处理函数关闭该队列的硬件中断调用napi_schedule_irqoff把napi挂到本CPU的poll_list并触发NET_RX_SOFTIRQCPU回到软中断上下文执行net_rx_action遍历poll_list调用驱动的poll函数驱动从ring buffer中取出skb经过GRO合并、RPS分流交给上层协议栈最终进入socket接收队列。如果ring里包被掏空驱动返回小于weight的值内核清除调度状态并重新打开硬件中断如果没掏空继续轮询直到budget耗尽或时间到。这套流程最精巧的地方在于整个过程里包数量越大越倾向于轮询批量处理包数量小则依靠中断随到随收。切换的决策分散在驱动的poll返回值里每个网卡驱动都可以根据硬件特性做微调而不是由内核一刀切。4. NAPI机制的现代化扩展与调参实战4.1 budget、weight和budget_usecs三个容易混的参数NAPI相关的内核参数里net.core.budget、net.core.weight和net.core.budget_usecs是最常被混淆的三兄弟。先给个明确的区分表参数默认值作用范围含义net.core.budget300每个CPU一次net_rx_action整个轮询循环最多处理的包总数net.core.weight64每个napi实例驱动poll一次最多处理的包数也是注册时传给netif_napi_add的值net.core.budget_usecs2000每个CPU一次net_rx_action轮询最多持续的微秒时间在实际踩坑中很多人只看包数量不看时间限制导致一个CPU上即使只挂了几个napi实例只要流量大实际每次轮询也就跑了2毫秒就被踢出去。所以调NAPI性能时我通常会同时关注budget和budget_usecs。最常用的健康指标是/proc/net/softnet_stat里的time_squeeze列。你可以用watch -n 1 cat /proc/net/softnet_stat观察。time_squeeze的含义是一轮net_rx_action结束时poll_list上还有napi等着处理但预算或时间已经耗尽只能被迫退出。如果这个数值持续快速增长说明你的收包预算确实不够用。这时候可以考虑调大budget但简单粗暴地调大budget会带来一个问题软中断单次运行时间变长应用延迟和调度延迟可能变差。建议配合budget_usecs一起把握节奏比如把budget从300调到600budget_usecs从2000调到4000边调边压测观察。我几年前调过一台双路服务器上的ixgbe 10G网卡默认参数下time_squeeze几乎每一个刷新周期都在涨。把budget调到600、budget_usecs调到4000后time_squeeze停止了增长吞吐量也上去了代价是某些低优先级应用的延迟偶发变高。后来针对业务场景又把budget_usecs降回3000折中处理。这类参数没有一劳永逸的答案只能按业务性状实测。4.2 低延迟场景下的BUSY POLL机制NAPI解决的是高负载下的活锁问题但中断本身仍然有延迟。对于高频交易、游戏服务器这类对单包延迟极为敏感的场景内核还提供了busy poll机制也就是NAPI_STATE_PREFER_BUSY_POLL和相关socket选项SO_BUSY_POLL。busy poll的思路是应用层在socket上调用recv时如果数据还没到与其休眠等中断不如主动在用户态上下文去调用驱动的poll逻辑直接查看网卡ring里有没有新包。这样一来数据在收包路径上少了一次中断调度和软中断排队的时间延迟能降低不少。代价是CPU占用会明显上升因为进程在无包可收时会自旋等待而不是睡眠唤醒。实际项目中我会把它限制在延迟敏感的少数socket上绝不对所有socket全局开启。全局开net.core.busy_poll会让系统所有进程的收包行为都变成自旋CPU容易打满很多线上事故就是这么来的。这一块比较适合做性能优化专题单独展开这里你只需要知道NAPI的底层poll机制为busy poll铺好了路两者是同一套基础设施上的不同用法。4.3 与GRO、RPS的配合NAPI只是收包链路的一个环节驱动在poll函数里收上来的skb通常情况下并不会直接交给协议栈而是先经过GRO合并。GRO的全称是Generic Receive Offload它把多个小包合并成一个更大的skb再往上层传减少协议栈的处理次数。你可以在驱动的poll里找到napi_gro_receive这样的调用这就是在把收上来的包交给NAPI框架做GRO处理。net_rx_action在驱动poll返回后会统一flush当前napi的gro_list所以gro和napi是紧密结合的一套机制。另一个与NAPI密切相关的机制是RPS。RPS提供了把收包处理分散到多核的能力。当一个CPU的net_rx_action处理完驱动poll后它会把每个skb按hash映射到目标CPU投递到目标CPU的softnet_data-input_pkt_queue里并唤醒目标CPU的backlog处理。注意这个backlog本身也是一个特殊形态的napi它的poll函数是process_backlog。所以你在调试RPS相关的队列问题时本质上还是在调NAPI这套框架。顺带一提在内核源码里你会看到enqueue_to_backlog和____napi_schedule这样两个函数。前者负责把包塞进目标CPU的input_pkt_queue后者负责把backlog挂在目标CPU的poll_list并触发软中断。两兄弟配合才能完成RPS的跨CPU投递。5. 诊断、避坑与面试考点5.1 先学会看这几个文件和命令排查网络收包性能问题我一般按这个顺序看指标。首先是/proc/net/softnet_stat。这个文件的每一行代表一个CPU列的含义包含processed、dropped、time_squeeze等。很多人第一眼看到满屏十六进制数字会发怵其实只要盯第三列time_squeeze和丢包相关列大部分问题就能暴露。其次是ethtool -S ethX。这里能看到网卡硬件层面的统计比如rx_errors、rx_missed、rx_no_buffer之类。如果rx_missed在涨说明网卡DMA缓冲区或者驱动环没跟上不是NAPI能独立解决的。多队列网卡还可以用ethtool -l ethX查看当前队列配置用ethtool -L调整队列数然后再看/proc/interrupts确认中断有没有均匀分布在多个核上。还要学会看软中断分布。mpstat -I CPU -P ALL 1可以看每个CPU上softirq的占比。如果只有一个核的softirq接近100%其他核很闲那多半是中断绑核或者RPS配置没做好NAPI再合理也只是一条车道上的快跑者。5.2 我踩过的几个NAPI相关坑第一个坑是盲目调大budget。曾经为了减少time_squeeze我把budget调到2000结果确实没有time_squeeze了但整个CPU的软中断占用长期拉满业务进程被严重挤压。后来才意识到time_squeeze这个指标本身不是敌人它是“预算用完被踢出”的一个信号。真正要解决的是包的分布和数量而不是无限放大单轮预算。第二个坑是irqbalance和RPS配置打架。irqbalance会自动迁移网卡中断来均衡负载但它主要基于中断数量RPS是内核层的软中断分发。两个机制同时启用时如果热插拔CPU或者驱动重置中断可能悄悄集中到一个核上而你还在老地方排查RPS没生效。第三个坑发生在虚拟机环境。虚拟化网卡如virtio-net同样使用NAPI框架但如果你创建虚拟机时只配置了单队列那么virtio-net只有一个napi实例、一个vCPU能收包。此时NAPI调优作用非常有限正确解法是给虚拟机网卡加多队列然后在虚拟机里配合多队列做中断绑定。我遇到过一次云主机吞吐上不去所有NAPI参数都调过一轮最后发现就是队列数只有1。第四个坑和驱动API版本有关。老内核和老驱动混搭时有驱动在中断上下文不使用napi_schedule_irqoff而用普通的napi_schedule这在某些kprobe或抢占配置下会产生额外开销甚至可能触发BUG_ON之类的路径。看驱动源码时最好先确认你内核版本对应的推荐用法。5.3 NAPI核心面试题速答这篇文章收尾前顺手整理几个面试里常见的NAPI问题。如果你正在准备Linux内核相关的面试可以参考下面的回答要点快速自检。问题核心回答要点NAPI为什么能减轻中断压力低流量时中断驱动高流量时关闭中断改为轮询批量处理减少每包中断开销避免活锁napi_schedule做了哪几件事置位NAPI_STATE_SCHED防重入把napi挂到当前CPU的softnet_data-poll_list触发NET_RX_SOFTIRQpoll函数返回weight表示什么表示驱动觉得队列里还有包没处理完希望内核继续调度它轮询返回小于weight表示队列已掏空可以清状态收工budget有什么用耗尽会怎样限制一次net_rx_action处理的包总量耗尽后如果poll_list仍有napi则time_squeeze计数增加等待下一轮软中断NAPI和RPS是什么关系NAPI是驱动侧收包轮询机制RPS在net_rx_action之后把skb按hash分发到其他CPU的backlogbacklog本身也是一个NAPI实例多队列网卡的NAPI如何并行每个硬件队列注册独立napi实例MSI-X中断绑定不同CPU各CPU处理自己poll_list上的napi面试里如果被问到“NAPI的缺点”也不要只答优点。NAPI在高负载下的轮询会拉高CPU占用对所有包一视同仁无法区分优先级它对低延迟场景不友好所以才有busy poll补位另外NAPI要求驱动在poll里做非常快的操作如果驱动本身有慢路径软中断会把整个CPU拖住。我个人在实际操作中的体会是NAPI的价值不只是性能提升更是一次设计思路的转变把“每个事件都打断CPU”改成“让CPU按照自己的节奏批量干活”。线上调优时请务必先理解这套机制的权衡点在哪里一次只改一个参数并且同时看吞吐、延迟、软中断占比和time_squeeze几个侧面不要被单一指标误导。最后再分享一个小技巧如果你发现某个核的poll_list长期不空且time_squeeze飙升先别急着调budget看看是不是网卡中断合并参数rx-usecs被调得太小导致中断触发频率过高也许把合并窗口稍调大一点问题就解决了。
02
RELATED NEWS

相关资讯

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

03
WHY YAOTU

想打造同款高转化官网?

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

◈

场景化定制

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

◐

营销型架构

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

▲

全周期服务

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

免费获取你的建站方案

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