尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

NAPI机制深度解析:从中断到轮询的Linux收包路径优化实践

NAPI机制深度解析:从中断到轮询的Linux收包路径优化实践 做网络驱动或者内核协议栈开发的工程师迟早都会撞上NAPINew API这个词。绝大多数资料告诉你它是中断和轮询的混合体但光知道这八个字遇到真实性能问题照样抓瞎。我第一次调网卡收包瓶颈时就是因为没吃透NAPI的调度机制和状态位语义对着/proc/net/softnet_stat里疯涨的time_squeeze毫无头绪。这篇我以Linux 6.x内核源码为主线把NAPI原理机制从头到尾拆开讲清楚包括数据结构、调度流程、驱动poll函数写法、与GRO/RPS/XDP的协作关系以及常见性能问题的排查思路。适合正在做驱动开发、或者想深入理解内核网络收包路径的读者也适合准备嵌入式内核方向面试的朋友。1. 为什么传统中断收包会被洪水冲垮NAPI要解决的头号问题1.1 一个数据包一个中断收包路径上的上下文切换税在NAPI出现之前网卡驱动收包走的是纯中断模式。网卡每收到一个报文就往CPU扔一个硬件中断信号。CPU被打断后保存寄存器现场进入中断上下文执行驱动注册的中断处理程序。中断处理程序里做这些事从RX描述符环形队列里找到新报文对应的描述符分配sk_buff把网卡DMA搬运过来的数据关联到skb上调用netif_rx()把包交给软中断路径最后清中断、返回。这套流程在几十Mbps的低速率下完全没问题问题出在扩展性上。每个报文都要经历一次完整的硬件打断CPU流水线冲刷、寄存器压栈退栈、中断控制器仲裁、cache状态失效这些开销加起来少说几百个时钟周期。当报文速率冲到百万级PPS时CPU每秒要响应百万次中断绝大多数时间花在处理中断本身真正搬运数据和运行上层协议栈的时间被严重挤占。这个现象在网络领域有个专门的说法中断风暴。有朋友会想了那干脆不用中断改成纯轮询不就行了纯轮询的毛病同样明显流量空闲时CPU空转在一个死循环里反复读网卡状态寄存器白白烧算力。服务器网络的流量特征是潮汐式的突发和静默交替出现固定轮询在这种场景下表现非常差。1.2 中断风暴与livelockCPU看似忙碌却毫无进展比中断风暴更恶心的场景是活锁。当报文到达速率超过系统实际处理能力时驱动每处理完一个报文队列里又积压了更多硬件持续触发中断驱动持续应答看起来CPU占用率拉满实际上协议栈根本没时间来消化这批报文有效吞吐直接归零。我早年间压测一块千兆网卡时就遇到过流量一打满机器SSH卡死mpstat里硬中断和软中断占比加起来接近100%但接口的收包速率反而上不去。这就是最典型的收包路径活锁。NAPI的核心设计思路就是要把两种模式的优点揉到一起有包到达时先用硬件中断通知CPU保证低延迟中断处理程序里立即关闭该队列的中断源防止同一波流量反复打断CPU然后进入软中断轮询模式成批消化硬件队列里的报文直到队列处理到接近空才重新打开中断等待下一波流量。这套思路翻译成内核里的话就是NAPI的调度机制。理解了这个动机后面所有源码细节都好办了。2. NAPI的数据结构体系napi_struct、softnet_data与状态位的三角关系读NAPI源码绕不开三个核心元素驱动注册的napi_struct实例、每CPU一份的softnet_data收包中枢、以及state字段里那个贯穿始终的NAPI_STATE_SCHED状态位。这三者的关系理清了NAPI的调度骨架就搭起来了。2.1 napi_struct驱动与协议栈之间的契约书napi_struct是驱动和内核协议栈之间的一份契约。协议栈调用驱动实现的poll回调来取报文驱动承诺遵守预算约束。关键字段如下无关的我已经删掉struct napi_struct { struct list_head poll_list; /* 挂入softnet_data-poll_list的链表节点 */ unsigned long state; /* NAPI状态位NAPI_STATE_SCHED是核心 */ int weight; /* 单次poll最多处理的报文数默认64 */ int (*poll)(struct napi_struct *, int); /* 驱动实现的轮询回调 */ struct net_device *dev; /* 所属网络设备 */ struct list_head dev_list; /* 挂在net_device上的链表节点 */ ... };weight是单个NAPI实例每次被软中断调用时允许处理的报文数量上限。这个数不是越大越好后面讲调度时会细说。poll是驱动必须实现的核心回调内核不理解驱动内部的队列细节它只认这个函数我调用你你给我一批报文。2.2 softnet_data每个CPU私有的收包枢纽softnet_data是per-CPU变量每个CPU核心都有一份独立副本。它的核心是poll_list一个把当前CPU上所有等待轮询的NAPI串起来的链表struct softnet_data { struct list_head poll_list; /* 所有待poll的NAPI实例 */ struct sk_buff_head process_queue; /* backlog实例的处理队列 */ struct sk_buff_head input_pkt_queue; /* 传统netif_rx()路径的收包队列 */ unsigned int received; unsigned int dropped; unsigned int time_squeeze; ... };这里有个非常关键的特征poll_list是per-CPU的。一个NAPI被调度后只会挂在触发它的那个CPU的poll_list上后续的软中断处理也只会发生在同一颗CPU上。这意味着NAPI天然是CPU绑定的。多队列网卡之所以能并行收包靠的就是每个队列的NAPI被不同CPU调度每个CPU各处理各的poll_list。2.3 NAPI_STATE_SCHED状态位的语义与竞争处理state字段里的NAPI_STATE_SCHED是理解NAPI的一把钥匙。它的语义有两层一是标识我已经在某个CPU的poll_list上排队了二是用于防重入。看一下调度前的检查函数static inline bool napi_schedule_prep(struct napi_struct *n) { return !napi_disable_pending(n) !test_and_set_bit(NAPI_STATE_SCHED, n-state); } static inline void napi_schedule(struct napi_struct *n) { if (napi_schedule_prep(n)) __napi_schedule(n); }test_and_set_bit是原子操作。假设驱动在中断处理程序里关了中断源但硬件寄存器可能还挂着pending的中断或者多队列网卡的两个队列共用一个NAPI实例这种情况很少但不为零这些路径都可能并发调用napi_schedule()。原子位上谁先置位成功谁入队后到者直接放弃不会出现同一个NAPI在poll_list里挂两次的情况。一旦NAPI处于调度状态SCHED置位即使后续又有报文到达驱动也不应该再触发一次完整的调度流程。报文会老老实实积压在硬件队列里等poll函数批量搬走。这个状态位的设计保证了中断关闭期间持续来包不会导致任何调度混乱。3. 一次完整的中断调度周期从硬件中断到net_rx_action数据结构是静态的骨架调度周期才是NAPI流动的血液。完整走一遍流程很多困惑会瞬间解开。3.1 中断处理程序的标准动作关中断、入队、触发软中断当网卡收到报文、触发硬件中断后CPU进入驱动的IRQ处理函数。NAPI驱动在这个函数里的标准动作是关闭当前设备/队列的中断写网卡寄存器mask或调用disable_irq调用napi_schedule()把NAPI挂到本CPU的poll_list上触发NET_RX_SOFTIRQ软中断。第2步的底层实现是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); }中断处理程序到这里就返回了剩下的搬运工作全部留在软中断阶段做。这是Linux中断处理上半部只做最少活重活交给下半部的典型体现。为什么必须在中断处理程序里抢着关中断直接想一下如果不关会怎样第一个报文来了napi_schedule成功NAPI入队第二个报文又触发中断虽然NAPI_STATE_SCHED挡住了重复入队但CPU还是被硬生生打断了两次上下文切换税照付。只有把中断源mask掉才能让CPU从被打断-应答-再被打断的循环里解脱出来安心去软中断批量收包。3.2 net_rx_action如何在同一批NAPI之间分配预算NET_RX_SOFTIRQ软中断的处理函数是net_rx_action()。它从本CPU的softnet_data里取出poll_list依次调用每个NAPI的poll。这里有两个关键参数来控制软中断的占用时间netdev_budget整轮软中断在所有NAPI上总共处理的报文预算默认300netdev_budget_usecs整轮软中断允许执行的时间预算默认2000微秒即2毫秒。核心循环简化后长这样while (!list_empty(list)) { if (budget 0 || time_after_eq(jiffies, time_limit)) { sd-time_squeeze; break; } n list_first_entry(list, struct napi_struct, poll_list); budget - napi_poll(n, repoll); ... }有个细节容易让人误解napi_poll()调用驱动poll时传入的第二个参数是n-weight不是net_rx_action里剩下的全局预算。也就是说单个NAPI每次最多消化weight个报文默认64即使全局剩余预算只剩20它也照样能处理64个。因此全局budget是一个尽力而为的软上限在边界上可能略超——这是设计上接受的精度损失单次poll的执行粒度就是weight。时间预算也很有意思。软中断执行时间一旦超过2毫秒不管报文预算剩多少net_rx_action都会提前退出把还没处理完的NAPI重新放回poll_list然后再次触发NET_RX_SOFTIRQ等待下一个软中断窗口继续干活。这样做的根本目的是防止网络软中断霸占CPU太久把进程调度饿死。软中断优先级虽高但也不能无法无天。3.3 poll返回值的语义等于weight意味着什么驱动poll函数的返回值是整个NAPI调度机制中语义最重的一个信息返回值小于weight表示驱动认为这轮我把队列处理得差不多了。驱动内部通常调用napi_complete_done()清除NAPI_STATE_SCHED把NAPI从poll_list摘除并重新打开硬件中断。返回值等于weight表示驱动撞到了单次预算上限队列里大概率还有积压。此时napi_poll()会把NAPI放回待处理链表等待下一轮软中断继续调度。NAPI_STATE_SCHED保持置位驱动不允许打开中断。这种宁可多轮询几轮也不提前开中断的策略保证了高负载下中断关闭时间足够长把收包开销稳定地摊销到大批量轮询里。低负载时队列很快读空poll返回小于weight中断随即打开继续享受硬件中断的低延迟通知。weight既是预算也是是否还有活干的信号理解了这一点NAPI的调度节律就抓住了。4. 驱动侧poll函数实现范式从零写一个NAPI驱动的核心理论铺垫完了切换到驱动开发视角。很多工程师的NAPI启蒙是从e1000、igb这些老驱动里抄出来的这里给一个结构完全对得上的精简骨架。4.1 驱动初始化netif_napi_add和napi_enable的搭配驱动在probe阶段要注册NAPI实例典型代码netif_napi_add(netdev, priv-napi, my_poll, 64); napi_enable(priv-napi);netif_napi_add()把poll回调、weight填进napi_struct并把实例挂到net_device的napi_list上。napi_enable()清除NAPI_STATE_DISABLE等关闭标志使实例变为可用。顺序上有个经验之谈先注册并enable NAPI再申请中断。否则中断一上来驱动可能还没准备好NAPI实例中断处理程序里去调度一个未初始化的实例后果不堪设想。驱动open流程里DMA环形队列、NAPI、中断申请这三者的初始化顺序必须严格队列先行NAPI跟上最后才开中断。weight的选取也别无脑用64。weight越大单次poll处理包越多但poll执行时间会变长挤压软中断周期内其他NAPI的时间。对于多队列网卡每个队列一个NAPI所有NAPI的weight累加起来很快就冲破全局netdev_budget。后面调优部分会具体说。4.2 收包主循环中的budget与gro处理poll函数的核心是一个循环从硬件RX环形队列取描述符、读报文、组skb、送协议栈。骨架如下static 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_read(priv); if (!skb) break; napi_gro_receive(napi, skb); work_done; } if (work_done budget) { napi_complete_done(napi, work_done); my_rx_irq_enable(priv); } return work_done; }有三个容易踩的坑逐个说。第一循环必须以budget为硬边界。如果这一轮已经把weight跑满work_done等于budget说明队列可能还有积压必须原样返回等软中断下一轮调度。贪心多读几个包会直接破坏net_rx_action的资源公平分配让其他NAPI饿着。第二收包接口选用napi_gro_receive()而不是netif_rx()。netif_rx()是传统中断路径的接口它把skb塞进per-CPU的input_pkt_queue再由内置的process_backlog实例去消化。如果在poll上下文用netif_rx()等于把一个本该批量递送的报文绕回了排队路径白白增加一次队列操作和调度延迟。正确做法是走napi_gro_receive()让报文有资格参与GRO合并减少上层协议栈的处理次数。第三队列读空后必须先napi_complete_done()再打开中断。反过来操作会引入一个竞态窗口中断先来IRQ处理程序调napi_schedule()发现NAPI_STATE_SCHED还没被清掉napi_schedule_prep()直接放弃入队。这一包没有被调度要等下一个报文到达才唤醒白白损失一个包的延迟严重时可能连续丢包。4.3 收尾工作napi_complete_done的正确打开方式napi_complete_done()相比早期的napi_complete()额外做了几件事更新gro_count统计并在有未完成GRO分片时做一次gro_flush。普通驱动直接调用即可不需要额外干预。驱动在调用napi_complete_done()之后还要干活主动操作网卡寄存器打开中断。这一步芯片差异很大有些网卡在读空最后一个描述符时自动解除mask有些必须软件显式写doorbell。写驱动时一定要认真核对芯片手册最常见的bug就是忘了开中断收包静默卡死。我调试某款网卡时甚至遇到过中断mask寄存器在NAPI路径里要写两次才真正生效第一次写芯片正忙直接忽略。后来在收包路径末尾追加一次读回操作强制寄存器flush问题才消失。这种坑看代码完全看不出来必须结合硬件行为排查。5. NAPI如何把skb送进协议栈与GRO、RPS等特性的协作NAPI不是孤立存在的它和上层协议栈之间还有几条重要的协作路径。这些路径直接影响报文从网卡到用户态socket的效率和路径长度。5.1 netif_receive_skb与napi_gro_receive的使用边界在NAPI poll路径里驱动有两个递交流程netif_receive_skb(skb)直接送协议栈接收路径不做GRO合并napi_gro_receive(napi, skb)先尝试与同流程的报文做GRO合并合并失败或不适合合并时才进普通收包路径。现代驱动基本都走napi_gro_receive。GRO的核心收益是降低报文数量同一连接的一批小包在驱动侧合并成一个大包再送上协议栈协议栈的处理次数大幅下降。napi_struct里的gro_count、gro_bitmask就是服务于这个机制的。但要清楚GRO的前提合并只发生在同一个NAPI实例内部。多队列网卡上一条TCP流如果被RSS分散到多个RX队列就有了多个NAPI实例内核无法跨实例合并GRO效果会打折扣。现代网卡靠硬件GRO和更聪明的RSS哈希来缓解但这个限制始终存在。5.2 RPS/RFS在多CPU场景下的补位RPSReceive Packet Steering解决的问题是协议栈处理集中在单核。它发生在报文进入协议栈的入口处通过报文hash把报文分发到目标CPU的process_queue然后触发目标CPU的NET_RX_SOFTIRQ由那边CPU上的process_backlog实例来处理。这引出一个重要结论对于没有RSS的网卡即使NAPI已经把报文从硬件搬出来了协议栈层的处理仍可能集中在NAPI所在的那颗CPU上。RPS就是在NAPI之后的层次做负载均衡。RFS更进一步根据socket所在CPU来引导报文让报文尽量在处理它的CPU上被用户态读取提高cache命中率。从NAPI视角看RPS只是报文从poll到协议栈途中的一个分叉动作。但调优时RPS的配置要和NAPI绑定的CPU set一起规划。否则可能出现NAPI在CPU0收包、RPS把报文转发到CPU3处理、结果还要把结果回传给CPU0的场景cache miss来回跑性能反而更差。5.3 多队列网卡的RSS与NAPI实例的映射关系现代网卡基本都支持多队列。每个RX队列有独立的中断通常是MSI-X和独立的描述符环形队列。标准做法是每个RX队列注册一个独立NAPI实例都挂到同一个net_device上。这样不同队列的NAPI可以被调度到不同CPU实现真正的并行收包。多队列驱动在中断处理程序里不需要关闭整个设备的中断每个队列独立mask就行队列之间互不影响。这也是多队列NAPI比单队列更干净的地方。/proc/irq/num/smp_affinity里每个队列中断号的绑定/sys/class/net/eth0/queues/rx-0/rps_cpus这类sysfs属性背后都是这个模型。配置多队列时最常见的低级错误是把所有队列的中断affinity都绑到同一个CPU上。结果每个CPU的poll_list上串了好几个NAPI软中断全部堆在一个核多队列的并行优势完全丧失。正确做法是把各队列中断分散到多个物理核并尽量避免与业务进程抢占同一个核的计算资源。6. NAPI的扩展形态Busy Poll、XDP与虚拟化收包路径NAPI并不是静态的从2.6内核到今天围绕它长出了多个扩展机制。这些内容偏进阶对做高性能网络或者排查极端延迟问题的人很有用。6.1 Busy Polling把NAPI延迟压到用户态的取舍NAPI轮询发生在软中断上下文报文从网卡到用户态进程之间至少跨过一次上下文切换。对超低延迟场景高频交易、行情分发这个切换开销可能就决定了能不能成交。Busy Polling的思路是用户态进程在等数据时不睡觉直接把CPU卷进NAPI轮询在报文到达的第一时间从硬件队列把它取走跳过整个软中断排队过程。具体机制是socket开启SO_BUSY_POLL选项后用户态调用poll/select/epoll等待读事件时内核会尝试对关联的NAPI执行busy poll。空闲等待期间CPU不进入睡眠而是主动调NAPI的poll函数去硬件队列捞包。代价非常直接CPU占用率飙升一个线程占死一个核。所以Busy Poll只适合延迟敏感、连接数少、CPU有余量的场景。连接数多了之后每个连接都去busy pollCPU线瞬间耗尽。正确玩法是专用网卡、专用NUMA节点绑定一台机器只跑少量高频连接才值得开。系统层还有net.core.busy_poll和net.core.busy_read两个sysctl可以打开全局限busy poll。但要清醒全局开启意味着所有socket都可能陷入轮询普通业务场景不要碰。6.2 XDP在NAPI轮询中的位置驱动态而非协议栈XDPeXpress Data Path挂在NAPI poll的收包路径里在skb被分配、协议栈介入之前让BPF程序直接操作DMA缓冲区的原始数据。XDP的高效率正来源于此省掉了skb分配、GRO、协议栈协议解析等一系列开销。驱动侧支持XDP时poll的收包循环里会多一个分支判断如果BPF程序裁决这个报文直接DROP或者走XDP_TX/redirect就不需要组装skb和调用协议栈接口只有需要继续送协议栈的报文才走正常路径。所以支持XDP的驱动poll函数看起来经常像两套逻辑一套普通收包一套XDP快路径。NAPI不仅不阻碍XDP反而是它的天然底座报文在轮询循环里成批读取放弃中断的批量处理模式跟XDP的高性能诉求完全一致。6.3 虚拟化场景下NAPI的适用性虚拟机场景同样能看到NAPI的影子。virtio-net前端驱动实现了NAPI后端vhost进程把报文写入共享vring后触发虚拟中断前端驱动照旧通过NAPI批量取包并送入协议栈。所以物理机上的NAPI调优经验大部分可以平移到虚拟网络。不过有个区别虚拟中断的开销远小于物理中断中断风暴问题不像物理网卡那么突出。虚拟化环境下更常见的瓶颈是vhost线程的调度延迟和共享内存cache miss。调优思路要跟着瓶颈走不能因为物理机上NAPI优化有效就照搬全套方案。7. 观察与调优怎么判断你的NAPI工作得好不好理论讲再多最终还得回到怎么判断系统行为是否健康。NAPI虽然藏在内核里但留了不少观测点。7.1 softnet_stat和netstat中的NAPI健康指标最直接的观测文件是/proc/net/softnet_stat每一行对应一个CPU。不同内核版本列数略有差异但前几列含义基本稳定列名称含义1received该CPU通过NAPI/backlog处理的累计报文数2dropped因队列满等原因丢弃的报文数3time_squeeze因budget或时间限制提前退出net_rx_action的次数4cpu_collision与跨CPU锁竞争相关较少见5received_rx新版本内核追加的统计6fast_rx新版本内核追加的统计time_squeeze是核心健康指标。它的值持续快速增长说明软中断经常意犹未尽就被预算或用满2毫秒时限打断。可能的原因有两类一是流量本身超出CPU处理能力需要升级硬件或多队列分散二是一次软中断里NAPI实例太多互相抢预算这时要把中断affinity分散开。反过来如果received列增速很快而time_squeeze基本不涨NAPI工作状态就是健康的。局域网侧的观测还可以配合ethtool -S eth0 | grep -i rx看驱动层的丢弃和命中计数。如果出现rx_missed、rx_fifo_errors持续增长往往和中断关闭时间过长、DMA环形队列溢出有关需要调整队列深度或poll节奏。7.2 常见问题time_squeeze过高、CPU不均衡、延迟抖动如果看到单核软中断占比极高、其他核很闲优先查两处网卡是否只启用了单队列RSS没生效多队列情况下中断是否全落在一个核上smp_affinity配置问题。这两种情况都会让大量NAPI实例挤在同一颗CPU的poll_list上。如果遇到延迟抖动大、间歇性几十毫秒尖峰重点看NAPI是否把软中断时间拉得过长挤压了进程调度。可选对策是降低weight比如从64调到32让每次poll更快返回给调度器更多喘息或者反过来调大netdev_budget让一次软中断处理更大批量降低软中断触发频率代价是单次CPU占用时间更长。调参务必一次只动一个然后用perf、bpftrace、mpstat组合观测。bpftrace可以挂到net_rx_action或者驱动poll函数上统计每次poll的执行时长与返回值分布。有这样的数据支撑调优才不是拍脑袋。还有一个容易被忽略的点NAPI软中断的优先级和中断绑核策略。如果NAPI处理不及时报文积压在硬件队列的时间会直接变成网络延迟。建议用irqbalance做动态均衡或者手动把网卡中断和业务进程的CPU亲和性做一个整体规划别只盯着某一环。关于NAPI的机制与调优我的体会是不要只停留在中断加轮询这个层面真正吃透weight、NAPI_STATE_SCHED状态位、poll返回值这三者的语义再摸清net_rx_action的调度节奏后面读任何网卡驱动的代码、排查收包性能问题都会豁然开朗。
返回列表