Linux网卡收包链路与DMA、NAPI、skb机制解析

Feng 33 阅读 操作系统

一个比特从网线进入应用缓冲区,身份要换好几次。中断、驱动、协议栈、recv 这句话把关键细节全省了:网卡把数据写到哪、CPU 怎么知道描述符完成、为什么收包不是一包一中断、sk_buff 凭什么不是一块字节数组。下面沿真实路径走一遍,并在容易翻车的地方补上可操作的判定依据。

物理层与以太网帧的边界

以太网链路上跑的是电或光。发送端把比特流编码成线路符号,接收端靠模拟前端、时钟恢复、均衡、解码把数字数据捞回来。别想成高电压是 1、低电压是 0 这么朴素,不同速率和物理层标准用的线路编码与调制差别很大,高速以太网会用多电平信号、复杂编码或多对线并行。PHY 负责链路训练、时钟同步、信号检测和编解码。

拿“你好”举例,UTF-8 字节是 e4 bd a0 e5 a5 bd,共 6 字节,48 个数据比特上线前还要参与物理层编码。CPU 最终拿到的不是波形图,是硬件还原出来的字节。

PHY 和 MAC 从连续线路符号里恢复出帧。常见 Ethernet II 帧大致长这样:

┌──────────┬─────┬────────────┬────────────┬───────────┬────────────┬─────┐
│ 前导码 7B│ SFD │ 目的MAC 6B │ 源MAC 6B │ 类型 2B │ 载荷填充 │ FCS │
│ │ 1B │ │ │ │ ~1500B │ 4B │
└──────────┴─────┴────────────┴────────────┴───────────┴────────────┴─────┘

前导码和 SFD 帮接收端同步、标记帧起始,通常由 PHY/MAC 硬件处理,不会当普通帧内容交给上层。EtherType 表示上层协议:IPv4 是 0x0800,ARP 是 0x0806,IPv6 是 0x86DD。802.1Q VLAN 标签插在源 MAC 之后、原类型字段之前。载荷一般至少填充到 46 字节,传统 MTU 是 1500,实际环境可能启用 Jumbo Frame。FCS 通常由 MAC 硬件检查,很多网卡在把数据交给主机内存前就剥掉了,抓包不一定看得到。

标准以太网最小帧 64 字节,从目的 MAC 算到 FCS 结束,不含前导码和 SFD。抓包配置、硬件卸载、VLAN 处理会让主机看到的字节布局和线路上实际帧有细微差异。帧不是 IP 包,IP 包也不是 TCP 消息:

以太网帧
┌───────────────────────┬───────────────────────────────┐
│ Ethernet Header │ Ethernet Payload │
│ 目的MAC 源MAC EtherType│ IPv4 包(或ARP、IPv6 等) │
└───────────────────────┴───────────────────────────────┘
↓
IPv4 包
┌───────────────────────┬───────────────────────────────┐
│ IPv4 Header │ TCP Segment / UDP Datagram │
└───────────────────────┴───────────────────────────────┘
↓
TCP段
┌───────────────────────┬───────────────────────────────┐
│ TCP Header │ 应用层字节 │
└───────────────────────┴───────────────────────────────┘

接收端不会先把整段比特流存起来再让 Linux 软件从头拼前导码、FCS 和以太网头。帧同步、CRC 校验、数据过滤这些重活大多由网卡硬件主导,主机侧更通用的起点是网卡已识别出的帧数据加上接收描述符状态。

DMA 写内存与三个地址

日常说的 NIC 是个笼统叫法。拆开看:PHY 负责物理介质侧的信号收发、编解码、链路同步和协商;MAC 负责以太网帧边界、MAC 地址过滤、FCS 校验;网卡控制器/DMA 引擎管理收发队列,读描述符、把收到的数据写进主机内存、更新完成状态。具体芯片里 PHY 可能独立也可能集成。

网卡的“缓冲区”不一定意味着芯片里有一块能装很多完整报文的大内存,不同硬件实现差异很大。对 Linux 驱动来说,真正要盯的是接收队列、描述符、DMA 地址和完成状态。

驱动初始化网卡时先准备一批接收缓冲区,把对应的 DMA 地址写进 RX 描述符。描述符通常包含长度、状态、校验和信息、VLAN 信息等字段,格式完全由硬件规定。帧到达时,网卡控制器找可用 RX 描述符,用 DMA 把帧数据写进描述符指向的内存,再更新描述符状态,告诉驱动这个 buffer 有数据了。数据搬运由设备 DMA 引擎完成,CPU 不需要逐字节 load/store。

