如果你维护过一台流量峰值很高的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_data
2.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_SOFTIRQ;CPU回到软中断上下文,执行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.budget | 300 | 每个CPU一次net_rx_action | 整个轮询循环最多处理的包总数 |
| net.core.weight | 64 | 每个napi实例 | 驱动poll一次最多处理的包数,也是注册时传给netif_napi_add的值 |
| net.core.budget_usecs | 2000 | 每个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调到600,budget_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_SOFTIRQ |
| poll函数返回weight表示什么 | 表示驱动觉得队列里还有包没处理完,希望内核继续调度它轮询;返回小于weight表示队列已掏空,可以清状态收工 |
| budget有什么用,耗尽会怎样 | 限制一次net_rx_action处理的包总量,耗尽后如果poll_list仍有napi,则time_squeeze计数增加,等待下一轮软中断 |
| NAPI和RPS是什么关系 | NAPI是驱动侧收包轮询机制,RPS在net_rx_action之后把skb按hash分发到其他CPU的backlog,backlog本身也是一个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被调得太小,导致中断触发频率过高,也许把合并窗口稍调大一点,问题就解决了。