
1. 从网线到 socket一次收包的内核之旅做 Linux 网络开发的人迟早都要面对“内核到底是怎么把报文收上来的”这个问题。我自己刚接触这块时打开内核源码看到net目录下几十万个文件完全不知道从哪里看起。后来花了不少时间梳理主线才慢慢摸清楚这条路其实非常清晰网卡硬件 → 驱动 → 内核协议栈 → socket 接收队列 → 应用层就这么一条流水线。这篇文章我打算把这整条链路掰开揉碎讲一遍重点放在数据面data path的每个关键节点NAPI 机制怎么工作、sk_buff如何穿过 IP 层和 TCP 层、软中断在中间扮演什么角色、多队列网卡又是怎么把负载分散到多个 CPU 上的。更重要的是我会把那些不翻源码根本发现不了的坑、调优时真正起作用的参数一起讲清楚。无论你是刚接触嵌入式 Linux 的初学者还是做网关、做高性能网络服务的开发者只要你的程序依赖网络收包这篇文章就能帮你建立一套完整的 “收包心智模型”。有了这套模型以后你再遇到“网卡收包有丢包”“某个 CPU 软中断 100%”“为什么业务进程收不到数据”这类问题至少知道该往哪个方向查。2. 收包整体架构理解整条流水线2.1 为什么收包是一条“流水线”而不是一个函数很多人学内核网络一上来就追具体函数比如netif_receive_skb或者tcp_v4_rcv追着追着就迷路了。我的建议是先看宏观的数据流把整条路径画出来再往细节里钻。先看一条从物理网卡到用户进程的完整路径网卡硬件收到数据包 → DMA 将数据写入内存中的环形缓冲区Ring Buffer → 硬件触发中断通知 CPU“有数据到了” → 驱动注册的中断处理函数ISR运行 → 驱动在 ISR 中调度 NAPI禁止该网卡继续产生中断 → 软中断 ksoftirqd或当前 CPU 的软中断上下文执行 NAPI poll → 驱动从 Ring Buffer 中取包构建 sk_buff → netif_receive_skb() 进入协议栈 → IP 层处理netfilter 钩子、IP 分片重组、路由查找 → 传输层处理TCP/UDP 解复用找到对应的 socket → 数据放入 socket 接收队列 → 唤醒等待在 recvmsg() / read() 上的用户进程 → 用户态通过系统调用取走数据这条链路本质上是一条流水线每个环节都在处理前一个环节的产物。把这个模型装进脑子里再去看代码你会发现内核网络子系统的组织方式其实就是照着这条流水线分割的驱动层、中断层、NAPI 层、协议栈层、socket 层。有个比喻我觉得特别贴切这就像一家餐厅的出餐流程。厨师网卡把做好的菜放在出餐口Ring Buffer然后按铃中断告诉服务员CPU有菜可以端了。服务员如果每来一道菜就跑去端一次高峰期会被累死——这就是纯中断模式的弊端。所以餐厅后来改成了按一次铃之后服务员不再理会新的铃声而是把出餐口积压的菜一次性全端走NAPI polling端完再休息这样才高效。2.2 收包路径上的关键模块地图网上的资料很零散这里我把收包路径上真正关键的内核模块以 Linux 5.x 源码树为例按路径列成一张表你阅读源码时可以直接对照着找内核路径职责关键文件驱动层初始化硬件、DMA 映射、配置 Ring Bufferdrivers/net/ethernet/intel/igb/igb_main.c 等中断子系统将硬件中断映射到 CPU触发软中断kernel/irq/、arch/x86/kernel/irq.cNAPI 机制中断与轮询的配合机制net/core/dev.c 中的net_rx_action收包入口驱动把 sk_buff 交给协议栈net/core/dev.c 中的netif_receive_skb/__netif_receive_skb_coreTC 入口流量控制收包方向也有 tc 钩子net/sched/sch_ingress.cnetfilteriptables/nftables 钩子、连接跟踪net/netfilter/、net/ipv4/netfilter/IP 层IPv4 首部校验、路由查找、分片重组net/ipv4/ip_input.c、ip_forward.c传输层TCP/UDP 端口解复用、状态机net/ipv4/tcp_ipv4.c、udp.csocket 层接收队列、等待队列、唤醒机制net/core/sock.c、datagram.c这张表不需要你现在就背下来真正的意义在于当你在内核里追踪一个包时你要始终清楚自己目前在流水线的哪个环节下一个环节应该去哪里找入口。2.3 中断与轮询为什么不能只用中断老一代网卡收包就是纯中断模式每来一个数据包网卡就向 CPU 发一个中断。这在低速网络下没问题但到了万兆甚至更高带宽时就会出现致命问题中断风暴interrupt storm。想象一下每秒进来 100 万个数据包如果每个包都触发一次中断CPU 就要被中断打断 100 万次。每次中断从保存现场、执行 ISR 到恢复现场大概需要几微秒的 CPU 时间光是处理中断就要占用掉大部分 CPU 资源真正用来处理数据的时间所剩无几。这在业界叫雷神之锤thundering herd效应别被名字吓到意思就是“大量的小中断把 CPU 淹没了”。内核解决这个问题的思路很朴素既然中断太频繁那就减少中断次数。具体做法有这么几层中断合并Interrupt Coalescing硬件层面让网卡积累一定数量的包或者等待一小段时间再发一次中断比如默认等 125 微秒。代价是增加了少量延迟但换来了吞吐量的大幅提升。NAPINew API这是内核层面的核心机制。第一次中断来了之后驱动在中断里把自己的poll方法挂到当前 CPU 的软中断队列上然后关掉网卡中断。之后 CPU 不再收中断而是通过软中断反复调用poll去网卡 Ring Buffer 里把积累的包成批取走直到包取完了再打开中断。这套“中断通知 轮询取包”的组合拳是理解现代 Linux 收包绕不开的第一道坎。后面我会专门用一整节展开讲 NAPI 的细节因为它在实际调优中太重要了。3. NAPI 机制收包效率的关键3.1 NAPI 的工作过程逐帧拆解NAPI 的出现彻底改变了收包模型。在 NAPI 模式下收包的完整时序是这样的网卡收到第一个包硬件把数据 DMA 到 Ring Buffer然后触发中断。CPU 执行驱动注册的 ISR中断处理函数。ISR 做的工作非常克制——它只做“必须要立刻做”的事清中断、关闭该网卡后续中断、将 NAPI 实例加入当前 CPU 的 softnet 队列然后触发软中断。软中断NET_RX_SOFTIRQ被触发进入net_rx_action()。这个函数会遍历当前 CPU 的 softnet 队列逐一调用 NAPI 实例的poll方法。驱动实现的poll方法从 Ring Buffer 里一次性取走一批包最多budget个逐个构建sk_buff然后交给netif_receive_skb()进入协议栈。如果poll方法把 Ring Buffer 里的包取完了驱动重新打开网卡中断并把 NAPI 实例从软中断队列里摘除整个收包过程回到“中断模式”等待下一个包如果没取完NAPI 会继续留在队列里软中断会再次调度它。关键点在于第 2 步和第 4 步中断处理函数做的事情尽可能少真正的收包动作全部转移到软中断的轮询阶段。内核源码里的net_rx_action大致长这样省略了大量细节保留主干逻辑static __latent_entropy void net_rx_action(struct softirq_action *h) { struct softnet_data *sd this_cpu_ptr(softnet_data); unsigned long time_limit jiffies 2; int budget READ_ONCE(netdev_budget); LIST_HEAD(list); local_irq_disable(); list_splice_init(sd-poll_list, list); local_irq_enable(); for (;;) { struct napi_struct *n; if (list_empty(list)) { if (!sd_has_rps_ipi_waiting(sd)) break; break; } n list_first_entry(list, struct napi_struct, poll_list); budget - napi_poll(n, repoll); /* 如果时间预算用完或者包处理达到 budget退出 */ if (unlikely(budget 0 || time_after_eq(jiffies, time_limit))) { /* 将剩余的 napi 实例重新放回 poll_list等待下一次调度 */ sd-time_squeeze; break; } } ... }这里有两个核心参数值得一提netdev_budget默认 300和time_limit默认 2 个 jiffies也就是约 2 毫秒。软中断每次最多处理 300 个包或者运行 2 毫秒目的就是防止“取包”过程饿死其他任务。这两个参数都可以通过/proc/sys/net/core/netdev_budget调整但一般不建议乱调后面我讲调优时再展开。3.2 驱动侧 NAPI 注册与实现要点看完框架再往下看看驱动具体怎么实现 NAPI。以 Intel igb 网卡驱动为例收包路径上有三个关键函数igb_pollNAPI 的 poll 回调、igb_clean_rx_irq真正从 Ring Buffer 取包、igb_alloc_rx_buffers给 Ring Buffer 补充空闲缓存。NAPI 的初始化代码中最关键的是netif_napi_add这个函数它把 NAPI 实例与设备的收包队列绑定static int igb_poll(struct napi_struct *napi, int budget) { struct igb_ring *tx_ring container_of(napi, struct igb_adapter, rx_ring[0].napi); ... clean_complete igb_clean_rx_irq(rx_ring, budget); if (clean_complete) napi_complete_done(napi, work_done); return work_done; }我来解释一下这段代码里的设计匠心。为什么budget要作为参数传进来因为内核规定了一次软中断的总预算这个预算要在所有注册到同一 CPU 的 NAPI 实例之间分配。驱动每取走一个包net_rx_action里的budget就减一这样可以保证多个网卡/队列同时有包时不至于饿死某一个。接着说napi_complete_done。这个函数做的事情是从 softnet 队列里摘掉当前 NAPI 实例、重新打开网卡中断调用驱动的irq_enable回调。它的命名里带done暗示“这次轮询结束了”如果驱动判断 Ring Buffer 里已经没有包了就要调用它把中断恢复让硬件在有新包时能再次通知 CPU。所以记住这个规律写驱动时poll 函数必须返回本次实际处理的包数Ring Buffer 空了就调用napi_complete_done并重新进入中断节能模式没空就不用软中断会继续调度你。3.3 多队列下的 NAPI 与 RPS 配合现代高性能网卡比如 Intel XL710、Mellanox ConnectX 系列都有多个 DMA 队列每个队列绑定一个特定的中断号可以分发到不同的 CPU 上处理。这块机制叫RSSReceive Side Scaling由网卡硬件根据哈希值源 IP、目的 IP、端口等将不同的连接分散到不同队列从而实现多核并行收包。每个硬件队列对应一个 NAPI 实例也就是一个napi_struct。一个队列的中断被调度到哪个 CPU那个 CPU 就负责 poll 这个队列的数据。如果你有 4 个队列并且中断恰好分布在 4 个不同 CPU 上那收包处理能力理论上可以接近 4 倍。但实际场景中RSS 不一定能均匀分布特别是连接数较少时哈希很容易偏向某个或某几个队列。内核提供了一种软件层的补充机制叫RPSReceive Packet Steering它不修改硬件队列的分配而是在netif_receive_skb入口处根据包的哈希值将包通过IPI处理器间中断转发给目标 CPU 的 softnet 队列由那个 CPU 的软中断来执行后续的协议栈处理。RPS 的配置方式是在/sys/class/net/网卡名/queues/rx-n/rps_cpus里写入目标 CPU 的 bitmask。比如要让 0-3 号 CPU 都能参与处理队列 0 的包可以执行echo f /sys/class/net/eth0/queues/rx-0/rps_cpusf就是 0b1111对应 CPU 0 到 3。这个配置在虚拟化场景比如 openstack 的虚机中尤其有用因为虚机的网卡通常是 virtio硬件队列数量有限RPS 可以把负载分散到更多的虚拟 CPU 上。4. 从 Ring Buffer 到协议栈sk_buff 的诞生4.1 Ring Buffer 的作用与配置调优很多人会把 Ring Buffer 和网卡 FIFO 混为一谈其实它们不是一回事。网卡里有个小的硬件 FIFO用来缓存物理链路上收到的数据帧很小几十 KB 到几百 KB 不等而 Ring Buffer 是驱动在内存中分配的 DMA 环形队列大小可以调整默认通常是 256 或 512 个描述符每个描述符指向一个数据缓冲区。收包时网卡 DMA 控制器直接把数据写到 Ring Buffer 指向的内存区域写完之后在描述符里标记完成状态然后才触发中断。注意这里的关键点在于数据在中断触发之前其实已经躺在内存里了CPU 要做的是找到这些数据而不用像 DMA 之前那样再主动去网卡内存里拷贝。Ring Buffer 调优经常出现在运维实战里。查看和修改大小的命令如下# 查看当前设置 ethtool -g eth0 # 修改为 4096 ethtool -G eth0 rx 4096 tx 4096ethtool -g输出中的Current hardware settings就是当前值Maximums是硬件支持的上限。如果发现网卡在高峰期有丢包而 Ring Buffer 的中断计数正常第一反应就应该是看看 rx 队列是否被塞满了。有一种典型的丢包场景业务吞吐量瞬时暴涨驱动来不及把所有包从 Ring Buffer 取走新来的包没有可用的描述符网卡只能丢弃。遇到这种情况适当调大 rx Ring Buffer 会有立竿见影的效果。不过也得提醒一句Ring Buffer 不是越大越好。每个描述符都意味着驱动要预留一块 DMA 缓冲区也就对应着一块真实物理内存。比如 4096 个描述符、每个缓冲区 2KB光一个队列就要占 8MB 内存。多队列网卡动辄几十个队列内存消耗会很可观。4.2 驱动收包的关键路径igb_clean_rx_irq真正从 Ring Buffer 中取出数据包的函数是igb_clean_rx_irq。它的核心逻辑大致如下static bool igb_clean_rx_irq(struct igb_ring *rx_ring, int budget) { unsigned int total_bytes 0, total_packets 0; u16 cleaned_count igb_desc_unused(rx_ring); struct sk_buff *skb rx_ring-skb; ... while (likely(total_packets budget)) { union e1000_adv_rx_desc *rx_desc; struct igb_rx_buffer *bi; unsigned int size; /* 从环上取下下一个描述符 */ rx_desc IGB_RX_DESC(rx_ring, rx_ring-next_to_clean); size le16_to_cpu(rx_desc-wb.upper.length); if (!size) break; /* 根据描述符状态判断是否是一个完整的包 */ ... /* 将 DMA 缓冲区和 skb 关联 */ skb igb_fetch_rx_buffer(rx_ring, rx_ring-next_to_clean, skb, size); ... /* 交给协议栈 */ if (igb_is_non_eop(rx_ring, rx_desc, skb)) continue; /* 合并所有分片后提交 */ skb-protocol eth_type_trans(skb, rx_ring-netdev); netif_receive_skb(skb); ... rx_ring-skb NULL; total_packets; } ... }几个值得注意的细节第一IGB_RX_DESC宏只是计算描述符的内存地址内存访问本身有 cache 机制所以频繁访问同一个环形缓冲区的描述符性能损耗不大。但前提是你的 DMA 缓冲区做了 cache 对齐否则会出现 cache line bounce多 CPU 同时访问同一块数据时性能会急剧恶化。驱动开发中一般用netdev_alloc_skb或者page_pool_alloc来分配避免这个问题。第二igb_fetch_rx_buffer这一步很巧妙。它不急着把一个包的所有分片汇聚成一个线性 skb而是先在 skb 上挂frags。就像你叫了一份外卖商家不把所有东西塞进一个袋子而是分几个袋子挂在外卖架上等用户协议栈确认收齐了再合并。第三eth_type_trans这个函数会被执行两遍吗答案是只在网卡驱动收包时执行一次目的就是从以太网帧头里解析出协议类型IP、ARP、VLAN 等设置好skb-protocol并将 skb 的 data 指针从帧头后移指向 IP 头。如果做 DPDK 这类用户态收包这个动作也要自己实现一遍。4.3 sk_buff 的内存管理与页池技术聊到sk_buff不得不提它的内存分配策略。早期内核给每个包分配独立的线性缓冲区小包还好说大包就要做多次内存拷贝性能很差。现在主流的做法是页池page pool一次分配一页4KB或者高配上的 8KB/64KB compound page然后把一个包的不同分片fragments挂到这一页的不同偏移上。这样既能减少分配次数又能在包被释放后快速归还页池复用。页池相关的代码在net/core/page_pool.c很多驱动已经在用了。你在perf top里看到page_pool_alloc_pages或者page_pool_put_page频繁出现说明驱动用的是页池模式。页池的好处是同一个池子里的页在不同 CPU 之间不会互串每个 CPU 都有独立的页池实例避免了 CPU 争用锁。收包场景下页池还有一个关键优化叫DMA 映射复用。驱动第一次把一个页映射给 DMA 时页池记录下 DMA 地址后面这个页被释放回池子再重新分配驱动不需要重新做 DMA 映射直接沿用之前的地址。别小看这个优化DMA 映射和反映射是有 TLB 和 IOMMU 开销的在高 PPS每秒数据包数场景下省下这一步能明显提升性能。5. 协议栈漫游IP 层、Netfilter、传输层5.1 netif_receive_skb 之后的路径netif_receive_skb是整个收包的统一入口。进入这个函数之后数据包会依次经过这些关键环节RPS 分发如果开启了 RPS包会根据哈希被转发到目标 CPU。TC ingress 钩子如果你配置了tc的ingress规则这里会先过一遍 tc 过滤器。这也就是很多做流量控制、流量镜像的工具如tc mirred能生效的位置。协议栈分发__netif_receive_skb_core最终会调用deliver_ptype_list_skb根据skb-protocol找到对应的packet_type一般是ip_rcvIPv4。这里有个很实用的排查技巧如果你想看一个包进入协议栈后去了哪里可以用bpftrace挂kprobe:netif_receive_skb或者在__netif_receive_skb_core入口处打印skb-protocol。比如bpftrace -e kprobe:netif_receive_skb { printf(dev%s proto%04x\n, str(((struct sk_buff *)arg0)-dev-name), ((struct sk_buff *)arg0)-protocol); }跑起来就能看到每个包是哪个网卡进来的、什么协议。这个手段在定位“某个包到底有没有进入协议栈”这个问题时特别有效。5.2 IP 层处理与 Netfilter 钩子IP 层入口是ip_rcv。它做的事包括校验 IP 首部、检查版本、丢弃畸形包然后最关键的一步是进入Netfilter的PREROUTING钩子。Netfilter 钩子的位置和顺序是理解 iptables / nftables 工作原理的基础。收包方向上有三个关键点进入协议栈 → NF_INET_PRE_ROUTING → 路由决策 → NF_INET_LOCAL_IN本机进程 / NF_INET_FORWARD转发在PRE_ROUTING链上可以做 DNAT、连接跟踪conntrack等操作decnet路由决策确定包是给本机还是转发给本机的会走到LOCAL_IN链这是 INPUT 规则生效的地方转发的会走到FORWARD链。很多做网关、路由器开发的人对FORWARD链上 conntrack 的性能非常敏感这里有一个隐藏很深的问题如果开启了nf_conntrack每个新连接都要创建一条 conntrack 表项。在 SYN Flood 攻击下conntrack 表可能被塞满导致合法的连接也被丢弃。常见的优化手段是调整 conntrack 表大小# 查看当前 conntrack 表大小和计数 sysctl net.netfilter.nf_conntrack_max cat /proc/sys/net/netfilter/nf_conntrack_count # 调整表大小 sysctl -w net.netfilter.nf_conntrack_max1048576但这只是治标。真正要扛住大流量得综合考虑超时时间的缩短、内存分配策略、甚至关闭不必要的跟踪。后面我会在“性能排查”一节再提几个实战指标。IP 层还有一个容易忽略的路径分片重组。当一个 IP 数据报大于 MTU 且包被分片后接收端要等所有分片到齐才能重组。这个重组有超时时间和内存上限Linux 默认限制是 64 个分片同时重组超时 30 秒。如果有人恶意发送大量不完整的分片就能吃满重组缓冲区这叫分片攻击。制造这类问题时/proc/sys/net/ipv4/ipfrag_time和ipfrag_max_dist是排查的重要参数。5.3 TCP/UDP 解复用包如何找到自己的 socketIP 层处理完根据协议头里的协议号包会被分发给tcp_v4_rcv或udp_rcv严格来说 UDP 入口是__udp4_lib_rcv。传输层的核心工作可以概括为通过四元组源 IP、源端口、目的 IP、目的端口查找对应的 socket。这里有两个查找表TCP 的ehashestablished hash 表和bhashbind hash 表UDP 也有自己的哈希表。内核在建立连接时会根据四元组做哈希收到报文时再根据同样的哈希快速定位。这个查找过程有个很微妙的地方它是在软中断上下文做的并且要加锁。因此高并发场景下socket 哈希表的锁竞争会成为一个瓶颈。内核针对这个问题的优化手段把ehash分成了多个桶每个桶有独立的读写锁并且把链表节点与锁分布在不同 cache line 上减少 cacheline 颠簸。如果你用perf统计到tcp_v4_rcv和__inet_lookup_established占比较高且 CPU 使用率不均那就是哈希表冲突或锁竞争的信号。优化思路通常是确认网卡的 RPS/RSS 哈希配置是否均匀调整 socket 的reuseport属性让多个进程/线程共享同一个端口由内核把连接分发到不同的 socket 上如果连接数奇高可能要考虑协议栈外的用户态方案比如 XDP来分担压力。TCP 层还有一个不能不提的机制接收窗口和零窗口。接收窗口大小决定了发送方能连续发多少数据而不用等 ACK。如果接收窗口很小接收方就会频繁通告零窗口导致发送方进入持续重传和探测状态吞吐量断崖式下降。排查这类问题时重点关注/proc/net/netstat里的TCPRcvQDrop、TCPOfO乱序以及TCPZeroWindow等计数器。UDP 的路径相对简单没有连接状态机包来了直接按五元组找 socket找到就往接收队列里塞。但 UDP 有一个“经典坑”如果 socket 接收队列塞满新到的包会被直接丢弃且应用层感知不到任何错误。默认情况下net.core.rmem_max和net.ipv4.udp_mem不会为 UDP 预留太多空间UDP 业务量一大丢包就非常隐蔽。排查时除了看应用日志还要看/proc/net/snmp中的UdpRcvbufErrors字段。5.4 将数据送上 socket 队列传输层完成解复用后最终要把数据放到 socket 的接收队列。这个动作涉及两个重要的队列接收队列sk_receive_queue和后备队列sk_backlog。如果进程此时正好在recvmsg调用里被阻塞等待并且持有了 socket 锁那么内核会直接把数据放进sk_receive_queue然后唤醒进程。如果进程正忙于处理其他数据或者锁被占用了数据会先放到sk_backlog等进程把锁释放后再由release_sock处理。这个“绕过锁直接投递”的机制叫locked 快速路径能减少锁等待时间。在 TCP 场景下还有两个和性能相关的路径GROGeneric Receive Offload和GSOGeneric Segmentation Offload。GRO 在驱动或协议栈层将多个小包合并成一个大的逻辑包减少协议栈处理次数。GSO 则是在包离开协议栈之前允许拆分发送时由硬件或软件在更下游完成拆分。GRO 打开时如果你用 tcpdump 抓包可能看到单个报文里包含了多个 TCP 段比如总长度 6000 多字节但 TCP 首部只算一次。这不是 bug而是 GRO 合包的结果。排查这类“抓包看到大包”的困惑时记住 GRO 就好。6. 多队列、中断绑定与包分发策略6.1 中断绑定把中断固定在指定 CPU 上服务器的网卡尤其是 Intel、Mellanox 这些牌子都支持多队列每个队列有独立的中断号。Linux 通过irqbalance服务或者手动写/proc/irq/irq/smp_affinity来设置中断绑定的 CPU。中断绑定之所以重要是因为 CPU 的 cache 亲和性。如果同一个队列的数据总是在同一个 CPU 上处理那么和这个队列相关的数据结构sk_buff 缓存、socket 哈希表、协议栈状态有很大概率驻留在那个 CPU 的 cache 里处理速度会快很多。如果中断在 CPU 之间乱跳每次都要重新加载 cache性能损耗很显著。查看和设置中断绑定的常用命令# 查看网卡各队列对应的 IRQ 号 cat /proc/interrupts | grep eth0 # 将特定 IRQ 绑定到 CPU 0 echo 1 /proc/irq/78/smp_affinity # 查看当前 affinity cat /proc/irq/78/smp_affinity/proc/irq/78/smp_affinity里写入的是一个十六进制 bitmaskbit 0 对应 CPU 0。如果要绑定到 CPU 0 和 CPU 2那就写0x5。这里有个实际经验如果使用irqbalance它默认会帮你做一轮平衡但它的判断逻辑并不一定适合所有场景。我在实际项目中试过很多次对于网络密集型应用手动设置中断绑定比 irqbalance 的默认策略好不少特别是当你有两个 NUMA 节点时必须保证网卡中断落在网卡所在的 NUMA 节点对应的 CPU 上否则跨 NUMA 访问内存延迟会成倍增加。可以用lspci -v查看网卡所在的 NUMA node再用numactl --hardware查看 CPU 分布。6.2 RSS 的哈希策略与网卡配置RSS 的效果取决于哈希算法和哈希字段的选择。默认情况下网卡会使用一个固定 key 做 Toeplitz 哈希输入字段包括源/目的 IP、源/目的端口。某些网卡驱动允许自定义哈希 key 和字段比如只按 IP 哈希用ethtool -n和ethtool -N可以查看和修改# 查看 RSS 哈希配置 ethtool -n eth0 rx-flow-hash tcp4 # 设置为按 IP 和端口哈希 ethtool -N eth0 rx-flow-hash tcp4 sdfnrx-flow-hash tcp4 sdfn中的sdfn分别表示s源 IP、d目的 IP、f源端口、n目的端口。你可以根据业务特征调整。比如你的业务从少数几个 IP 发起大量连接那按 IP 哈希很可能导致哈希偏斜此时改为按端口哈希会更均匀。判断 RSS 是否均衡有一个简单粗暴的办法看/proc/interrupts里每个中断号的计数是不是均匀增长。如果某个中断号的计数增长明显快于其他说明哈希偏了流量都压在一个队列上。6.3 RPS 与 XPS软硬件协作的流量分发除了 RPS收包方向还有XPSTransmit Packet Steering用于发送方向。XPS 允许每个发送队列绑定到指定的 CPU 集合这样从某个 CPU 上发起的发送操作会优先选到绑定的队列提高发送侧的 cache 亲和性。配置 XPS 的方法和 RPS 类似在/sys/class/net/网卡名/queues/tx-n/xps_cpus里写入 CPU bitmask。一般在多队列网卡上配合中断绑定一起配置。更复杂的分发策略是aRFSAccelerated Receive Flow Steering需要网卡和驱动支持可以让硬件根据应用的 socket 位置动态调整队列到 CPU 的映射实现更精确的“流量跟随应用”。不过这个特性在实际运维中并不多见因为配置复杂收益也不是线性的。7. 收包性能调优从工具到实战7.1 看哪些指标用什么工具排查收包性能问题第一步是搞清楚“包到底丢在哪了”这需要分层观察。我的经验是优先看这几个地方1. 网卡层ethtool -S eth0 | grep -E rx_|tx_重点看rx_dropped、rx_missed、rx_errors。rx_missed表示硬件 FIFO 溢出说明入包速率超过了驱动的处理能力优先调大 Ring Buffer 或减少中断合并延迟rx_dropped可能是驱动队列满了或内核协议栈丢弃。2. 协议栈层cat /proc/net/softnet_statsoftnet_stat每行对应一个 CPU第四列是time_squeeze表示软中断时间预算耗尽后但包还没处理完的次数数值持续增长说明 CPU 上软中断太拥挤。第三列是dropped表示 backlog 队列满导致的丢包。3. socket 层cat /proc/net/snmp | grep -E Udp|Tcp cat /proc/net/netstat | grep -E TCP|IpExt重点看UdpRcvbufErrors、UdpSndbufErrors、TcpExtTCPRcvQDrop、TcpExtTCPBacklogDrop。4. 中断与 CPU 分布cat /proc/interrupts top -n1 -H看中断计数是否均匀以及各个 CPU 的软中断si占比。7.2 实战案例CPU 软中断 100% 但业务很闲我曾经处理过一个典型的性能问题一台 16 核服务器CPU 6 的软中断占用率常年 100%但业务进程只占 5% 的 CPU整体吞吐量还上不去。排查过程是这样的先看/proc/interrupts发现网卡的队列 6 中断计数暴涨——所有流量几乎都哈希到了队列 6。再看/proc/net/softnet_statCPU 6 的time_squeeze值在快速增加说明软中断处理不完预算耗尽后还有包积压。进一步分析网卡 RSS 哈希我意识到问题出在业务流的五元组特征上这些连接源 IP 段比较集中比如来自同一个 NAT 出口哈希取模后大量落在同一个队列上。同时irqbalance又把队列 6 的中断固定在了 CPU 6 上于是 CPU 6 成了瓶颈。解决办法有两步修改 RSS 哈希 key让流量重新分布到不同队列。用ethtool -X eth0 hkey 新的16字节key重新生成哈希。手动调整中断绑定将队列 6 的中断指向 CPU 8而非 CPU 6同时关闭该 CPU 上不必要的负载。改完之后各个队列的中断计数趋于均衡整体吞吐量提升了一倍多。这个案例给我们的启示是遇到软中断打满先不要急着调内核参数先看流量在队列上是不是偏了。7.3 内核参数调优哪些有用哪些是玄学网上关于内核收包调优的文章很多列了一大堆 sysctl 参数。我这里只挑几个真正有用的其余不浪费篇幅。参数默认值作用调整建议net.core.rmem_max212992socket 接收缓冲上限大流量场景调大到 16MB 以上net.core.netdev_max_backlog1000CPU backlog 队列长度高并发场景调大如 10000net.core.somaxconn4096listen 队列上限高并发连接场景调大net.ipv4.tcp_rmem4096 87380 6291456TCP 接收缓冲min default max按 RTT 和带宽积调整net.ipv4.tcp_tw_reuse0TIME_WAIT 复用短连接多的场景适当开启net.ipv4.tcp_max_syn_backlog1024SYN 队列长度连接建立慢时调大net.core.busy_read/busy_poll0用户态 busy poll低延迟场景可尝试但耗 CPU这里必须泼一盆冷水不要一上来把net.core.netdev_max_backlog改到几万。backlog 本质上是一个队列队列再大如果 CPU 处理能力跟不上只是把丢包问题往后拖延并不会提升处理能力反而可能增加内存消耗和延迟。调优的正确顺序是先定位瓶颈再根据瓶颈类型选择参数。7.4 XDP绕过协议栈的激进优化如果你的项目追求极致的 DDoS 防护或高性能网关单纯调内核参数可能还不够那就该考虑XDPeXpress Data Path了。它允许你在网卡驱动收包、还没创建sk_buff之前用 BPF 程序对原始报文做处理比如丢弃、转发、改包。最关键的收益是绕过了整个协议栈的开销并且可以利用网卡硬件卸载能力如 Intel 的 XDP offload直接处理。XDP 的典型使用场景包括DDoS 防护在 XDP 层直接丢 SYN Flood 包CPU 只付出极低的代价。负载均衡用 XDP 做透明 LB直接把包从一台服务器转发到后端。流量采样在 XDP 里做 sflow/netflow 的报文头提取比 tcpdump 高效太多。XDP 程序通常用 C 写编译成 BPF bytecode 后通过ip link set dev eth0 xdp obj xxx.o加载。一个简单的丢包程序如下#include linux/bpf.h #include linux/if_ether.h #include linux/ip.h #include bpf/bpf_helpers.h SEC(xdp_drop) int xdp_drop_prog(struct xdp_md *ctx) { return XDP_DROP; }编译加载后这个网卡上所有收到的包都会被在驱动层直接丢掉CPU 负担几乎可以忽略。当然生产环境中的 XDP 程序要复杂得多要和AF_XDPsocket 配合才能在用户态处理包这块如果展开讲又能写一篇长文这里先做个引子。8. 常见问题与排查技巧实录8.1 经典问题排查速查表现象可能原因排查命令 / 参数解决方向网卡 rx_dropped 持续增加Ring Buffer 满 / 中断处理不及时ethtool -S eth0、ethtool -g eth0调大 Ring Buffer、优化中断合并参数某个 CPU 软中断 100%流量哈希不均 / 中断绑定不均衡/proc/interrupts、/proc/net/softnet_stat调整 RSS key、手动设置 smp_affinity应用收包慢、延迟高socket 接收缓冲不足 / 协议栈 backlog 堆积netstat -s、ss -lnt调大 rmem、检查锁竞争和 wakeup 路径UDP 丢包但无报错接收队列满 / UDP 内存不足cat /proc/net/snmpgrep Udp连接建立慢、SYN 重传SYN 队列满 / conntrack 满ss -lnt看 Send-Qdmesg查 conntrack调大 SYN backlog、提升 nf_conntrack_maxtcpdump 抓到大包但 TCP 段正常GRO 合包导致ethtool -k eth0grep gro8.2 收包链路分段的隐藏坑Ring Buffer 和协议栈之间的“黑洞”有个场景很能迷惑人ethtool -S显示网卡没有丢包softnet_stat也没有dropped计数但应用就是收到不完整的数据或者整体吞吐上不去。我在 8.2 说一个特别容易踩的坑中断合并参数设置得过于激进时延迟会被放大。网卡的rx-usecs参数控制“收到包后最多等多长时间才触发中断”如果设置得很大比如 1000 微秒 1 毫秒那么即使数据早就到了 Ring Buffer中断也迟迟不触发软件层面看就是“延迟突然多了 1 毫秒”。对于高频交易、在线游戏这类延迟敏感业务这个坑非常致命。排查方法ethtool -c eth0 # 查看当前合并参数重点关注rx-usecs、rx-frames。如果业务对延迟敏感建议把rx-usecs调到 0 或很小比如 8~16 微秒如果吞吐量优先可以适当增大。记住中断合并是典型的“延迟换吞吐”取舍没有绝对正确的值。另一个坑是网卡驱动把包 DMA 到的内存区域可能在 NUMA 远端节点。如果网卡插在 NUMA node 0 的 PCIe 槽上驱动默认把 DMA 缓冲区和sk_buff分配到 node 0 的内存但你的应用线程如果跑在 node 1 的 CPU 上访问这些内存就是跨 NUMA 访问延迟和带宽都会受影响。正确的做法是用numactl让应用线程和网卡中断落在同一个 NUMA 节点。8.3 一个完整的定位流程实例我再用一个实例把上面的排查思路串起来。假设有一台机器业务方反馈“通过 TCP 下载文件的速度很慢只有预期的一半”。第一步看网卡层。ethtool -S eth0 | grep -E rx_dropped|rx_missed假设输出中发现rx_missed一直在涨。这说明硬件 FIFO 溢出也就是驱动取包的速度赶不上网络到达速度。再看ethtool -g eth0发现 rx Ring Buffer 只有 256。第二步先调大 Ring Buffer。ethtool -G eth0 rx 4096再观察rx_missed是否还涨。如果还涨说明不是队列深度的问题而是中断处理策略的问题。第三步看中断合并。ethtool -c eth0发现rx-usecs为 250 微秒。如果业务是下载大文件250 微秒的合并其实不算过分但为了确认把它调低一些再测ethtool -C eth0 rx-usecs 32测完发现吞吐量有提升但 CPU 开销也上来了因为中断次数更多了。第四步如果用 perf 发现tcp_v4_rcv和__inet_lookup_established占比较高再看 socket 层cat /proc/net/netstat | grep TCP尤其看TCPRcvQDrop和TCPBacklogDrop是否非零。如果TCPBacklogDrop有值说明进程的接收锁竞争严重频繁发生数据被放到 backlog 而不是直接投递的情况。此时可以尝试调大 socket 接收缓冲减少进程内的锁竞争比如使用 SO_REUSEPORT 打开多个接收 socket让每个 CPU 上的进程只处理自己那部分连接。第五步仍然不行再用perf top看热点函数。如果看到queued_spin_lock_slowpath很高说明锁竞争严重这时就要考虑 XDP 或者 DPDK 这类用户态方案了。这套流程的要点是从上到下逐层排查不要一上来就调 sysctl。很多时候问题根本不在协议栈参数而在最底层的 DMA 队列或者中断策略。9. 收包路径上的调试技术perf、trace 与 BPF9.1 给收包路径加“断点”kprobe 与 tracepoint如果我怀疑协议栈某个环节出了问题最直接的方法是在路径上对应的函数加探针。内核为我们准备了两类探针tracepoint相对稳定推荐生产环境使用和kprobe灵活但依赖函数名内核版本变化容易失效。收包路径上常见的 tracepoint 有netif_receive_skb进入协议栈入口。netif_rx非 NAPI 路径入队。napi_pollNAPI 轮询开始/结束。kfree_skb包被释放一般意味着被丢弃或处理完成。用 bpftrace 挂在kfree_skb上可以快速知道哪些包被丢弃了以及丢在哪个位置。比如bpftrace -e tracepoint:skb:kfree_skb { printf(skb%p protocol%04x location%s\n, arg0, ((struct sk_buff *)arg0)-protocol, kstack()); }这个探针特别适合排查“包进入协议栈后消失”的问题。如果丢包发生在kfree_skb可以直接看到调用栈定位到是 IP 层丢弃、TCP 层丢弃还是 netfilter 丢的。9.2 一次丢包定位的实战演示有一次我们遇到一个诡异的问题客户端频繁重连服务端的连接数量正常没有 reject但 SYN 的 ACK 偶尔会迟。用上面的 tracepoint 方法挂到kfree_skb后发现丢包位置在tcp_v4_rcv中具体是tcp_validate_incoming阶段丢弃原因是TCP_SKB_CB(skb)-seq不匹配即包乱序严重。进一步查网络链路发现中间交换机开启了流量整形部分包被延迟太久接收端认为超时后便丢弃。最终通过调整交换机队列策略解决而不是改内核参数。这个案例说明很多问题在协议栈之外探针的作用是帮你快速定位到“不是内核的锅”。9.3 perf 定位 CPU 热点如果softnet_stat显示某个 CPU 长期忙碌可以用perf看热点函数占比perf top -C 6典型的热点分布如下napi_poll 驱动收包函数高瓶颈在驱动收包/中断处理。netif_receive_skb_core高瓶颈在协议栈入口分发。ip_rcv、nf_hook_slow、tcp_v4_rcv高瓶颈在协议栈核心逻辑。queued_spin_lock_slowpath高锁竞争严重通常是多线程共享 socket 或哈希表冲突。根据热点位置再选择对应的优化方向。比如热点在nf_hook_slow可以考虑精简 nftables/iptables 规则或者使用nftables的flowtable做硬件/流表加速。9.4 工具使用心得我的个人经验是bpftrace 适合定性分析perf 适合定量统计ethtool 适合硬件层观测三者配合使用基本能覆盖 90% 的收包问题。如果做长周期的监控可以用perf record -g抓到内存后离线分析而不是长时间开perf top。而在多 CPU 的机器上记得给perf stat指定-C参数限定 CPU否则统计全系统会掩盖单核瓶颈。10. 写在最后内核收包学习路径与几个体会很多人问我内核网络这块怎么学才能又快又扎实。我给的答案一直是先搭一条主链路再往每个节点里钻。先把netif_receive_skb到tcp_v4_rcv这条线走通再倒回来看驱动、NAPI、Ring Buffer、中断最后再深入 TCP 状态机、内存管理和锁的细节。这样你始终知道自己在哪里不会被海量源码淹没。另一个体会是收包路径上很多看起来“性能优化”的设计本质都是在延迟和吞吐之间做权衡。NAPI 是这样中断合并是这样GRO 也是这样。理解了这个权衡很多参数的默认值和调整方向你就能自己推导出来而不是靠背配置表。最后再分享一个实用的建议如果你在嵌入式设备上做收包相关的开发记得看一眼硬件平台的 DMA 能力和 cache 一致性模型。很多嵌入式 SoC 上驱动默认分配的 DMA 缓冲区和 CPU cache 之间的一致性维护方式不同收包性能可能差好几倍。遇到“同样的代码在 A 板卡上跑得好好的换到 B 板卡就狂丢包”时优先怀疑 DMA/cache 配置而不是去调协议栈参数。收包这条路我走了很多年踩过数不清的坑但把这条链路从头到尾打通之后再遇到网络性能问题心里那块地方总是踏实的。希望这篇文章能帮你把这条路也走通。