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 Buffer | drivers/net/ethernet/intel/igb/igb_main.c 等 |
| 中断子系统 | 将硬件中断映射到 CPU,触发软中断 | kernel/irq/、arch/x86/kernel/irq.c |
| NAPI 机制 | 中断与轮询的配合机制 | net/core/dev.c 中的net_rx_action |
| 收包入口 | 驱动把 sk_buff 交给协议栈 | net/core/dev.c 中的netif_receive_skb/__netif_receive_skb_core |
| TC 入口 | 流量控制(收包方向也有 tc 钩子) | net/sched/sch_ingress.c |
| netfilter | iptables/nftables 钩子、连接跟踪 | net/netfilter/、net/ipv4/netfilter/ |
| IP 层 | IPv4 首部校验、路由查找、分片重组 | net/ipv4/ip_input.c、ip_forward.c |
| 传输层 | TCP/UDP 端口解复用、状态机 | net/ipv4/tcp_ipv4.c、udp.c |
| socket 层 | 接收队列、等待队列、唤醒机制 | net/core/sock.c、datagram.c |
这张表不需要你现在就背下来,真正的意义在于:当你在内核里追踪一个包时,你要始终清楚自己目前在流水线的哪个环节,下一个环节应该去哪里找入口。
2.3 中断与轮询:为什么不能只用中断
老一代网卡收包就是纯中断模式,每来一个数据包,网卡就向 CPU 发一个中断。这在低速网络下没问题,但到了万兆甚至更高带宽时就会出现致命问题:中断风暴(interrupt storm)。
想象一下:每秒进来 100 万个数据包,如果每个包都触发一次中断,CPU 就要被中断打断 100 万次。每次中断从保存现场、执行 ISR 到恢复现场,大概需要几微秒的 CPU 时间,光是处理中断就要占用掉大部分 CPU 资源,真正用来处理数据的时间所剩无几。这在业界叫雷神之锤(thundering herd)效应,别被名字吓到,意思就是“大量的小中断把 CPU 淹没了”。
内核解决这个问题的思路很朴素:既然中断太频繁,那就减少中断次数。具体做法有这么几层:
- 中断合并(Interrupt Coalescing):硬件层面,让网卡积累一定数量的包或者等待一小段时间再发一次中断,比如默认等 125 微秒。代价是增加了少量延迟,但换来了吞吐量的大幅提升。
- NAPI(New 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_poll(NAPI 的 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 上处理。这块机制叫RSS(Receive Side Scaling),由网卡硬件根据哈希值(源 IP、目的 IP、端口等)将不同的连接分散到不同队列,从而实现多核并行收包。
每个硬件队列对应一个 NAPI 实例,也就是一个napi_struct。一个队列的中断被调度到哪个 CPU,那个 CPU 就负责 poll 这个队列的数据。如果你有 4 个队列,并且中断恰好分布在 4 个不同 CPU 上,那收包处理能力理论上可以接近 4 倍。
但实际场景中,RSS 不一定能均匀分布,特别是连接数较少时,哈希很容易偏向某个或某几个队列。内核提供了一种软件层的补充机制叫RPS(Receive 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_rcv(IPv4)。
这里有个很实用的排查技巧:如果你想看一个包进入协议栈后去了哪里,可以用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_max=1048576但这只是治标。真正要扛住大流量,得综合考虑超时时间的缩短、内存分配策略、甚至关闭不必要的跟踪。后面我会在“性能排查”一节再提几个实战指标。
IP 层还有一个容易忽略的路径:分片重组。当一个 IP 数据报大于 MTU 且包被分片后,接收端要等所有分片到齐才能重组。这个重组有超时时间和内存上限,Linux 默认限制是 64 个分片同时重组,超时 30 秒。如果有人恶意发送大量不完整的分片,就能吃满重组缓冲区,这叫分片攻击。制造这类问题时,/proc/sys/net/ipv4/ipfrag_time和ipfrag_max_dist是排查的重要参数。
5.3 TCP/UDP 解复用:包如何找到自己的 socket
IP 层处理完,根据协议头里的协议号,包会被分发给tcp_v4_rcv或udp_rcv(严格来说 UDP 入口是__udp4_lib_rcv)。
传输层的核心工作可以概括为:通过四元组(源 IP、源端口、目的 IP、目的端口)查找对应的 socket。这里有两个查找表:TCP 的ehash(established hash 表)和bhash(bind 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 场景下,还有两个和性能相关的路径:GRO(Generic Receive Offload)和GSO(Generic 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里写入的是一个十六进制 bitmask,bit 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(收包方向),还有XPS(Transmit Packet Steering)用于发送方向。XPS 允许每个发送队列绑定到指定的 CPU 集合,这样从某个 CPU 上发起的发送操作会优先选到绑定的队列,提高发送侧的 cache 亲和性。
配置 XPS 的方法和 RPS 类似,在/sys/class/net/<网卡名>/queues/tx-<n>/xps_cpus里写入 CPU bitmask。一般在多队列网卡上配合中断绑定一起配置。
更复杂的分发策略是aRFS(Accelerated 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_stat,CPU 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_max | 212992 | socket 接收缓冲上限 | 大流量场景调大到 16MB 以上 |
net.core.netdev_max_backlog | 1000 | CPU backlog 队列长度 | 高并发场景调大,如 10000 |
net.core.somaxconn | 4096 | listen 队列上限 | 高并发连接场景调大 |
net.ipv4.tcp_rmem | 4096 87380 6291456 | TCP 接收缓冲(min default max) | 按 RTT 和带宽积调整 |
net.ipv4.tcp_tw_reuse | 0 | TIME_WAIT 复用 | 短连接多的场景适当开启 |
net.ipv4.tcp_max_syn_backlog | 1024 | SYN 队列长度 | 连接建立慢时调大 |
net.core.busy_read/busy_poll | 0 | 用户态 busy poll | 低延迟场景可尝试,但耗 CPU |
这里必须泼一盆冷水:不要一上来把net.core.netdev_max_backlog改到几万。backlog 本质上是一个队列,队列再大,如果 CPU 处理能力跟不上,只是把丢包问题往后拖延,并不会提升处理能力,反而可能增加内存消耗和延迟。调优的正确顺序是:先定位瓶颈,再根据瓶颈类型选择参数。
7.4 XDP:绕过协议栈的激进优化
如果你的项目追求极致的 DDoS 防护或高性能网关,单纯调内核参数可能还不够,那就该考虑XDP(eXpress 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/snmp | grep Udp` |
| 连接建立慢、SYN 重传 | SYN 队列满 / conntrack 满 | ss -lnt看 Send-Q,dmesg查 conntrack | 调大 SYN backlog、提升 nf_conntrack_max |
| tcpdump 抓到大包但 TCP 段正常 | GRO 合包导致 | `ethtool -k eth0 | grep gro` |
8.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 与 BPF
9.1 给收包路径加“断点”:kprobe 与 tracepoint
如果我怀疑协议栈某个环节出了问题,最直接的方法是在路径上对应的函数加探针。内核为我们准备了两类探针:tracepoint(相对稳定,推荐生产环境使用)和kprobe(灵活但依赖函数名,内核版本变化容易失效)。
收包路径上常见的 tracepoint 有:
netif_receive_skb:进入协议栈入口。netif_rx:非 NAPI 路径入队。napi_poll:NAPI 轮询开始/结束。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 配置,而不是去调协议栈参数。
收包这条路我走了很多年,踩过数不清的坑,但把这条链路从头到尾打通之后,再遇到网络性能问题,心里那块地方总是踏实的。希望这篇文章能帮你把这条路也走通。