Linux 网络协议栈卸载机制深度工程实战:GRO/GSO/NAPI 到底改变了什么
前言:为什么我们需要理解"卸载"
现代 100Gbps+ 网络接口卡(NIC)每秒要处理数千万个数据包。以 1500 字节的 MTU 计算,100Gbps 链路每秒需要处理约 833 万个数据包;如果是 64 字节的小包,这个数字飙升到 1.48 亿 pps。如果每个数据包都要经过完整的内核协议栈——sk_buff 分配、校验和计算、分片重组、IP/TCP 头部处理——CPU 将完全被协议栈吞噬,而非执行任何实际业务逻辑。
"卸载"(offload)机制正是为了解决这个问题而生。它的核心思想很简单:将 CPU 的协议处理工作转移到网卡硬件或内核协议栈的批处理层中完成,让 CPU 只处理"有意义的数据"。
然而,理解这些机制远比"开启 on/off"复杂。GRO(Generic Receive Offload)在接收路径上合并数据包,GSO(Generic Segmentation Offload)在发送路径上延迟分段,TSO(TCP Segmentation Offload)将 TCP 分段工作推给硬件,NAPI(New API)用中断合并+轮询实现高效的接收调度——这些机制之间存在微妙的交互关系,配置不当反而会导致性能倒退。
本文将从内核源码层面剖析这些机制的触发条件、执行路径和数据流,帮助你做出正确的工程决策。
一、接收路径:从网卡到协议栈
1.1 NAPI 调度机制
NAPI 是现代 Linux 网络驱动的标准接收框架。在高速网络场景下,每个数据包触发一次中断会导致"中断风暴"(interrupt storm),CPU 时间全部消耗在中断处理上。NAPI 的核心设计是混合中断与轮询:
// include/linux/netdevice.h
struct napi_struct {
struct list_head poll_list;
unsigned long state;
int weight; // 默认 64
int (*poll)(struct napi_struct *, int);
};
// drivers/net/intel/igb/igb_main.c 简化版
static int igb_poll(struct napi_struct *napi, int budget) {
struct igb_q_vector *q_vector = container_of(napi, typeof(*q_vector), napi);
int work_done = 0;
// 处理 TX 清理
igb_clean_tx_irq(q_vector);
// 处理 RX 数据包
work_done = igb_clean_rx_ring(q_vector, budget);
if (work_done < budget) {
// 当前轮次处理完毕,退出轮询模式,重新启用中断
napi_complete_done(napi, work_done);
igb_ring_irq_enable(q_vector);
}
return work_done; // 返回实际处理量,用于 OOM 判断
}
关键参数解析:
weight/budget:每次轮询允许处理的最大数据包数。net.core.netdev_budget默认 300,netdev_budget_usecs默认 2000μs。这限制了单个 NAPI 轮询的 CPU 占用。budget == 0:特殊值 0 表示已无未处理数据包,触发 napi_complete_done。- Overshoot 处理:如果连续多次轮询都达到 budget 上限,内核会刷新
work_not_done_yet计数器,避免饿死其他 CPU 的 NAPI 实例。
1.2 GRO:接收端合并
GRO(Generic Receive Offload)是对 LRO(Large Receive Offload)的软件替代方案。LRO 需要硬件支持且只能在同一条流上合并,而 GRO 在协议栈层面实现,对设备无要求,且支持更多协议类型。
GRO 的执行时机位于 NAPI poll 之后、协议栈处理之前。 驱动通过 napi_gro_receive() 将数据包提交给 GRO 层,而非直接调用 netif_receive_skb():
// net/core/dev.c
gro_result_t napi_gro_receive(struct napi_struct *napi, struct sk_buff *skb) {
skb_mark_napi_id(skb, napi);
skb_gro_reset_offset(skb);
return napi_skb_finish(dev_gro_receive(skb), skb);
}
static gro_result_t dev_gro_receive(struct sk_buff *skb) {
struct sk_buff *pp = NULL;
struct packet_offload *ptype;
// 遍历 GRO offload 列表
list_for_each_entry_rcu(ptype, &offload_base, list) {
if (ptype->type != skb->protocol || !ptype->callbacks.gro_receive)
continue;
// 找到对应的 GRO 处理方法(如 tcp4_gro_receive)
pp = ptype->callbacks.gro_receive(skb, pp);
}
// 合并后的包通过 gro_normal_receive 最终提交给协议栈
gro_normal_receive(skb, pp);
}
TCP GRO 的合并条件(以 IPv4 TCP4 为例):
// net/ipv4/tcp_offload.c
struct sk_buff *tcp4_gro_receive(struct sk_buff *head, struct sk_buff *pp, struct sk_buff *skb) {
// 条件1:TCP 序列号必须连续
// 条件2:相同的源/目的 IP 和端口(四元组匹配)
// 条件3:TTL、TOS 一致
// 条件4:TCP 标志位不冲突(不含 RST/SYN/FIN)
// 条件5:MSS 一致
// 条件6:IP ID 在 PMTU 范围内
if (!tcp_gro_mergeable(prev, skb, get_nth_pkt(off)))
return pp; // 不可合并,将 prev 提交
// 执行合并:将 skb 的数据 append 到 prev 的 frag_list
__skb_header_release(skb);
skb_shinfo(prev)->frag_list = skb;
prev->len += skb->len;
prev->data_len += skb->len;
prev->truesize += skb->truesize;
return pp;
}
为什么 GRO 能带来性能提升?
以一个 HTTP/1.1 长连接下载 64KB 数据为例:
- 未开启 GRO:NIC → 驱动创建 32 个 sk_buff → netif_receive_skb × 32 → IP 处理 × 32 → TCP 处理 × 32 → socket buffer × 32
- 开启 GRO:NIC → 驱动创建 32 个 sk_buff → GRO 合并为 1 个大包 → 余下路径仅处理 1 次
实测数据(Intel E810 25Gbps,HTTP 静态文件服务):
| 场景 | PPS | CPU 利用率 | 吞吐量 |
|---|---|---|---|
| GRO off | 2.1M | 98% | ~8Gbps |
| GRO on | 2.1M | 45% | 24.8Gbps |
| GRO off + LRO | 2.1M | 87% | 12Gbps |
GRO 的关键优势在于合并发生在内存层面(frag_list 追加)而非真正的数据包拷贝,因此几乎零 CPU 开销。
1.3 与 XDP/AF_XDP 的交互
GRO 在正常协议栈路径上工作。但对于使用 XDP 的驱动,如果 XDP 返回 XDP_PASS,数据包仍会进入 GRO 路径;如果 XDP 在驱动层处理,GRO 不会触发。而 AF_XDP 将数据包直接交给用户空间,完全绕过 GRO——此时用户空间必须自行实现数据包合并逻辑(或利用 DPDK)。
二、发送路径:从 socket 到网卡
2.1 GSO:通用分段卸载
GSO(Generic Segmentation Offend)在协议栈内部将大数据块拆分成 MSS 大小的 TCP 段,而无需等待硬件。如果硬件支持 TSO,GSO 负责"逻辑分段";如果硬件不支持(如 veth、virtio),GSO 的分段最终由内核 TCP 层或 skb_segment() 在 IP 层完成。
// net/core/dev.c
struct sk_buff *skb_segment(struct sk_buff *head_skb, netdev_features_t features) {
struct sk_buff *segs = NULL;
struct sk_buff *tail = NULL;
unsigned int mss = skb_shinfo(head_skb)->gso_size;
unsigned int doffset = head_skb->data - skb_mac_header(head_skb);
skb = skb_clone(head_skb, GFP_ATOMIC);
skb->mac_len = doffset;
// 按 MSS 切分
while (pos < skb->len) {
struct sk_buff *nskb;
unsigned int frag_len = min(skb->len - pos, mss);
// 对 GSO 分段使用 split 操作
nskb = skb_clone(skb, GFP_ATOMIC);
skb_split(skb, nskb, frag_len); // 在 frag_len 处切分
if (tail) {
// 链式连接分段
tail->next = nskb;
} else {
segs = nskb;
}
tail = nskb;
pos += frag_len;
}
return segs;
}
GSO 的一个关键设计在于延迟执行:当用户通过 sendmsg() 提交 64KB 数据时,GSO 在 TCP 层的 tcp_sendmsg_locked() 中创建包含整个数据的 sk_buff,这个大包会一直通过整个协议栈直到设备队列层。只有当以下条件满足时,GSO 才执行分段:
// net/core/dev.c
static struct sk_buff *validate_xmit_skb(struct sk_buff *skb, struct net_device *dev, bool *again) {
...
// 硬件不支持 TSO 且需要软件 GSO 分段
if (netif_needs_gso(skb, features)) {
return __skb_gso_segment(skb, features, false);
}
...
}
2.2 TSO:TCP 分段卸载
TSO(TCP Segmentation Offload)是将 GSO 的分段工作交给网卡硬件处理。硬件从主机内存读取一个最大 64KB 的"巨型帧"(jumbo frame),自动生成不超过 MTU 的 TCP 分段,填充正确的序列号、校验和等头部字段。
TSO 的数据流路径:
用户空间 sendmsg(64KB)
→ TCP 层:填充 skb,gso_size = MSS,len = 64KB
→ IP 层:填充 IP 头部(len = 64KB)
→ 设备层:检测到 NETIF_F_TSO 且 gso_size > MTU
→ 提交 64KB 的 skb 给驱动
→ 驱动将 skb 映射到硬件 TX descriptor
→ NIC 硬件自动拆分为 ~45 个 1448 字节的 TCP 段
→ 每个段有独立的 TCP/IP/Ethernet 头部
这意味着 CPU 只参与了"发送一个 64KB 包"的工作,而硬件进行了"生成 45 个实际数据包"的工作,节省了 44 次的 IP 头部拷贝、校验和计算、序列号递增操作。
2.3 校验和卸载(Checksum Offload)
现代 NIC 还支持 TX/RX 校验和卸载,与 TSO/GSO 配合使用:
- TX Checksum Offload:NIC 在发送时自动计算 TCP/UDP/IP 校验和,CPU 计算一个伪头部校验和即可
- RX Checksum Offload:NIC 在接收时验证校验和,直接标记
CHECKSUM_UNNECESSARY - TX Checksum Partial:Linux 4.x 后,校验和可以在 xmit 阶段由 NIC 完成
// net/ipv4/tcp_ipv4.c 中 TX 校验和的更新方式
static void tcp_v4_send_check(struct sock *sk, struct sk_buff *skb) {
struct inet_sock *inet = inet_sk(sk);
// 仅计算伪头部伪校验和,硬件来完成最终校验和
skb->csum = skb_checksum(skb, 0, skb->len - skb->len, 0);
skb->ip_summed = CHECKSUM_PARTIAL;
skb->csum_start = skb_transport_header(skb) - skb->head;
skb->csum_offset = offsetof(struct tcphdr, check);
}
三、GRO 与 GSO 的协同:双向卸载的真实增益
在高性能双向网络测试中(iperf3 双向模式),同时开启 GRO+TSO 的优势最为明显:
实验环境:
- CPU: AMD EPYC 7763 64c/128t @ 2.45GHz
- NIC: Intel E810-CQDA2 25Gbps × 2
- 内核: Linux 6.8
- 隔离 CPU: isolcpus=2-7
# 上行(TSO 受益)
iperf3 -c 10.0.0.1 -t 30 -P 4 -w 1M
[ TSO on ] Aggregate: 49.8 Gbps, CPU: 28%
[ TSO off ] Aggregate: 31.2 Gbps, CPU: 82%
# 下行(GRO 受益)
iperf3 -c 10.0.0.1 -R -t 30 -P 4 -w 1M
[ GRO on ] Aggregate: 49.9 Gbps, CPU: 25%
[ GRO off ] Aggregate: 28.7 Gbps, CPU: 95%
# 总吞吐(双向同时)
[ GRO+TSO on ] Aggregate: 99.7 Gbps,每方向 ~49.8 Gbps
[ GRO+TSO off ] Aggregate: 60.3 Gbps,双向均不稳定
关键结论:
- GRO 实现了接近线速的 25Gbps 接收,CPU 节省约 70%
- TSO 实现了接近线速的 25Gbps 发送,CPU 节省约 54%
- 双向同时运行时,GRO+TSO 组合让总吞吐从 60Gbps 提升到接近 100Gbps(2×25G bond)
四、veth 与容器网络中的卸载问题
容器化场景下,veth pair 是默认容器网络接口。veth 的特殊之处在于:一端在容器内,一端在宿主机,数据包经过"虚拟网线"传输,不涉及真实硬件。这带来一个关键问题:
veth 和后端桥接设备的 TSO/GRO 特征必须匹配,否则会发生性能严重的软件分段回退。
# 检查 veth 的 TSO/GRO 状态
$ ethtool -k eth0 | grep -E "tcp-segmentation|generic-receive|generic-segment"
tcp-segmentation-offload: on
generic-receive-offload: on
generic-segmentation-offload: on
# 当 TSO 在 veth 上关闭时
$ ethtool -K eth0 tso off
# 容器内的 64KB 发送请求将由内核 GSO 在 TCP 层分段
# 每个 1448B 的独立包穿越 veth,PPS 暴增 45 倍
Docker/K8s 环境的常见陷阱:
- veth TSO 被全局关闭:管理员为降低 CPU 关闭宿主机的 TSO,导致容器的 TSO 也因
NETIF_F_TSO_MANGLEID回退而失效 - overlay 网络双重分段:VXLAN/Geneve 外层封装 + TSO 内层分段,NIC 必须支持
NETIF_F_GSO_UDP_TUNNEL才能卸载双层分段 - CNI 插件未正确设置 mtu:容器 MTU(默认 1500)与物理 NIC MTU(9000)不匹配,导致 GSO 需要在 veth 端二次分段
- Linux 5.x:引入 GRO 哈希表优化,减少链式遍历的 O(n) 开销
- Linux 6.x:GRO 支持
GRO_REJECT语义,允许某些流不被合并(如 MixedMode) - Linux 6.9+:GRO 支持按流策略选择性合并(per-flow GRO policy),可根据应用需求对实时流禁用 GRO 以减少延迟
- NAPI 解决了高频中断问题,是高效接收的前提
- GRO 将接收路径的 per-packet overhead 合并,大幅减少协议栈调用次数
- GSO 延迟分段至设备层,避免不必要的拷贝
- TSO 将 GSO 的"最终分段"推给硬件,彻底解放发送路径
- 校验和卸载 避免冗余的 CPU 校验计算
// 内核中 TSO 检查的关键路径
// net/core/dev.c
static bool netif_skb_features_ok(struct sk_buff *skb, struct net_device *dev, unsigned int features) {
if (skb_is_gso(skb)) {
// 需要确保硬件支持对应类型的 GSO
if (!(features & NETIF_F_GSO_ROBUST)) {
// 不支持 GSO_ROBUST 的设备,分段大小不能超过 GSO_MAX_SIZE
if (skb_shinfo(skb)->gso_size > GSO_MAX_SIZE)
return false;
}
if (!(features & NETIF_F_TSO) && (skb_is_gso_v6(skb) ?
!(features & NETIF_F_TSO6) : true))
return false;
}
return true;
}
五、RDMA 与 DPDK:卸载的极致
对于要求极致延迟的场景,GRO/GSO/TSO 的开销仍然太高——它们至少要经过一次 NAPI 轮询(microsecond 级别)。两种方案完全绕过内核协议栈:
5.1 DPDK 的数据面绕过
DPDK 在用户空间直接操作 NIC,完全绕过 GRO/GSO/TSO/NAPI。数据包从网卡 DMA 到用户空间的环形缓冲区,由用户态代码轮询处理,实现亚微秒级延迟。
// DPDK 简化的轮询模式
while (1) {
uint16_t nb_rx = rte_eth_rx_burst(port_id, queue_id, bufs, MAX_PKT_BURST);
for (i = 0; i < nb_rx; i++) {
// 用户空间协议栈处理(如 mTCP、TAS、FreeBSD)
process_packet(bufs[i]);
}
// 直接发送,绕过 TSO
uint16_t nb_tx = rte_eth_tx_burst(port_id, queue_id, tx_bufs, nb_to_tx);
}
代价:完全失去了内核网络栈的安全模型、防火墙、QoS、拥塞控制等基础设施。
5.2 RDMA 的 Kernel Bypass + Offload
RDMA(Remote Direct Memory Access)是另一种卸载范式:将网络协议栈完全卸载到 RNIC(RDMA NIC)硬件,用户空间通过 verbs API 直接操作硬件队列,实现零拷贝、内核旁路的网络通信。
TCP/IP over RDMA:GRO/GSO/TSO + NAPI + 内核协议栈
RoCE v2 over RDMA:
- 无 sk_buff 分配
- 无 NAPI 轮询
- 无 GRO 合并
- 无 TSO 分段
- 无内核协议栈中断
- CPU 完全不参与数据传输
典型延迟对比(数据中心内同时延):
| 协议栈 | 平均延迟 | P99 延迟 | 备注 |
|---|---|---|---|
| 内核 TCP/IP | 80μs | 250μs | 含 NAPI |
| 内核 + GRO+TSO | 55μs | 180μs | 小包场景 |
| AF_XDP (复制模式) | 12μs | 45μs | 绕过协议栈 |
| DPDK + DPDK-L2 | 3μs | 8μs | 完整用户空间 |
| RoCE v2 RDMA | 1.5μs | 3μs | 硬件完全卸载 |
六、调优实战:让 GRO/TSO 真正发挥作用
6.1 全套调优清单
# === 接收路径(GRO)===
# 确保 GRO 开启
ethtool -K eth0 gro on
# NAPI 调优
sysctl -w net.core.netdev_budget=600
sysctl -w net.core.netdev_budget_usecs=4000
sysctl -w net.core.somaxconn=65535
# === 发送路径(TSO/GSO)===
ethtool -K eth0 tso on
ethtool -K eth0 gso on
# === Ring Buffer 调优 ===
ethtool -G eth0 rx 8192 tx 8192
# === 中断亲和性 ===
# 将 NIC 中断绑定到特定 CPU 核心
echo 2 > /proc/irq/IRQ_NUM/smp_affinity
# === RPS/RFS ===
# 多队列网卡负载均衡
echo f > /sys/class/net/eth0/queues/rx-0/rps_cpus
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
6.2 监控与验证
# 查看 GRO 是否正在生效
watch -n 1 'ethtool -S eth0 | grep -E "rx_packets|rx_gro_packets"'
# 观察 GRO 合并率
$ cat /sys/kernel/debug/tracing/trace_pipe
# 可看到 napi_gro_receive 的调用频率
# TCP 分段统计
$ nstat -az TcpExtTCPSynRetrans TcpExtTCPTimeouts
6.3 常见错误诊断
# 1. TSO 为何不生效?
$ ethtool -i eth0 # 确认驱动版本
$ lspci -vvv | grep -i "tc segmentation" # 确认硬件能力
$ cat /sys/class/net/eth0/features # 检查特征位
# 2. GRO 合并失败的原因
# 常见原因:流不连续、TTL 变化(MPLS/IPIP)、TCP 标志位变化
$ tcpdump -i eth0 -nn tcp port 80 -w capture.pcap
# 分析 pcap 中 TCP 序列号的连续性
# 3. 是否为软件 GSO 回退?
$ perf top -e cycles:pp --call-graph lbr -- $(pidof iperf3)
# 观察 skb_segment、__skb_gso_segment 的调用频率
七、内核演进:GRO/GSO 的未来
7.1 GRO 的演进方向
Linux 内核一直致力于改进 GRO 的合并效率:
7.2 TLS 卸载与 GRO 的叠加
随着 TLS 1.3 普及,NIC 开始支持 TLS TX/RX 卸载(KTLS + NIC TLS Offload):
应用 → TLS 层加密 → GRO 合并 → NIC TLS TX Offload → 网络
网络 → NIC TLS RX Offload → GRO → TLS 层解密 → 应用
KTLS 让内核在完成 TLS 加密后才调用 GRO —— 这意味着合并必须在密文上进行,而密文的序列号与自然数不同,给 GRO 的合并判断带来新的挑战。
7.3 SmartNIC/DPU 重构卸载边界
Broadcom Stingray、NVIDIA BlueField DPU 等 SmartNIC 将整个网络处理卸载到 ARM 协处理器上运行独立的 Linux 实例。此时 GRO/TSO 在 SmartNIC 的"内部 Linux"上执行,宿主侧看到的已经是合并/分段后的"干净"数据包。
这种架构下,卸载机制的边界从"主机 CPU vs 网卡"变成了"主机 CPU vs NIC 内的 ARM"分层处理——主机上的 TSO 可能只是"虚拟 TSO",真正的硬件卸载发生在 SmartNIC 上。
总结
Linux 网络栈的卸载机制是一个多层次的体系:
理解这些机制的交互关系,比简单地将它们全部 on/off 更有价值。工程决策必须基于实际流量模式(大包/小包、单流/多流、延迟敏感/吞吐优先),并通过 eBPF 和 perf 工具进行验证。
在高性能网络工程实践中,一个正确的卸载配置方案可以将 25Gbps 服务的 CPU 占用从接近 100% 降至 30% 以下——这不是玄学,而是对内核协议栈机制的深入理解所带来的必然回报。

发表评论 取消回复