主机内存 网卡
┌──────────────────────┐ ┌──────────────┐
│ RX描述符 Ring │<读写状态>│ DMA队列控制器│
│ desc[0] -> buffer A │<DMA地址│ │
│ desc[1] -> buffer B │ │ 收到以太网帧 │
│ desc[2] -> buffer C │ └──────────────┘
└──────────────────────┘
 ▲
 └── 驱动稍后检查描述符,回收已完成的 buffer

这里必须分清三个地址:CPU 虚拟地址、CPU 物理地址、设备 DMA 地址。DMA 地址不一定等于 CPU 看到的物理地址。启用 IOMMU 时,设备看到的 DMA 地址可能经过 IOMMU 映射,驱动必须用 Linux DMA API 分配、映射和同步内存。随手把 virt_to_phys() 的结果当通用 DMA 地址用,是驱动开发里很典型的一类翻车。

// 内核驱动示意片段:展示 RX 缓冲区映射,不是可直接编译的完整驱动。 
dma_addr_t dma_addr;

// buf 是满足 DMA 要求、由驱动持有的接收内存;size 是可写入容量。 
dma_addr = dma_map_single(dev, buf, size, DMA_FROM_DEVICE);
if (dma_mapping_error(dev, dma_addr)) {
 // 映射失败:不能把这个缓冲区发布给设备。 
 return -EIO;
}

/*
以下 my_*
是设备相关的伪代码接口,负责格式和字节序转换。 */
my_rx_desc_set_addr(desc, dma_addr);
my_rx_desc_set_capacity(desc, size);

// 先发布地址和长度,再交出描述符所有权;具体屏障由设备协议决定。 
dma_wmb();
my_rx_desc_give_to_device(desc);
my_rx_ring_notify_device(rxq);

// 映射信息必须保存:完成后按 DMA API 同步/解除映射并管理生命周期。 

描述符环常用一致性 DMA 内存,包缓冲区常用 streaming DMA 映射。即使描述符内存具有一致性,正确的访问顺序和内存屏障仍然要处理。dma_rmb() 约束顺序,dma_sync_*() 处理映射的访问同步,两者不能互相顶替。

这类误用的典型症状是:驱动在带 IOMMU 的机器上加载后收不到包,dmesg 里出现 DMAR: [DMA Read] Request device [xx:xx.x] fault addr … 或 DMA-API 相关的 mapping error 日志。排查时先确认 dma_map_single 的返回值是否被检查,再用 dmesg | grep -i iommu 看 fault 记录,必要时临时用 intel_iommu=off 启动对比——能收包就基本锁定是映射问题,而非网卡或链路问题。

描述符环不是字节环

RX Ring 通常是描述符组成的环,不是把所有包字节挤在同一个环形数组里。描述符记录缓冲区地址、状态、接收长度等,实际帧内容放在它指向的主机内存。设备可能有片上 FIFO,但那不是 socket 的接收队列。

驱动和设备按约定推进生产、消费位置。驱动源码里常见 next_to_use、next_to_clean 这类变量名,但索引含义和完成队列设计并非所有设备一样。一个大帧可能跨多个描述符,有些设备还把缓冲区投递和完成信息分开维护。所以“一个描述符 = 一个完整包”只能当最简教学模型。读 Intel igb 的 RX 清理代码,能看到跨描述符组帧和缓冲区补充。

// 仅为示意:真实 RX 描述符由网卡硬件规格决定。 
struct rx_desc_demo {
 __le64 dma_addr; // 设备要写入的 DMA 地址 
 __le16 length; // 收到的数据长度 
 __le16 status; // DONE、错误、校验和等状态位 
};

上面这份结构只帮助理解,不是 Linux 内核真实统一定义的结构。

CPU 逐字节从设备寄存器读数据会消耗大量指令和总线访问,高包速下根本撑不住。DMA 把大块传输交给设备和内存系统,CPU 主要做资源管理、协议处理和调度。但 CPU 仍要读描述符、碰报文头、维护引用计数、做路由查找,普通 recv() 通常还要复制有效载荷。所以“有 DMA”不等于“整个收包零拷贝”。某些驱动为了小包效率还会复制报文头或小包内容。判断拷贝成本要问清楚:哪段数据、从哪复制到哪、由谁完成。

