操作系统内功 · 第 27 篇|sk_buff:一个包在内核网络栈里的旅行成本

sk_buff 是什么:内核网络栈的通用货币

第 14 篇画过一个包从网卡 DMA 到 epoll 返回的全景,第 26 篇讲了这段路在哪个核上跑。本篇解剖这条路上被搬来搬去的那件行李:sk_buff(简称 skb)——内核里表示一个网络包的通用结构。从驱动收包到协议栈逐层解析,再到 socket 队列,包的内容与元数据都装在 skb 里;理解它,网络栈的一切开销(拷贝几次、分配几次、检查什么)就有了具体的形状。

skb 的设计要同时满足两类需求:数据本体(以太网帧/IP 包,大小从几十字节到 64KB)与层层元数据(各协议层的头指针、校验状态、路由缓存、时间戳)。它把两者装进一个精心布局的内存块。

结构解剖:线性数据区与四个指针

skb 的核心是一段连续的「线性数据区」加四个移动指针:

+------+--------+----------+---------+--------+------+
| head | data   |          | tail    | end    |      |
|  ↓   |   ↓    |  payload |    ↓    |   ↓    |      |
+------+--------+----------+---------+--------+------+
 headroom | 协议头们(被逐层剥掉)| 数据    | tailroom
  • head/end:数据区的物理边界(分配时固定)。
  • data/tail:当前有效数据的逻辑边界,随解析推进而移动。
  • headroom 是留给协议头的:收包方向上,MAC 头、IP 头、TCP 头依次被解析,skb_pull 把 data 指针后移「剥头」;发包方向相反,skb_push 把 data 前移「戴帽」。整个收发路径没有任何 memcpy 发生在协议头解析上——只是指针移动。
  • skb_reserve 在分配时预留 headroom,正是为了保证收到包后各层协议头有地方「戴回去」。

这个设计的精妙处:一个包在协议栈里旅行,数据本体一步都不动,动的全是描述符。协议栈的分层(L2 → L3 → L4 → socket)在 skb 上表现为指针的序贯推进与各层 skb->transport_header/network_header/mac_header 的记录——分层架构的性能代价被压缩到接近零。

分片与聚合:skb 的组装形态

超过 MTU(通常 1500 字节)的包需要拆分,skb 用分片列表(frag_list / frags)支持非线性数据:主体之外挂多个页片段(skb_shinfo(skb)->frags),每片可以指向独立分配的页。这个设计有两个高阶应用:

GRO(generic receive offload):NAPI 轮询收包时,把同一条流的多个小包聚合成一个超大 skb再交给协议栈——协议栈按大包处理一次,而不是按 1500 字节循环多次。小包率高的负载(10G+ 线速的小包)GRO 能省一半以上的协议栈 CPU。ethtool -k eth0 里 generic-receive-offload: on 就是它。

TSO(tcp segmentation offload):发送方向的镜像——协议栈构造一个 64KB 的「伪大包」,由网卡硬件负责切成 MTU 大小并算校验和。CPU 只处理一次大包逻辑,切包的体力活卸载给硬件。GRO/TSO 合起来是「攒大包摊薄每包固定成本」思想的硬件实现——与第 15 篇的批量化 syscall、第 13 篇的 io_uring 攒批是同一个工程哲学在网络栈的投影。

clone 与 copy:共享与复制的边界

skb 在路径上经常需要「多个人看同一份数据」:协议栈分析时 tcpdump 要抓包、netfilter 要检查、抓包模块要复制。skb 提供两个层次的复制:

  • skb_clone:复制 skb 结构体本身(元数据),共享线性数据区与分片页。clone 与原始 skb 各自持有引用计数,读数据安全,改数据不安全。抓包(packet socket)几乎全用 clone——零拷贝地旁观。
  • skb_copy:深拷贝,数据也复制一份。改写前的必要动作(如 netfilter 的 NAT 改写地址端口前的 skb_ensure_writable)。

