ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux内核收包全链路解析:从DMA、NAPI到协议栈

Linux内核收包全链路解析:从DMA、NAPI到协议栈 1. 一次收包的全链路拆解从网卡到进程做网络开发、嵌入式Linux这块的早晚都得跟内核收报文打交道。不管你是写驱动、调协议栈还是纯粹想弄清楚一个ping包进来CPU到底干了哪些活这套链路都是绕不开的底子。我最早开始啃这块是被一个线上问题逼的网卡流量明明没跑满但业务侧就是疯狂报延迟抖动最后追到内核软中断占比居高不下才发现是收包路径上NAPI轮询和协议栈处理互相打架。从那以后我就意识到不懂内核怎么把报文收上来排查网络疑难杂症基本就是靠猜。这篇文章我从数据流的角度把Linux内核收报文的完整过程从头到尾捋一遍网卡DMA怎么把数据搬进内存、硬中断怎么触发、NAPI机制为什么非得轮询中断混着来、软中断里协议栈怎么层层剥离头部、最后数据怎么塞进socket接收队列等进程来取。中间会穿插一些我实际调优和踩坑的记录包括怎么看/proc/net/softnet_stat、怎么调网卡队列和中断亲和性、RPS/RFS在这些环节里扮演什么角色。适合谁看做嵌入式Linux驱动开发的、搞网络性能调优的、面试前临时抱佛脚的还有那些用tcpdump抓包但一直想搞明白包到底是从哪条路进到应用层的同学。我尽量不堆晦涩源码用数据从网线进来到进程读到这条主线串起来每个环节讲清做了什么、为什么这么做、出了问题怎么查。2. 收包路径上的核心部件不只是网卡把包发上来这么简单2.1 网卡DMA数据搬进内存的第一步很多人以为收包是网卡收到数据后CPU主动去网卡里把数据读出来。真实情况恰恰相反收包的主力搬运工是DMA引擎CPU在大部分时间里根本不参与数据拷贝。网卡驱动在初始化的时候会向内核申请一块或者多块内存这些内存被组织成环形描述符队列Ring Buffer网卡和驱动通过这块环形队列协作。队列里的每个描述符Descriptor其实就是一个指向数据缓冲区的指针加一些元数据。网卡收到报文后直接把报文内容通过DMA写进这些预先分配好的缓冲区写完后在描述符里更新状态然后触发中断告诉CPU数据已经到位了。这里面有个关键设计思路预先分配、零拷贝接收。驱动在收包之前就把内存准备好网卡只管往里面写避免了收包过程中动态分配内存的开销和不确定性。这也是为什么ethtool -g eth0能看到rx/tx ring parameters的原因——这些就是我们通过驱动配置的环形队列深度。环形队列的深度设置非常有讲究。调大了能扛住瞬时突发流量但每个包在队列里排队时间长延迟会上去而且内存占用也高调小了延迟低、内存省但流量一旦短时间爆发描述符耗尽丢包就来了。我自己的习惯是延迟敏感型服务把队列调浅一些比如256或者512吞吐型业务调到1024甚至2048具体数值还是要靠压测去试。2.2 硬中断与下半部机制为什么不能在中断里干重活DMA把数据写进内存之后网卡会通过PCIe总线向CPU发起中断请求。这时候CPU会立刻跳转到驱动注册的中断处理函数ISR。但注意ISR里绝对不能干耗时的事情比如协议栈解析、内存拷贝统统不行。原因很朴素中断上下文是原子的它会把当前正在执行的进程打断而且同一条中断线上的其他中断都会被屏蔽如果ISR执行太久系统等于周期性卡死一会儿。所以内核采用了一个经典设计——中断下半部Bottom Half机制。上半部就是ISR本身它只做最快的工作把网卡的中断屏蔽掉把对应的软中断一般是NET_RX_SOFTIRQ标记为待处理然后立刻返回。真正繁重的收包处理交给软中断在更宽松的上下文里去执行。这里要理解一个点硬中断和软中断是配合关系不是替代关系。硬中断负责叫醒内核软中断负责干活。不过现在的驱动基本都不采用每来一个包就硬中断一次的纯中断模式了因为高PPS场景下中断风暴会让CPU直接被打满这就引出了NAPI。2.3 从中断到轮询NAPI机制解决的是CPU空转问题NAPINew API是Linux收包机制里最具里程碑意义的设计之一。它的核心思想是中断只用来唤醒唤醒之后转为轮询收包。具体过程是这样的第一个包到达时网卡触发硬中断ISR里把网卡的中断关掉然后唤起NET_RX_SOFTIRQ软中断。软中断处理函数进入网卡驱动的poll回调这个回调会一口气把环形队列里攒下来的包全部收完或者收满预算budget为止。等队列清空了再重新打开网卡中断等待下一个包的到来。这样做的最大好处是高流量场景下中断次数被压缩到极低CPU大部分时间在轮询收包吞吐能力大大提升低流量场景下网卡还是靠中断唤醒不会让CPU空转。用大白话说NAPI就是门铃响一下我就去把邮箱里的信全取出来门铃一直响我就干脆站在邮箱旁边一件件拿拿完再歇。这里有个值得注意的参数budget通常默认是300表示一次软中断处理最多收多少个包。还有每个网卡队列的weight通常是64。为什么这么设其实就是要防止某个网卡队列饿死其他软中断——如果处理得太久其他网络设备、定时器、RCU这些软中断都会等待。我们之前遇到过一个场景单队列网卡被UDP小包打爆si软中断占用100%就是收包处理太长挤占了其他任务。3. 深入NAPI收包循环poll函数里到底发生了什么3.1 设备驱动侧的收包动作要认真理解NAPI的收包过程最简单的方式是直接看驱动里的poll实现。不同网卡驱动实现略有差异但核心流程惊人地一致驱动检查当前环形队列的消费指针next_to_clean和生产指针next_to_use确认有多少个描述符已经被网卡写入了数据。从环形队列里逐个取出这些描述符拿到对应的数据缓冲区构建struct sk_buffsocket buffer。调用napi_gro_receive或者netif_receive_skb把skb交给协议栈。这里特别值得展开的是sk_buff的构建。skb是整个Linux网络协议栈最核心的数据结构它不直接拷贝数据本身而是通过指针、偏移量来管理数据。比如head指向缓冲区起始地址data指向网络协议头部的当前位置tail指向数据末尾end指向缓冲区末尾。协议栈每剥一层头做的基本就是skb_pull把data指针往后挪这样上层的代码就能拿到自己关注的那个协议头。这种设计避免了数据在每一层之间的多次拷贝是内核收包高性能的关键之一。实际写驱动的时候构建skb有几种方式build_skb把网卡DMA缓冲区直接零拷贝构造成skb这种方式效率最高但要求缓冲区是驱动自己分配的。napi_alloc_skb/netdev_alloc_skb新分配一个skb并把网卡缓冲区里的数据拷贝过去适用于某些无法直接利用原缓冲区的场景代价是多了这次拷贝。3.2 GRO合并小包场景的救命稻草在napi_gro_receive这个函数里内核会尝试做**GROGeneric Receive Offload**处理。它的作用是把同一连接、特征相同的多个小包合并成一个大的skb交给协议栈。为什么要做这件事因为协议栈处理每个包都有固定开销比如遍历路由表、查找socket、更新统计等。如果把10个1500字节的包合并成1个对协议栈来说处理的包数就少了一个数量级CPU开销大幅下降。GRO的判断逻辑其实不复杂新到的包和正在合并的包要求协议相同、五元组源IP、目的IP、源端口、目的端口、协议相同、TCP标志位方向一致等。对于UDP内核从某个版本开始也支持了UDP的GRO但需要应用配合开启。这里有个很常见的困惑GRO、LRO、RRO有什么区别特性GROLRORRO实现位置内核软件层通用网卡硬件内核软件层合并粒度按流精准合并支持TCP/UDP按流合并主要TCP只合并同一流的相邻包风险低可能破坏TCP时间戳语义低适用场景通用高性能网卡嵌入式/简单场景因为GRO是在软件层做的所以它对CPU收益非常明显。我在测试机上验证过纯UDP小包64字节接收开GRO比关GRO吞吐能提升30%以上CPU占用明显下降。如果你的网卡不支持硬件LRO软件GRO基本是必开项。3.3 RPS/RFS把软中断处理分摊到多个CPU默认情况下网卡一个队列的中断/软中断只绑定在一个CPU上通过中断亲和性配置。单队列网卡在高PPS下单核CPU的软中断使用率很容易逼近100%其他核闲着帮不上忙。RPSReceive Packet Steering就是用来解决这个问题的它把收到的包根据哈希值分散到多个CPU的软中断队列里处理。具体原理是网卡驱动收包后对包的四元组做哈希然后按哈希值把skb放到目标CPU的backlog队列同时唤醒目标CPU的软中断。这样收包处理就从单核变成多核并行。RFS进一步做了优化会把同一个流的包尽量送到正在处理该流socket的CPU上提升CPU缓存命中率。不过要提醒一句RPS不是银弹。做了一次CPU间的转发相当于多了跨核调度开销和锁竞争。如果本身是多队列网卡优先还是让网卡自己的RSSReceive Side Scaling来做到队列分散这比RPS的软件分发效率高。RPS更适合老网卡、单队列网卡或者网卡队列数少于CPU核数的场景。4. 从驱动到协议栈skb的分发之路4.1 协议栈入口netif_receive_skb与__netif_receive_skb_core当NAPI的poll函数把skb从驱动侧送出来接下来就会进入netif_receive_skb。这个函数是驱动和协议栈的边界也是很多人看源码时容易迷路的地方。netif_receive_skb本身做的事情不多主要检查一下skb的时间戳、处理一下Generic XDP钩子然后就进入核心函数__netif_receive_skb_core。这个函数做三件事遍历ptype_all链表把一份数据副本送给所有注册的抓包器比如tcpdump、AF_PACKET套接字。这就是为什么你在任何协议处理之前都能抓到原始帧。遍历ptype_base哈希表根据skb-protocol找到对应的网络层处理函数。比如以太网帧的protocol字段是ETH_P_IP对应的就是ip_rcv。调用对应协议的处理函数把skb向上送。从这里开始收包进入了网络层。值得强调的一点是tcpdump这类工具抓到的包位置就在这个环节——它拿到的是链路层的原始帧还没有经过网络层和传输层的解析所以你能看到完整的以太网头、IP头、TCP头甚至校验和字段。4.2 IP层处理转发还是本地交付ip_rcv是网络层的入口。它要做的事情包括检查IP头合法性版本、长度、校验和处理IP选项查找路由决定这个包是本地交付input还是转发forward。如果包的目标IP是本机地址IP层会调用ip_local_deliver根据协议号把包分发给TCPtcp_v4_rcv或UDPudp_rcv。如果是转发包则进入ip_forward经过路由再次发出。本地交付前还有个重要逻辑IP分片重组。如果上层协议是UDP且包被分片了ip_local_deliver会把这些分片缓存起来等全部分片到达后重组再提交给UDP层。分片重组是一个标志性的性能瓶颈点——分片包多了内核的ip_frag缓存会膨胀内存和CPU都吃紧。所以生产环境我一直建议把MTU调对尽量避免IP分片。另外还有个netfilter的钩子在这里起作用。我们常用的iptables、nftables就是在ip_rcv之后、ip_local_deliver之前通过钩子介入的。理解了这个位置就明白为什么有些防火墙规则会显著增加收包延迟——每个包都要在钩子链上做规则匹配规则多了自然慢。4.3 传输层处理查socket、入队列进入TCP或UDP传输层后核心任务是找到这个包对应的socket然后把数据挂到socket的接收队列里。TCP的tcp_v4_rcv要比UDP复杂得多。它需要根据四元组查找struct sock这个过程是通过__inet_lookup_skb完成的核心数据结构是ehash哈希表。查找到socket后还要处理TCP状态机如果是处于ESTABLISHED状态连接的包走tcp_rcv_established如果涉及新连接SYN包走tcp_rcv_state_process。TCP收包还有很多细节值得说乱序包要插入ofo_queue等待排序、重复包要直接丢弃、接收窗口要更新、还有一系列拥塞控制相关的反馈。为了性能进入tcp_rcv_established后还有个快速路径Fast Path概念如果包正好是下一个期望的序号且没有特殊标志位可以直接跳过一系列检查快速把数据拷贝到用户缓冲区。UDP的接收相对简单根据四元组找到socket把skb挂到socket的接收队列sk_receive_queue唤醒在recvfrom上阻塞的进程。UDP没有拥塞控制、没有重传、没有乱序处理所以内核里有句玩笑话UDP收包是最接近转发的协议处理。不过UDP接收有一个隐藏的大坑接收队列溢出。如果应用读取不够快socket接收队列会被填满之后到达的UDP包会被直接丢弃而udp_rmem_min这类参数决定了队列的一个初始水位。排查UDP丢包时除了看网卡层的rx_missed还一定要看netstat -su里的receive buffer errors。5. 收包性能观测与排查从工具到内核指标5.1 用ethtool看网卡层丢包排查收包问题我一般从网卡层开始一层层往下追。最先用的就是ethtoolethtool -S eth0重点关注几个计数器rx_packets/rx_bytes正常收包计数rx_dropped驱动层主动丢弃的包通常是环形队列满导致rx_missed/rx_no_buffer网卡硬件层面或者驱动没来得及处理的包rx_errors物理层或MAC层错误。如果rx_dropped和rx_missed明显增长先检查环形队列是否够大ethtool -g eth0如果当前值已经是最大值且还在丢说明单纯加大队列解决不了要换思路比如增加网卡队列数多队列网卡、开RPS或者优化应用读取速度。5.2 软中断和核间调度看si和软中断统计top里看到si软中断占用高不要慌先确认是哪类软中断在消耗CPUcat /proc/softirqsNET_RX对应的值如果特别高说明是收包触发的软中断。再看这些软中断是集中在少数CPU上还是均匀分布mpstat -P ALL 1如果是集中在某个核上说明中断亲和性没配好或者RPS没开。多队列网卡的话用set_irq_affinity把不同队列的中断绑定到不同CPU上# 找到网卡对应的中断号 cat /proc/interrupts | grep eth0 # 修改中断亲和性绑定到CPU0-3 echo 0f /proc/irq/irq_number/smp_affinity这里0f是位图二进制1111表示CPU0到CPU3。注意smp_affinity不允许全为0实际使用建议把每个队列绑在不同核上并避免绑到CPU0太狠因为它还要处理各种系统任务。5.3 /proc/net/softnet_stat软件层的丢包信号softnet_stat可能是排查收包问题最有用的一个文件但很多同学不知道怎么看。它的每个字段在较新的内核里有多个值但按列来说最需要关注的是前几列cat /proc/net/softnet_stat每一行代表一个CPU。第一列是processed表示该CPU累计处理的包数量。第二列是dropped因为各种原因在软件层丢弃的包数量这里是重点如果第二列持续增长说明发生了软件丢包。第三列是time_squeeze表示NAPI收包时预算budget用完了但队列还没清空被迫退出这种情况通常说明流量太猛或者预算不够。如果time_squeeze增长很快可以适当调高budget和网卡队列的weight# 查看和调整budget参数 sysctl net.core.netdev_budget sysctl -w net.core.netdev_budget600netdev_budget_usecs也能调它限制了每次软中断处理收包的最大时间默认2000微秒。调高这两种参数能让每次软中断处理更多包但如果调得太大其他软中断的延迟会上升需要权衡。5.4 用perf抓热点准确定位CPU花在哪有时候单纯看计数器不够还得知道CPU时间消耗在哪个函数上。我常用的手段是perfperf top -C 3只看CPU3软中断集中的核上的热点函数。如果热点集中在napi_poll、process_backlog这类收包函数上说明收包路径本身压力大如果热点在tcp_v4_rcv或者一些锁上那问题可能在协议栈处理要从socket参数、应用读取模式等方面下手。有一次我用perf发现热点在inet_lookup相关函数上说明socket查找代价高。后来通过开启reuseport并让多个进程绑定同一端口有效分流了查找压力延迟明显下降。6. 我调过的一次真实收包问题从丢包到最终定位6.1 现象与初步排查之前维护过一台网关设备流量模型是大量UDP小包汇聚某天业务反馈说丢包率突然从万分之一冲到5%。我第一时间登录设备看网卡统计ethtool -S eth0 | grep rx结果显示rx_missed上涨明显这基本定位是硬件/驱动层丢包。考虑到流量是突发型我先把环形队列从512加大到2048但问题并没有解决rx_missed依旧在涨。于是我看软中断分布mpstat -P ALL 1发现CPU2的软中断占用接近100%而其他核心大多空闲很明显是单队列网卡导致收包全压在CPU2上了。6.2 解决路径RPS分担软中断负载设备网卡不支持多队列最直接的方案就是开RPS。我在/sys/class/net/eth0/queues/rx-0/rps_cpus里写入所有CPU的位图比如4核的机器写入f同时在rps_flow_cnt里配置流表大小echo f /sys/class/net/eth0/queues/rx-0/rps_cpus echo 4096 /sys/class/net/eth0/queues/rx-0/rps_flow_cnt改动之后再观察mpstat软中断负载从原来集中在一个核变为均匀分布到4个核rx_missed不再上涨业务侧反馈丢包率回到正常。这个案例的教训很深刻当网卡不支持RSS但CPU有多余核心时RPS是性价比极高的优化手段。而且RPS配置里rps_flow_cnt一定要设否则RPS只能做负载均衡没有RFS的CPU亲和性优化缓存命中率会低不少。6.3 后续跟进抓包确认、协议栈参数微调问题解决后我又用tcpdump抓了几个包确认接收正常同时把net.core.rmem_max和net.core.rmem_default适量调大给UDP socket接收队列多点余量避免应用偶发调度不及时导致的用户态丢包。这次经历让我养成了一个习惯任何收包调优前先确认网卡能力、队列数、CPU拓扑再决定从驱动层、RPS还是应用层入手别一上来就乱调参数。7. 收包调优的几条铁律与小技巧7.1 调优顺序从硬件到协议栈层层递进根据项目经验我建议的排查和优化顺序是确认网卡本身的多队列能力能开RSS就优先开RSS合理配置中断亲和性让每个队列中断绑定到不同CPU队列深度根据流量模型调整延迟敏感调浅吞吐追求调深软件层考虑GRO和RPS/RFS特别是单队列网卡检查socket参数和协议栈参数比如rmem_max、netdev_budget最后才考虑改代码——比如用DPDK、AF_XDP这类用户态收包方案。为什么这个顺序有意义因为越底层的优化收益越大且副作用越小。RSS是网卡硬件负载均衡几乎不消耗CPU而RPS是软件模拟有一定开销改socket参数则是治标。7.2 tcpdump抓不到包先看是不是驱动层就丢了很多同学抓包排查问题发现tcpdump抓不到某些包上来就怀疑是不是网卡没收到。其实tcpdump是通过AF_PACKET套接字挂到ptype_all链表上的它能看到的是已经进入协议栈入口的包。如果包在驱动DMA阶段就丢了环形队列满、rx_missedtcpdump是不可能看到的。所以抓包前先看ethtool -S的计数区分是网卡没收到还是协议栈丢了能少走很多弯路。我在一次DPDK项目中就因为这个踩过坑数据面用DPDK接管网卡但控制面还想用tcpdump看一眼报文结果发现什么都抓不到。原因就是DPDK在驱动层直接把队列接管了包根本没经过内核协议栈ptype_all钩子自然看不见。这种情况下要在DPDK侧自己加抓包逻辑或者用网卡硬件的端口镜像。7.3 别忽视CPU亲和性与NUMA收包性能和CPU拓扑强相关。一个常见的性能杀手是网卡插在NUMA节点0的PCIe插槽上但软中断却被调度到NUMA节点1的CPU上处理。这样skb对应的DMA缓冲区在节点0内存而CPU访问跨NUMA节点的内存延迟和带宽都会受很大影响。排查方法很简单# 查看网卡所在NUMA节点 cat /sys/class/net/eth0/device/numa_node # 查看CPU所属NUMA节点 lscpu | grep -A3 NUMA node理想状态下网卡中断、软中断处理、应用进程都尽量在同一个NUMA节点内。7.4 别忘了net.core.netdev_max_backlog这个参数控制的是当NAPI由触发转向process_backlog时backlog队列的最大长度。在默认情况下如果协议栈处理不过来netdev_max_backlog满了就会丢包。实测中流量峰值时这个值很容易被顶满尤其是单队列网卡配合RPS时。我一般在流量大的设备上把它从默认的1000调大sysctl -w net.core.netdev_max_backlog8192调大后注意观察softnet_stat第二列dropped是否还上涨如果不再涨说明丢包确实和backlog溢出有关。7.5 从白盒角度理解收包才能真正用好黑盒工具最后说一点体会。市面上很多网络监控工具都在应用层统计丢包、时延但如果你对内核收包链路没概念看到数字也不明白问题出在哪。反过来理解了网卡DMA、NAPI、软中断、协议栈分发、socket队列这条链路再看监控数据心里基本有底。性能问题本质上是个定位问题链路在哪一段卡住优化就在哪一段发力。这套方法论不仅适用于传统Linux服务器嵌入式设备、虚拟化环境virtio-net收包路径类似、容器网络veth、overlay里都会遇到同样的思路只是入口网卡和路径略有不同。以后再遇到网卡收包慢的灵异问题先别急着重启从链路底层一层层剥开多半能找到真正的原因。
返回列表