中断合并与 NAPI 的两级预算

设备写好数据后要让软件知道。常见方式是硬件中断,PCIe 多队列网卡常用 MSI-X,把不同队列的中断分到不同 CPU。网卡还能做中断合并,累计一些事件或等一段时间再通知,一个中断可能对应多个接收完成项,一个包也未必单独触发一次中断。

硬中断里把 IP、TCP、唤醒进程全干完,别的工作容易被挤没。典型 NAPI 驱动的硬中断处理尽量短:检查中断原因、做设备所需的确认或屏蔽,然后调度 NAPI,把重活留给后续执行上下文。默认配置下 NAPI 主要跑在网络接收软中断上下文,这种上下文不能随意睡眠。选用线程化 NAPI、PREEMPT_RT 等配置时,执行方式会变。

三个东西拆开看:软中断是延后执行机制,网络接收对应 NET_RX_SOFTIRQ;NAPI 提供设备事件处理的组织方式,核心是 napi_struct 与驱动 poll() 回调;ksoftirqd/N 是对应 CPU 的内核线程,在必要时承担没处理完的软中断工作。

典型路径里 net_rx_action() 先处理待轮询的 NAPI 实例,再调驱动 poll 回调,比如 igb_poll()。软中断可能在中断返回等时机执行;如果没完成且受执行时间、重启次数或调度条件约束,后续工作可能由 ksoftirqd 接手。并非每个包都先唤醒一次 ksoftirqd。

/*
教学伪代码:my_*
是设备相关操作,不是 Linux 通用 API。 */
static irqreturn_t my_irq(int irq, void *opaque)
{
 struct my_queue_vector *v = opaque;

 if (!my_irq_is_pending(v))
 return IRQ_NONE; // 共享中断时可能不是本设备的事 

 my_ack_irq(v); // 确认方式取决于硬件 

 if (napi_schedule_prep(&v->napi)) {
 my_mask_queue_irq(v); // 先屏蔽该队列通知,避免调度竞态 
 __napi_schedule(&v->napi); // 将实例交给 NAPI 处理 
 }
 return IRQ_HANDLED;
}

static int my_poll(struct napi_struct *napi, int budget)
{
 struct my_queue_vector *v = container_of(napi, struct my_queue_vector, napi);
 int work;

 my_reclaim_skb_tx(v); // 这里只回收普通 skb 的 TX 完成项 
 if (budget == 0)
 return 0; // 此时不处理 RX,也不调用 complete 

 work = my_clean_rx(v, budget); // RX 最多处理 budget 个包 

 if (work < budget && napi_complete_done(napi, work))
 my_unmask_queue_irq(v); // 成功完成 NAPI 后再恢复队列中断 

 return work; // 返回 budget 表示需要继续轮询 
}

budget 限制的是这一轮 RX 工作量,不是字节数。恰好处理 budget 个包又刚好清空队列时,上面选择返回 budget、等再轮询一次,避免错误宣告完成。实际驱动还要遵守设备的重新使能与竞态处理规则。

按 1 Gbit/s 线速持续发最小帧,每包一次中断的话,每秒大约 148.8 万次中断,光进出中断状态就够呛。NAPI 的思路是低负载靠中断及时响应,负载上来后轮询批量处理,队列处理完再恢复中断。中断合并的调优可用 ethtool -C eth0 rx-usecs 50 rx-frames 32,含义是累计 32 个接收帧或等待 50 微秒后触发一次中断,代价是尾延迟增加约数十微秒;高吞吐场景可适当加大,延迟敏感场景应调小甚至关闭。

这里至少两级限制:驱动 poll 里的 RX budget,和 net_rx_action() 一轮的总包数、时间预算。它们共同服务公平性,避免收包一直霸占 CPU。吞吐、延迟、CPU 占用得一起看。

sk_buff 是处理单据不是字节数组

NAPI 被调度后,驱动 poll 函数检查 RX Ring。看到某个描述符被硬件标记完成,驱动读长度、状态、校验和等元数据,再按自己的 buffer 管理方式把包交给后续网络栈。常见步骤:确认描述符已完成;按设备协议和 DMA API 保证后续读取的描述符和数据有效可见;取接收长度、错误状态、校验和卸载结果、VLAN 标记、RSS 哈希;必要时跨描述符组帧并构造/补充 skb;推进消费位置并及时补缓冲区。