对应到工程观测:tcpdump 抓包对吞吐的影响之所以「比想象的小」,是因为它只 clone;而 conntrack(连接跟踪,第 28 篇的主角)需要改写包内容,走的必然是写路径——成本类别完全不同。

一次收发的成本账:内存在哪、拷贝几回

把第 14 篇的全景图与 skb 的解剖合并,一次典型收包的内存动作清单:

  1. 驱动分配 skb:从 per-CPU 的 slab 缓存拿(第 3 篇的 SLUB),通常还有驱动维护的 skb 池——热路径上 skb 分配几乎无锁。
  2. DMA 写入数据区:网卡直接把包内容 DMA 到 skb 预留的缓冲(或独立的页片段),CPU 不碰数据。
  3. 协议栈解析:零拷贝,纯指针移动(上一节的「剥头」)。
  4. socket 队列入队:skb 挂进接收队列(sk->sk_receive_queue),epoll 唤醒应用。
  5. recv 时的一次拷贝:skb 数据 → 用户缓冲区。这是收包路径上 CPU 参与数据的唯一一次搬运。
  6. skb 释放:回到 slab/池。

结论:常规收包 = 1 次拷贝 + 多次指针操作。sendfile/Splice(第 6 篇)能把这次拷贝也省掉;XDP/AF_XDP(第 9 篇)更进一步把「skb 的分配与释放」本身也绕开(在驱动层用独立的帧缓冲环处理)——那是内核旁路的终极形态。每层优化省的都是同一笔账的不同科目:数据拷贝、元数据开销、分配成本。

观测 skb 行为的入口

# 每秒收发包数与字节数(skb 流量水位)
ip -s -s link show eth0
# 协议栈丢包统计:skb 在哪一步被丢
netstat -s | grep -iE 'drop|prune|collapse'
# socket 接收队列积压(skb 堆在应用没读的队列里)
ss -tm   # Recv-Q 与 skmem 详情
# GRO/TSO/GSO 开关状态
ethtool -k eth0 | grep -E 'offload|segmentation'

ss -tm 的 skmem 一行值得专门解释:skmem:(r0,rb212992,t0,tb212992,...) 展示该 socket 的接收队列当前 skb 数与缓冲上限——应用读得慢,skb 就在内核队列里堆积,堆满后新包被丢(netstat -s 的 prune/collapse 计数就是队列挤兑的痕迹)。高并发服务「吞吐上不去、CPU 却不忙」的另一个隐藏病因就在这:瓶颈不是 CPU 而是单 socket 的队列与内存预算(net.core.rmem_max 等)。

AF_XDP 与零拷贝套接字:绕开 skb 的应用层通道

XDP 把包留在驱动层做决策,AF_XDP 则把包直接递给应用:内核为 socket 分配一组 UMEM 内存池(应用与驱动共享的帧缓冲环),符合条件的包按 XDP 程序的 REDIRECT 判决进入 AF_XDP 环,应用从环里直接读包——整条路径上没有 skb、没有协议栈、没有系统调用级别的数据拷贝(收包方向只有 DMA 写入 UMEM 这一次内存动作)。这是「内核旁路」与「留在内核」之间的中间路线:比 DPDK 温和(不需要整卡独占,可以按流分流——XDP 程序把目标流量 REDIRECT 给 AF_XDP socket,其余照走内核栈),比原始 socket 快一个量级(每包成本从微秒级降到百纳秒级)。适用边界同样清晰:它给你的只是「原始以太网帧」,L3/L4 及以上的协议处理全部自己来——所以现实中的 AF_XDP 用户基本是自研协议栈的项目(高性能 DNS 服务器、自定义传输协议、DPDK 的替代品探索)。gVisor 的网络栈、某些 QUIC 加速器、云厂商的虚拟交换组件是它活跃的领域。给后端工程师的务实结论:日常服务永远不会直接用 AF_XDP,但当你的网关「每包预算」被算到极致、且瓶颈证明在 skb 与协议栈的固定开销时,AF_XDP 加 XDP 分流是比「全部换 DPDK」低一个量级改造成本的选项——知道这条中间路线存在,架构选型时就少一个「要么忍要么掀桌」的假两难。