“取”不一定整包复制。驱动可以让 skb 关联已经 DMA 完成的页,也可以复制小包或报文头。Intel igb 里能看到 igb_clean_rx_irq()、igb_build_skb()、igb_construct_skb() 等实现。底线是:原来的页已经交给协议栈持有,就不能不加区分地把同一片内存重新投给网卡覆盖。驱动必须协调引用计数、页片段范围、回收时机和重新映射/同步。

struct sk_buff 主要保存元数据:来源设备、协议类型、报文头位置、校验状态、长度、引用计数、队列组织信息。包字节存在它关联的缓冲区里,不是全部内嵌在 struct sk_buff 中。可以把它理解成一张处理单据:数据在哪、处理到哪一层、接下来谁接手。

成员或访问方式
回答的问题

skb->dev
当前关联哪个网络设备?封装处理时可能变化

skb->protocol
当前设备层载荷属于哪个协议?常见为网络字节序 EtherType

skb->len
从当前数据视图计算,总共有多少有效数据

skb->data_len
其中多少数据位于非线性部分

skb->ip_summed
接收校验和状态

skb_mac_header() 等
MAC、网络层、传输层头分别在哪

skb->truesize
内存记账成本,不等于载荷长度

ip_summed 这个名字容易误导。接收方向的 CHECKSUM_UNNECESSARY 通常表示设备已对相应受支持协议完成有效校验,协议栈可依约利用结果,不是这个包所有校验从此不用管。

线性缓冲区的边界:head 是线性头部起始,data 是当前有效数据起始,skb_tail_pointer(skb) 指向线性有效数据末尾之后,skb_end_pointer(skb) 是线性可用区域结束边界。head 到 data 是 headroom,data 到 tail 是当前线性数据,tail 到 end 是 tailroom。其后还有管理共享数据等信息的 skb_shared_info。源码细节:tail、end 字段类型是 sk_buff_data_t,常见配置下存的是相对 head 的偏移,不能一律当裸指针打印或运算,用辅助函数省得踩坑。

// 内核调试片段:不假设 tail/end 的底层存储一定是指针。 
static void inspect_skb(const struct sk_buff *skb)
{
 const unsigned char *tail = skb_tail_pointer(skb);
 const unsigned char *end = skb_end_pointer(skb);

 pr_debug("len=%u linear=%u data_len=%u headroom=%u\n",
 skb->len, skb_headlen(skb),
 skb->data_len, skb_headroom(skb));

 // 两个指针都位于相应的线性缓冲区中。 
 pr_debug("linear_span=%td tail_to_end=%td\n",
 tail - skb->data, end - tail);
}

对非线性 skb,skb->len 不等于 tail – data,通常有 skb->len = skb_headlen(skb) + skb->data_len。解析头部不能随便强转就读,必要时先 pskb_may_pull(),并在可能改变布局后重新取得头指针。skb_pull() 常用来推进当前 data 视图,去掉一个头部往往只是调整视图和长度,原字节未必立即清零,更不需要每剥一层就复制整包。

从网卡内存到协议栈可处理对象,本质是所有权交接:硬件持有 RX 描述符和 DMA 缓冲区;硬件完成后驱动取回缓冲区权限;驱动构造 skb 交给网络栈;后续协议层、socket 队列、应用读取逐步消费。常见操作包括分配 skb 元数据、关联接收页或填充线性区、设置长度与设备、填校验和信息、记录 RX 队列,再通过 napi_gro_receive() 等入口提交。

GRO 可以把满足条件的同一流报文合并处理,减少上层调用和元数据开销,线上多个帧到上层可能只对应一个更大的 skb。启用驱动模式原生 XDP 时,驱动可能在创建 skb 之前就把数据交给 XDP 程序,XDP_PASS 才继续常规收包,DROP、TX、REDIRECT 走其他路径。generic XDP 则在已有 skb 的路径中运行,别把所有 XDP 都固定画在同一个位置。

链路层核对身份与分发

以太网头提供二层链路地址和载荷类型。典型以太网驱动一般调 eth_type_trans(),记录 MAC 头位置、识别帧类别、推进数据视图、返回协议值。这个动作常常发生在驱动提交 skb 之前,所以章节分层不能机械当成严格函数调用顺序。

// 教学片段:假定 skb 目前从以太网头开始,接收元数据已设置妥当。 
static void my_deliver_ethernet(struct napi_struct *napi,
 struct net_device *dev,
 struct sk_buff *skb)
{
 if (!pskb_may_pull(skb, ETH_HLEN)) {
 kfree_skb(skb); // 连基础以太网头都不完整 
 return;
 }

 skb->protocol = eth_type_trans(skb, dev); // 保存 MAC 位置并识别协议 
 napi_gro_receive(napi, skb); // 交出 skb,之后不能继续随意使用 
}

看到以太网帧别立刻假设下一字节就是 IPv4 头。0x0800 进 IPv4,0x86DD 进 IPv6,0x0806 是 ARP,0x8100/0x88A8 是 VLAN/服务 VLAN,0x8863/0x8864 是 PPPoE 发现/会话。ARP 不走 IPv4 → TCP → socket 这条路;PPPoE 会话里的 IP 也不是直接紧跟普通 Ethernet II 头。

VLAN 有个调试细节:硬件可能剥离帧中标签,把 VLAN ID 等信息放进接收描述符,驱动再写进 skb 元数据。抓包工具可能通过辅助信息重建标签展示。看到原始缓冲区少了四字节,先确认卸载配置。

目的 MAC 是本机,不代表目的 IP 也是本机。一台 Linux 路由器收到的帧,目的 MAC 是路由器接口,里面目的 IP 可能是更远处的服务器。要分开问:二层是否接收这个帧;是否交给 bridge 等二层转发逻辑;如果进本机 IP 栈,路由结果是本地投递还是三层转发。Linux bridge 可以在二层决定转发,不必让每帧都进本机 TCP。

通用接收代码里,Linux 根据 skb 协议、设备、已注册协议处理器分发。源码阅读可从 __netif_receive_skb_core() 一带找起,IPv4 处理器关联 ip_rcv() 等入口。现代内核经常批量提交 skb,5.10 之后 netif_receive_skb_list_internal()、ip_list_rcv() 这类列表路径更常见;RPS 还可能先把工作导向另一个 CPU 的 backlog,其容量由 net.core.netdev_max_backlog 控制,默认值 1000,可用 sysctl -w net.core.netdev_max_backlog=5000 调大。调用栈里找不到单一包入口点,不意味着协议栈被绕过。

网络层决定去向

以 IPv4 为例,先检查版本、头长度、实际可读长度、头部校验和、总长度等基本条件。IPv4 头最少 20 字节,有选项时变长,不能把偏移永久写死为 20。IHL 以 4 字节为单位给出头长度;Total Length 是 IP 数据报总长度,不含以太网头和填充;TTL 限制转发生存范围;Protocol 指明上层协议,TCP 是 6,UDP 是 17。

TTL 不是每被一个内核函数处理一次就减一。普通路由转发路径会检查并递减,正常的本地投递不需要为了收包先减一次。以太网填充不属于 IP 数据报,IP 层依据 IP 总长度处理多余尾部,绝不能把填充字节当 TCP 应用数据。

基本检查通过后,典型代码走到 ip_rcv_finish(),没有可用目的路由信息时做输入路由查找,随后由 dst_input() 调用路由结果关联的输入处理函数。粗分三类:本地路由进 ip_local_deliver();需要转发进 ip_forward();无法接收或转发则丢弃,条件允许时生成相应 ICMP 错误。

路由判断还受网络命名空间、策略路由、接口、标记等上下文影响。“目的地址不是收包接口的 IP”不能直接推出必定转发,本机可能在其他接口持有这个地址。排障时 ip route 不能只看一眼 default,必要时还要看 ip rule、local 表,以及进程所在的网络命名空间。

IPv4 数据报可能因 MTU 等原因被拆成更小分段。分片偏移以 8 字节为单位,重组使用源地址、目的地址、协议号、标识以及内核处理上下文关联。本机接收路径必须先获得完整 IP 数据报,才能按正常方式交给 TCP/UDP——后续分片未必带 TCP/UDP 头,直接按端口号过滤很容易得出错误判断。

v6.12 的 ip_local_deliver() 代码里能看到本地发送前的重组处理。但启用连接跟踪等功能时,Netfilter 的 defrag 逻辑可能更早在 PREROUTING 相应优先级执行,重组并不永远固定发生在同一个函数里。分片缺一部分时可能一直等到超时,重排资源也有上限。