每包成本预算表:从内核栈到 XDP 的阶梯

把本篇与前三章的知识折算成一张「每包成本」预算表,量级感受比精确数字更有用。常规内核栈收包(skbuf 路径):硬中断加 NAPI 加协议栈加一次用户拷贝,每包约 2-5 微秒(小包、含缓存未命中的波动)——单核上限约 0.3-0.5 Mpps,这也是「普通 Linux 网关单核吞吐」的量级解释。XDP native 路径:驱动层 eBPF 判决,跳过 skb 分配与协议栈,每包约 100-200 纳秒——单核 5-10 Mpps,DDoS 清洗与 L4LB 的选择。AF_XDP 应用层收包:XDP REDIRECT 加 UMEM 环,每包约 200-400 纳秒(加了用户态取包的环节)——单核 3-5 Mpps,自研协议栈的地基。DPDK 全旁路:轮询加自管驱动,每包约 50-100 纳秒——单核 10-20 Mpps,代价是独占 CPU 与网卡。四个档位相差近两个数量级,但每一档都在为「跳过哪段固定成本」付费:内核栈为通用性付费( skb、分层、可观测),XDP 为「内核内的快路径」付费(驱动兼容性、验证器门槛),AF_XDP/DPDK 为「完全控制」付费(自建协议栈、运维复杂度)。预算表的使用方式:先测出业务真实需要的每秒包数与可用核数,倒推出单核需要的每包预算,再选档——需要 5 Mpps 就不必上 DPDK,XDP 或纯调优(RSS 加 GRO 加队列绑核)可能已经够。量级匹配胜过技术炫技。

skb 的内存压力面:EMERGENCY pool 与包内存的耗尽形态

skb 体系有自己的内存耗尽形态,与第 20 篇的 OOM 是并行的两套机制。内核为网络包预留了独立的内存预算(/proc/sys/net/core/ 下的一组参数加 per-socket 的 rmem/wmem),包内存耗尽时不触发全局 OOM,而是协议栈选择性丢包——UDP 的丢包计数增长、TCP 触发零窗口与重传、accept 队列溢出(TCPBacklogDrop 计数)。这个设计的合理性在于丢包是网络的正常语义(上层有重传与重试),比杀进程温和得多;代价是故障形态隐蔽——「内存没满、CPU 不高、但流量掉」的组合,真相常是某个 socket 的接收队列吃满了内核给它的缓冲配额(net.core.rmem_max 或应用没设 SO_RCVBUF 时走默认值)。排查入口三层:ss -tm 的 skmem 行看单 socket 的缓冲占用与配额差;netstat -s 的 prune/collapse/RcvbufErrors 计数看全局挤兑;/proc/net/protocols 看每种协议的总体内存占用。应用侧的防御:高吞吐接收端显式设置 SO_RCVBUF 并监控队列深度(把 Recv-Q 纳入告警),消费速度与队列水位的联动治理(背压)比事后调大缓冲有效——缓冲只是把丢包延迟成超时,消费能力才是根。包内存与用户内存的这套双轨制,是网络子系统「用有界资源处理无界流量」哲学的完整表达。

小结

sk_buff 用「线性数据区 + 移动指针」的结构让包在协议栈里旅行时数据本体零拷贝——分层架构的成本被压缩为指针推进。clone/copy 的区分支撑了 tcpdump 的零拷贝旁观与 netfilter 的写时保证;GRO/TSO 用攒大包摊薄每包固定成本,与 syscall 批量化、io_uring 攒批共享同一哲学。一次常规收包的真实账单是「1 次用户态拷贝 + 多次指针操作」,sendfile、XDP 逐层省掉的是同一笔账的不同科目。下一篇看在这条路上设卡收费的模块:netfilter 与它的五条链。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?