Netfilter 是内核中的 hook 基础设施;iptables、nftables 是与规则配置相关的用户态体系。nftables 用的仍是 Netfilter 基础设施,没有凭空多造一个独立 IP 栈。PREROUTING 在路由检查之前起作用,是因为 DNAT 这类操作可能修改数据包目的地址,后面的路由决策必须依赖修改后的结果。只做转发的设备,数据通常根本不进本机 TCP 接收队列。

传输层找到连接

IP 协议号已经说明数据包该交给 TCP 还是 UDP。到这一层还要再检查头部信息、长度、校验和。TCP 头至少 20 字节,Data Offset 给出实际头长度;UDP 基础头 8 字节。具体校验可能结合网卡提供的卸载状态完成,不能假设每次都由 CPU 对整个载荷重算。

端口不是进程 PID,也不是硬件队列号,它是传输层端点信息的一部分。普通已建立 TCP 连接最常用的定位方式是四元组:源 IP、源端口、目的 IP、目的端口,查找还要考虑网络命名空间、协议、可能的设备绑定等上下文。

IPv4 TCP 关键入口是 tcp_v4_rcv()。它可能利用提前 demux 缓存的信息,也可能做连接表查找;已建立连接、监听 socket、TIME_WAIT 等状态各有分支。监听端口收到 SYN 走建连逻辑,不能把 SYN 当已可读业务数据入队。UDP 要分情况:未 connect() 的普通 UDP socket 往往按本地地址、端口及绑定约束匹配;已 connect() 的 UDP socket 还会约束对端。SO_REUSEPORT、广播与组播可能改变投递对象数量或选择方式。

TCP 交付的是有序字节流。序列号描述字节位置,不是第几个网络包。假设这个方向下一个期望字节是 1000,“你好”的 6 个 UTF-8 字节占据 [1000, 1006),接收并接受这段连续数据后,下一个期望字节变成 1006,没有前面缺口时累计确认号相应推进到 1006。SYN、FIN 各自也消耗一个序列号位置。

容易说反的概念:收到的段的 Sequence Number 表示对端这次发来的数据位于哪个字节区间;ACK Number 表示对端已经确认了我发送方向的哪些连续数据;我回给对端的接收窗口表示我这一侧目前愿意再接收多大的字节范围;拥塞窗口 cwnd 是发送侧根据拥塞控制限制在途数据。TCP 序列号会回绕,内核比较时要用相应的序列号比较方法。

乱序段可以先进入乱序结构,等缺口补齐再推进连续可交付范围;重复段按协议规则处理。找到 socket 才刚拿到正确上下文。以已建立 TCP 连接为例,tcp_v4_do_rcv()、tcp_rcv_established() 及后续逻辑要继续处理状态、序列号、ACK、窗口、数据和内存资源。可接受的连续数据,内核可能把 skb 加到接收队列,也可能与尾部已有 skb 合并;乱序数据走另外的结构。队列里 skb 个数不等于线上原始帧个数。

// 教学伪代码:表达 TCP 入站处理分支,不是原样内核实现。 
if (socket_temporarily_owned_by_user(sk)) {
 // 先暂存,之后由相应 backlog 处理继续推进协议逻辑。 
 defer_to_socket_backlog(sk, skb);
} else if (!tcp_segment_is_acceptable(sk, skb)) {
 // 不同错误可能触发丢弃、ACK、RST 等不同动作,这里合并表示。 
 handle_unacceptable_segment(sk, skb);
} else if (segment_starts_after_receive_gap(sk, skb)) {
 keep_in_out_of_order_structure(sk, skb); // 后面的字节先等等 
} else {
 process_contiguous_tcp_data(sk, skb); // 可能合并队尾,并推进序列状态 
 notify_if_stream_is_readable(sk); // 满足可读条件时再通知等待者 
}

这里的 backlog 是为 socket 并发处理服务的临时队列,不表示进程没来读所以所有新数据都往 backlog 塞。

接收队列与背压

几种常被统称“接收队列”的东西,位置、用途、容量约束都不一样:

队列或结构
所在位置
主要存什么、解决什么问题

RX Ring
驱动与设备之间
描述符及接收完成信息,协调 DMA 缓冲区

每 CPU 网络 backlog
通用网络接收层
经 netif_rx()、RPS 等路径排队的 skb

sk->sk_backlog
单个 socket 内核状态
socket 暂由进程上下文持有时,暂存待继续处理的入站 skb

sk->sk_receive_queue
单个 socket 内核状态
普通 TCP 路径中组织可按序读取的数据 skb

TCP out_of_order_queue
TCP 专用状态
保存尚未填平前面缺口的乱序数据

现代 UDP 还有 udp_sock 中的 reader_queue 等组织方式,用于减少读侧争用。

socket 的“被用户持有”,通常指进程进入内核后通过 lock_sock() 等机制持有 socket 状态,不是进程只要在用户态跑着 socket 就一直归它独占。释放或让出相关所有权时,backlog 中的包会按协议回调继续处理。

TCP 接收缓冲不是无限大。应用读得慢,未处理数据积累,占用内存增加,接收方据此调整向对方通告的接收窗口,限制对方继续发送。窗口可以降到零,对端可能发零窗口探测确认情况。应用读走数据、释放接收资源后,接收方按协议规则主动更新窗口。背压就这样从应用消费速度经内核传到发送端。

# 下面这条“等式”不成立,只能用来提醒自己别这么算。
通告窗口 ≠ SO_RCVBUF配置值 - 已收到的业务载荷字节数

# 真正理解时,还要考虑:
# skb 内存开销、接收缓冲自动调节、窗口缩放、已通告的边界、
# 协议状态,以及具体内核版本的窗口计算策略。

skb->truesize 表示记账成本,小包尤其可能出现业务字节不多、内存占用却不少。接收窗口与内存预算相互关联,但不是同一个量。

TCP 收到可交付数据,满足条件时调用数据就绪通知逻辑,最终关联 sk->sk_data_ready() 等回调。普通 socket 常见回调通知等待队列,让阻塞读取、poll/epoll 等等待者获得继续检查状态的机会。

几个不是一一对应的关系要记牢:来了一个帧,未必新增可读数据,可能只是 ACK、重复段或缺口后面的乱序段;新增可读数据,未必立刻进程切换,目标线程可能本来就在跑;收到多个段,也可能只表现为一次就绪通知,低水位等条件影响通知时机。定位尾延迟时可以拆成数据成为可读之前、线程被唤醒到实际运行、运行后完成读取与解析三段。

TCP 会在一定程度上通过接收窗口管理对方。资源不够时可能直接扔掉新数据,或收回一些乱序数据;未被确认的数据丢失,发送方靠协议机制重传。可靠传输不意味着每个最终抵达本机端的 TCP 段都绝对不会被丢弃。

普通 UDP 没有 TCP 式接收窗口机制。接收内存不足时,新到的数据报可能直接被丢弃;发送方 sendto() 成功不能证明接收应用拿到了它。内核统计可关注 UdpRcvbufErrors 等变化。RX 描述符耗尽、CPU 网络 backlog 满、socket 内存不足属于不同丢包点。net.core.netdev_max_backlog 主要针对相应的软件输入队列,不是所有 NAPI RX Ring 和 socket 接收队列的统一总开关。

队列扩容的边界可以算一笔简化账:

# 简化模型:到达与处理速率均按“包/秒”,容量也按“包”计算。
# 它用于理解持续过载,不模拟真实网卡和 socket 的全部约束。
arrival_pps = 150_000
service_pps = 100_000
free_slots = 1000

fill_seconds = free_slots / (arrival_pps - service_pps)
print(f"约 {fill_seconds * 1000:.2f} ms 后耗尽余量")
# 约 20.00 ms

增加队列能吸收更长时间的突发流量,但只要持续到达速率大于处理速率,最终还是会满,缓冲区加大后排队时间可能更难看。先找出谁消费不过来,再决定容量要不要改。

用户态读取与系统调用

硬件收包处理和应用系统调用是两条最后在 socket 状态上汇合的路径。

用户态 API
典型内核入口关系
主要区别

read(fd, buf, len)
VFS 读路径进入 sock_read_iter()
按文件描述符读取,没有 recv 专用 flags 参数

recv(fd, buf, len, flags)
按体系结构与 libc ABI,经接收系统调用进入通用 socket 收取路径
可以传 MSG_PEEK、MSG_DONTWAIT 等 flags

recvfrom 与 recv 走同一族收取路径,额外回填对端地址;recvmsg 支持分散聚集和辅助数据。无论哪个入口,最终都是在 socket 接收队列上取数据、按需复制到用户缓冲区,并更新窗口与内存记账。

ID:7064

ID:7064

Feng
这位作者很神秘,还没有填写简介。