一个 10Gbps 网口每秒要处理约 812 万个 1500
字节包。如果每个包独立走一遍协议栈——分配
sk_buff、查路由、过 Netfilter、递交 socket——CPU
根本跟不上。内核的解决思路是批量化:收包时把多个小包”聚合”成一个大包再递交协议栈(GRO),发包时把一个大包”延迟”到尽可能晚才拆分成小包(GSO/TSO)。这两个方向的卸载机制极大减少了协议栈的遍历次数,是现代
Linux 网络性能的基石。
但卸载不是免费午餐。GRO 聚合后的超大包穿过 Netfilter 时,conntrack 看到的是一个”假包”;GSO 延迟分段意味着 TC qdisc 对单个巨大包做整形而非真实小包;隧道设备的二次封装让 GRO/GSO 的 offload 路径更加复杂。理解这些机制的内核实现,才能在性能调优和故障排查中避开陷阱。
本文基于 Linux 6.6/6.8 源码,从数据结构、函数调用链、可观测手段三个维度全面拆解分段卸载。
一、核心数据结构
分段卸载的所有元数据都存在 sk_buff 尾部的
skb_shared_info
中。理解这个结构是后续所有分析的基础。
1.1 skb_shared_info 中的 GSO 字段
// include/linux/skbuff.h
struct skb_shared_info {
// ... 其他字段
unsigned short gso_size; /* 每个分段的 MSS(不含头部) */
unsigned short gso_segs; /* 分段数量 */
unsigned int gso_type; /* SKB_GSO_* 类型位图 */
struct sk_buff *frag_list; /* GRO 聚合链 / GSO 分段链 */
skb_frag_t frags[]; /* page fragment 数组 */
};gso_size 和 gso_segs
共同描述这个 sk_buff
“代表”多少个真实网络包:
- GRO
方向:
napi_gro_receive()把多个小包聚合后,设置gso_size = 原始 MSS、gso_segs = 聚合包数 - GSO/TSO
方向:
tcp_write_xmit()构建一个大 skb 时,设置gso_size = MSS、gso_segs = 数据长度/MSS
1.2 SKB_GSO_* 类型枚举
// include/linux/skbuff.h
enum {
SKB_GSO_TCPV4 = 1 << 0, /* TCP/IPv4 分段 */
SKB_GSO_DODGY = 1 << 1, /* 不可信来源(虚拟设备) */
SKB_GSO_TCP_ECN = 1 << 2, /* ECN 感知 */
SKB_GSO_TCP_FIXEDID = 1 << 3, /* 固定 IP ID */
SKB_GSO_TCPV6 = 1 << 4, /* TCP/IPv6 分段 */
SKB_GSO_FCOE = 1 << 5, /* FCoE */
SKB_GSO_GRE = 1 << 6, /* GRE 隧道 */
SKB_GSO_GRE_CSUM = 1 << 7, /* GRE + 校验和 */
SKB_GSO_IPXIP4 = 1 << 8, /* IP-in-IPv4 */
SKB_GSO_IPXIP6 = 1 << 9, /* IP-in-IPv6 */
SKB_GSO_UDP_TUNNEL = 1 << 10, /* UDP 隧道(VXLAN 等) */
SKB_GSO_UDP_TUNNEL_CSUM = 1 << 11,
SKB_GSO_PARTIAL = 1 << 12, /* 硬件只分段内层 L4 */
SKB_GSO_TUNNEL_REMCSUM = 1 << 13,
SKB_GSO_SCTP = 1 << 14, /* SCTP 分段 */
SKB_GSO_ESP = 1 << 15, /* IPsec ESP */
SKB_GSO_UDP_L4 = 1 << 16, /* UDP 载荷分段 */
SKB_GSO_FRAGLIST = 1 << 17, /* frag_list 聚合 */
};这个枚举与 NETIF_F_GSO_*
特性标志一一对应,内核在编译时用 BUILD_BUG_ON()
强制校验二者的位偏移一致。
1.3 NETIF_F_* 特性协商
// include/linux/netdev_features.h
// 设备能力标志(部分)
NETIF_F_TSO // TCP IPv4 分段
NETIF_F_TSO6 // TCP IPv6 分段
NETIF_F_TSO_ECN // ECN 感知 TSO
NETIF_F_GSO // 软件 GSO
NETIF_F_GRO // 软件 GRO
NETIF_F_GRO_HW // 硬件 GRO
NETIF_F_LRO // Large Receive Offload(已废弃)
NETIF_F_GSO_PARTIAL // 部分硬件卸载
NETIF_F_GSO_UDP_L4 // UDP 载荷 GSO设备通过三组标志管理 offload 能力:
| 字段 | 含义 |
|---|---|
dev->hw_features |
硬件支持的能力(只读) |
dev->features |
当前生效的能力 |
dev->wanted_features |
用户期望(ethtool -K 设置) |
生效能力 =
wanted_features & hw_features,再经过
ndo_fix_features()
驱动回调修正。例如,开启转发时驱动会自动关闭 LRO。
二、GRO:收包聚合引擎
GRO(Generic Receive Offload)在 NAPI
轮询阶段将同一条流的多个小包聚合为一个大
sk_buff,减少协议栈遍历次数。
2.1 GRO 入口与哈希表
驱动调用 napi_gro_receive() 将 skb 送入 GRO
引擎:
// include/net/gro.h / net/core/gro.c
gro_result_t napi_gro_receive(struct napi_struct *napi,
struct sk_buff *skb)
{
// 1. 计算 skb 哈希
// 2. 在 napi->gro_hash[bucket] 中查找同流 skb
// 3. 找到 → skb_gro_receive() 聚合
// 4. 未找到 → 新建链表项
// 5. 超过 MAX_GRO_SKBS → flush 最旧的
}Linux 6.x 用 8
桶哈希表(GRO_HASH_BUCKETS = 8)替代了早期的单链表,将流匹配从
O(n) 优化到 O(1):
// include/linux/netdevice.h
#define GRO_HASH_BUCKETS 8
struct gro_list {
struct list_head list;
int count;
};
struct napi_struct {
// ...
struct gro_list gro_hash[GRO_HASH_BUCKETS];
// ...
};2.2 napi_gro_cb:每包 GRO 元数据
每个进入 GRO 的 skb 都携带一个控制块,存储在
skb->cb[] 空间中:
// include/net/gro.h
struct napi_gro_cb {
void *frag0; /* 第一个 fragment 的虚拟地址 */
unsigned int frag0_len; /* frag0 可用长度 */
int data_offset; /* 当前层的数据偏移 */
u16 flush; /* 非零则不聚合,直接递交 */
u16 flush_id; /* IP ID 连续性校验 */
u16 count; /* 已聚合的分段数 */
u16 proto; /* 传输层协议 */
unsigned long age; /* 进入 GRO 的时间戳 */
/* 位域 */
u16 same_flow:1, /* 与已有条目属同一流 */
encap_mark:1, /* 经过隧道解封装 */
csum_valid:1, /* 硬件校验和已验证 */
csum_cnt:3, /* 待验证的校验和层数 */
is_flist:1, /* frag_list 模式聚合 */
recursion_counter:4; /* 隧道嵌套深度限制 */
};关键字段解读:
same_flow:协议层回调(如tcp4_gro_receive())判断两个 skb 是否属同一连接(五元组 + 序号连续),置位则可聚合flush:任何一层发现不可聚合条件(如 IP 选项不同、TCP 标志不兼容)时置位,强制该 skb 独立递交flush_id:检查 IP ID 是否连续递增,不连续则拒绝聚合(避免破坏 IP 分片重组语义)count:已聚合包数,达到gro_max_size / MSS时自动 flushrecursion_counter:4 位,最多支持 15 层隧道嵌套的 GRO
2.3 协议层 GRO 回调链
GRO 采用分层回调架构,每一层协议提供
gro_receive 和 gro_complete
回调:
napi_gro_receive()
→ dev_gro_receive()
→ inet_gro_receive() // IP 层:校验 IP 头、IP ID
→ tcp4_gro_receive() // TCP 层:校验五元组、序号
→ skb_gro_receive() // 实际聚合
每层的 gro_receive 负责:
- 解析该层头部
- 设置
same_flow(五元组/协议匹配) - 设置
flush(不可聚合条件) - 调用下一层
gro_receive
TCP 层的关键判断:
// net/ipv4/tcp_offload.c (概念性)
static struct sk_buff *tcp4_gro_receive(struct list_head *head,
struct sk_buff *skb)
{
// 检查源/目的端口、序号连续性
// 检查 TCP 标志:PSH/FIN/RST/SYN 中任一置位 → flush
// 检查窗口大小是否变化
// 检查 TCP 选项(timestamp)是否兼容
// 通过 → same_flow = 1
}2.4 skb_gro_receive():聚合引擎
当协议栈确认两个 skb
属同一流后,skb_gro_receive()
执行实际的数据合并:
// net/core/skbuff.c
int skb_gro_receive(struct sk_buff *p, struct sk_buff *skb)
{
struct skb_shared_info *pinfo = skb_shinfo(p);
struct skb_shared_info *skbinfo = skb_shinfo(skb);
// 策略 1:page fragment 追加
// 如果 p 的 frags[] 还有空间,直接把 skb 的数据页追加
// 这是零拷贝路径,最高效
// 策略 2:frag_list 链接
// 如果 frags[] 满或数据在 linear 区域
// 把 skb 挂到 p->frag_list 链表
// 更新元数据
p->len += skb->len;
p->data_len += skb->len;
p->truesize += skb->truesize;
NAPI_GRO_CB(p)->count++;
return 0;
}聚合模式选择:
| 模式 | 条件 | 优点 | 缺点 |
|---|---|---|---|
| frags[] 追加 | skb 数据在 page fragment | 零拷贝,GSO 分段高效 | frags[] 有上限(17 个) |
| frag_list 链 | linear 数据或 frags[] 满 | 无上限 | GSO 分段时需遍历链表 |
2.5 GRO flush 时机
GRO 聚合不能无限等待,以下条件触发 flush(将聚合包递交协议栈):
- NAPI
轮询结束:
napi_complete_done()→napi_gro_flush()清空所有 GRO 链 - 超时:
gro_flush_timeout(sysctl 可配,默认 0 = 仅在 NAPI 结束时 flush) - 聚合大小上限:
gro_max_size(默认 65536,6.x 可设到 524280) - 不兼容包到达:同流但 TCP 标志变化(如 FIN)触发 flush
- 非同流包占满:GRO 链表达到
MAX_GRO_SKBS(默认 8),最旧的被 flush
# 查看/调整 GRO flush 超时
sysctl net.core.gro_flush_timeout
# 配合 napi_defer_hard_irqs 使用效果更好
sysctl net.core.napi_defer_hard_irqs
napi_defer_hard_irqs 是 6.x
引入的重要优化:让 NAPI
轮询在没有新包时不立即重新使能中断,而是延迟若干次,给 GRO
更多时间积累同流包。这与 gro_flush_timeout
配合,可以显著提升 GRO 聚合率。
2.6 GRO 递交:gro_complete 回调
当 GRO 包被 flush 时,按协议层逆序调用
gro_complete 回调:
napi_gro_complete()
→ inet_gro_complete()
→ tcp4_gro_complete()
→ 设置 skb_shinfo(skb)->gso_size = MSS
→ 设置 skb_shinfo(skb)->gso_type = SKB_GSO_TCPV4
→ 设置 skb_shinfo(skb)->gso_segs = count
→ netif_receive_skb() // 进入正常收包路径
关键点:GRO 递交的包携带了 GSO
元数据(gso_size/gso_type),这使得后续如果需要转发(而非本地递交),可以用
GSO 重新分段,而无需回到原始小包。
三、GSO:延迟分段引擎
GSO(Generic Segmentation
Offload)的核心思想是:尽可能晚地把大包拆成小包。如果硬件支持
TSO,大包直接交给网卡分段;如果不支持,在
dev_queue_xmit() 的最后一步才用软件分段。
3.1 GSO 触发判断
发包路径中,dev_queue_xmit()
在实际发送前检查是否需要软件 GSO:
// net/core/dev.c (概念性)
static int __dev_queue_xmit(struct sk_buff *skb, ...)
{
// ...
if (netif_needs_gso(skb, features)) {
// 硬件不支持该 GSO 类型 → 软件分段
skb = __skb_gso_segment(skb, features, tx_path);
}
// ...
}netif_needs_gso() 的判断逻辑:
// include/linux/netdevice.h
static inline bool netif_needs_gso(struct sk_buff *skb,
netdev_features_t features)
{
return skb_is_gso(skb) && // 包标记了 GSO
(!skb_gso_ok(skb, features) || // 硬件不支持该类型
unlikely(skb->ip_summed != CHECKSUM_PARTIAL));
}简而言之:如果包标记了 GSO,且硬件不支持对应的
SKB_GSO_* 类型,就需要软件 GSO。
3.2 __skb_gso_segment():分段核心
// net/core/gso.c
struct sk_buff *__skb_gso_segment(struct sk_buff *skb,
netdev_features_t features,
bool tx_path)
{
struct sk_buff *segs;
// 1. 克隆 skb(保留原件用于错误回退)
// 2. 调用协议层回调 gso_segment
segs = skb_mac_gso_segment(skb, features);
// → inet_gso_segment()
// → tcp4_gso_segment()
return segs; // 返回分段链表
}TCP 的 GSO 分段逻辑:
// net/ipv4/tcp_offload.c (概念性)
struct sk_buff *tcp4_gso_segment(struct sk_buff *skb,
netdev_features_t features)
{
unsigned int mss = skb_shinfo(skb)->gso_size;
// 按 mss 拆分数据:
// 1. 每 mss 字节创建一个新 skb
// 2. 每个 skb 独立设置 TCP 头(序号、校验和)
// 3. 每个 skb 独立设置 IP 头(ID、长度、校验和)
// 4. 返回 skb 链表(通过 skb->next 链接)
}GSO 分段结果:
输入:1 个大 skb(len=45000, gso_size=1460, gso_segs=31)
输出:31 个小 skb 链表,每个 len ≈ 1460 + 头部
skb->next → skb->next → ... → NULL
3.3 GSO_PARTIAL:部分硬件卸载
现代网卡常常只支持对内层 L4
头做硬件分段,外层封装(VXLAN/GRE
头)需要软件处理。GSO_PARTIAL
解决这个问题:
// 驱动声明支持 PARTIAL
dev->hw_features |= NETIF_F_GSO_PARTIAL;
dev->gso_partial_features = NETIF_F_GSO_GRE |
NETIF_F_GSO_UDP_TUNNEL;工作流程:
- 软件 GSO 只拆分外层头(每个分段独立的外层 IP/UDP/VXLAN 头)
- 内层 TCP 保持大包形态,
gso_size保留 - 网卡硬件对内层 TCP 做最终分段
这大幅减少了 CPU 开销:软件只处理少量外层头,繁重的内层分段交给硬件。
3.4 UDP GSO(GSO_UDP_L4)
Linux 4.18 引入 UDP GSO,允许应用一次
sendmsg() 发送多个 UDP 报文:
// 用户态通过 cmsg 指定分段大小
struct msghdr msg;
int gso_size = 1472; // UDP 载荷大小
// 设置 UDP_SEGMENT cmsg
// 发送 N * gso_size 字节
// 内核路径:
// udp_sendmsg() → 创建大 skb
// gso_type = SKB_GSO_UDP_L4
// gso_size = 1472
// dev_queue_xmit() → 软件/硬件分段UDP GSO 对 QUIC 等协议的性能提升显著:一次系统调用发送多个数据报,减少 socket 锁竞争和协议栈遍历。
四、TSO:硬件分段卸载
TSO(TCP Segmentation Offload)是 GSO
的硬件加速版。当网卡支持 NETIF_F_TSO
时,内核直接把大 TCP 包(最大可达 64KB
甚至更大)交给网卡,由网卡硬件完成分段。
4.1 TSO 设置路径
TCP 发送路径中,tcp_write_xmit() 构建大
skb:
// net/ipv4/tcp_output.c (概念性)
static int tcp_write_xmit(struct sock *sk, unsigned int mss_now,
int nonagle, int push_one, gfp_t gfp)
{
// mss_now = TCP MSS(通常 1460)
// tso_segs = min(skb->len / mss_now, tso_max_segs)
while ((skb = tcp_send_head(sk))) {
unsigned int limit = tcp_mss_split_point(sk, skb, mss_now,
nonagle, cwnd);
// limit 可远大于 mss_now(受 cwnd、TSQ 限制)
// 设置 GSO 元数据
skb_shinfo(skb)->gso_size = mss_now;
skb_shinfo(skb)->gso_segs = DIV_ROUND_UP(skb->len, mss_now);
skb_shinfo(skb)->gso_type = SKB_GSO_TCPV4; // 或 TCPV6
tcp_transmit_skb(sk, skb, ...);
}
}4.2 TSO 尺寸限制
// include/linux/netdevice.h
struct net_device {
unsigned int gso_max_size; /* 通用 GSO/TSO 最大字节数 */
unsigned int gso_ipv4_max_size; /* IPv4 专用限制 */
u16 gso_max_segs; /* 单个 GSO 包最大分段数 */
u16 tso_max_size; /* TSO 专用限制 */
u16 tso_max_segs; /* TSO 最大分段数 */
};
// 驱动通过以下 API 声明限制
netif_set_tso_max_size(dev, 65536);
netif_set_tso_max_segs(dev, 64);TCP 层的 tcp_tso_segs() 综合考虑:
tso_max_size/tso_max_segs:硬件限制sk->sk_pacing_rate:TCP pacing 限制(避免一次性发太多导致微突发)sysctl_tcp_min_tso_segs:最小分段数(默认 2)- 拥塞窗口
cwnd:不超过窗口允许的数据量
4.3 TSO 与 TCP pacing 的交互
TSO 的一个”陷阱”是微突发(micro-burst):一个 64KB 的 TSO 包到达网卡后,网卡会在极短时间内以线速发出 44 个 1500 字节包,瞬间占满交换机缓冲区。
内核的解决方案是 TSQ + pacing:
// TCP Small Queues (TSQ)
// net/ipv4/tcp_output.c
// 限制每个 socket 在 qdisc + 驱动队列中的数据量
sysctl_tcp_limit_output_bytes // 默认 2 × 64KB
// FQ qdisc pacing
// net/sched/sch_fq.c
// 根据 sk->sk_pacing_rate 控制发送时机
// 每个 TCP 流独立 pacing最佳实践组合:
| 机制 | 作用 | 默认值 |
|---|---|---|
| TSO | 减少 CPU 开销 | 开启 |
| TSQ | 限制 socket 排队深度 | 2 × 64KB |
| FQ qdisc | 精确 pacing | 需手动配置 |
tcp_min_tso_segs |
小窗口时保持 TSO 效率 | 2 |
# 推荐配置:TSO + FQ pacing
tc qdisc replace dev eth0 root fq
ethtool -K eth0 tso on gso on五、LRO 的废弃与教训
LRO(Large Receive Offload)是 GRO 的前身,由网卡硬件完成收包聚合。它被废弃的原因是一个深刻的架构教训。
5.1 LRO 的致命缺陷
LRO 在网卡固件中聚合 TCP 包时,重写了 TCP/IP 头部:
原始包 1: IP.id=100, TCP.seq=1000, TCP.ack=5000, TCP.window=32768
原始包 2: IP.id=101, TCP.seq=2460, TCP.ack=5000, TCP.window=32768
原始包 3: IP.id=102, TCP.seq=3920, TCP.ack=5200, TCP.window=65535
LRO 聚合后: IP.id=100, TCP.seq=1000, TCP.ack=5200, TCP.window=65535
(中间包的 ack/window 信息丢失)
这导致三个问题:
- 破坏转发:聚合后的包不是合法的 IP 包,不能被路由转发
- 破坏 Netfilter:conntrack 看到的 ack/window 值不正确
- 破坏桥接:bridge 无法正确处理聚合包
因此内核强制规则:开启 ip_forward 时自动关闭 LRO。
5.2 GRO 的设计改进
GRO 的核心改进是不修改原始头部:
| 特性 | LRO | GRO |
|---|---|---|
| 实现位置 | 网卡固件 | 内核软件 |
| 头部保真 | 重写头部 | 保留原始头部 |
| 可逆性 | 不可逆 | 可通过 GSO 还原 |
| 转发兼容 | 不兼容 | 完全兼容 |
| 协议支持 | 仅 TCP | TCP/UDP/隧道/… |
| 灵活性 | 固件固定 | 可通过回调扩展 |
GRO 聚合后的包可以通过 GSO 重新分段,恢复出与原始包等价的包序列。这是 GRO 能用于转发路径的关键。
六、隧道设备的卸载处理
隧道(VXLAN、GRE、IPsec 等)给卸载带来了额外复杂性:外层和内层各自需要独立的 GRO/GSO 处理。
6.1 gro_cells:虚拟设备的 GRO 中转
隧道设备解封装后,内层包需要重新走 GRO。内核提供
gro_cells 机制:
// include/net/gro_cells.h
struct gro_cells {
struct gro_cell __percpu *cells;
};
struct gro_cell {
struct sk_buff_head napi_skbs;
struct napi_struct napi;
};工作流程:
物理网卡 GRO → 聚合外层包
→ 隧道驱动解封装(剥离 VXLAN/GRE 头)
→ gro_cells_receive()
→ 内层包进入 per-CPU 的 gro_cell
→ 独立 NAPI 实例做内层 GRO
→ 内层聚合包递交协议栈
使用 gro_cells 的设备:
- IP 隧道(IPIP、GRE、SIT)
- VXLAN
- IPsec(XFRM)
- IPv6 隧道
- Geneve
6.2 隧道 GSO 分段路径
发包方向,隧道设备的 GSO 需要处理多层封装:
tcp_write_xmit() → 大 TCP skb (gso_type = SKB_GSO_TCPV4)
→ 隧道设备 xmit
→ 添加外层头(VXLAN/GRE + 外层 IP/UDP)
→ gso_type |= SKB_GSO_UDP_TUNNEL // 标记隧道封装
→ dev_queue_xmit() → 物理网卡
→ netif_needs_gso() 检查
→ 如果网卡支持隧道 TSO:直接硬件分段
→ 否则:GSO_PARTIAL 或完全软件分段
6.3 隧道 offload 的常见陷阱
陷阱 1:MTU 与 GSO 的不匹配
# VXLAN 封装增加 50 字节开销
# 内层 MTU 1450,但 GRO 聚合后内层包可达 65536
# 如果物理网卡不支持隧道 TSO,软件 GSO 按内层 MTU 分段
# 每个分段都要独立封装外层头 → CPU 开销倍增陷阱 2:校验和卸载的层次
# 内层 TCP 校验和 → CHECKSUM_PARTIAL(硬件计算)
# 外层 UDP 校验和 → 取决于网卡能力
# 不匹配时回退到软件校验 → 性能下降
ethtool -k eth0 | grep tx-udp_tnl-segmentation陷阱 3:GRO 与隧道嵌套
napi_gro_cb.recursion_counter 限制最多 15
层隧道嵌套 GRO。超过后强制回退到普通收包路径。实际中超过 2
层隧道就应该检查架构设计。
七、对 Netfilter 与 TC 的影响
GRO/GSO 对 Netfilter 和 TC 的行为有深远影响,是运维中最容易踩坑的地方。
7.1 GRO 与 conntrack
GRO 聚合发生在 Netfilter
之前(napi_gro_receive() 在
netif_receive_skb() 之前)。这意味着:
时间线:
GRO 聚合 → netif_receive_skb() → NF_INET_PRE_ROUTING (conntrack)
→ NF_INET_LOCAL_IN
conntrack 看到的是:
一个 len=65536 的"假包",而非 45 个 1460 字节的真包
影响:
- conntrack 计数器:1 个 GRO 包 = 1 次 conntrack 查找(性能提升)
- 但
nf_conntrack_acct统计的字节数是 GRO 包大小,包计数偏低 - NAT 性能:GRO 后的大包做 NAT,输出时如果目标设备不支持 GSO,需要先 GSO 分段再每个包独立 NAT → 可能成为瓶颈
7.2 GSO 与 TC qdisc
TC qdisc 看到的是 GSO 大包,不是真实小包:
问题场景:
HTB 限速 100Mbps,GSO 大包 65536 字节
→ HTB 把这个大包当作一个"token"消耗
→ 65536 字节瞬间发出 → 微突发
→ 然后等待 token 补充 → 延迟增大
解决:
方案 1:在 TC 中使用 GSO 感知的 qdisc(如 fq_codel、fq)
方案 2:手动在 TC 路径中触发 GSO 分段
FQ/FQ_Codel 等现代 qdisc 会在内部调用
skb_gso_segment()
将大包拆分后再排队,避免突发问题。但 HTB/TBF 等传统 qdisc
不会自动分段。
# 查看 qdisc 是否正确处理 GSO
tc -s qdisc show dev eth0
# 如果看到 backlog 中有超大包,可能是 GSO 未分段
# 推荐:fq_codel 作为叶子 qdisc
tc qdisc replace dev eth0 root handle 1: htb default 10
tc class add dev eth0 parent 1: classid 1:10 htb rate 100mbit
tc qdisc add dev eth0 parent 1:10 fq_codel7.3 GSO 重新分段点
在转发路径中,GSO 分段可能在多个位置发生:
GRO 聚合包 → ip_forward()
→ NF_INET_FORWARD (Netfilter) // 操作 GSO 大包
→ ip_output()
→ NF_INET_POST_ROUTING // 仍然是大包
→ dev_queue_xmit()
→ netif_needs_gso() // 在这里决定是否分段
→ 支持 TSO → 直接发送(硬件分段)
→ 不支持 → __skb_gso_segment() → 分段后逐个发送
关键理解:Netfilter 的所有钩子看到的都是 GSO 大包。如果
Netfilter 规则依赖包大小判断(如
-m length),需要注意 GRO/GSO 的影响。
八、可观测性实战
8.1 查看 offload 状态
# 查看设备 offload 能力
ethtool -k eth0 | grep -E '(generic-receive-offload|generic-segmentation|tcp-segmentation|large-receive)'
# generic-receive-offload: on → GRO
# generic-segmentation-offload: on → GSO
# tcp-segmentation-offload: on → TSO
# large-receive-offload: off → LRO(应保持关闭)
# 查看隧道 offload
ethtool -k eth0 | grep tunnel
# tx-udp_tnl-segmentation: on
# tx-udp_tnl-csum-segmentation: on
# 查看 GSO 最大值
cat /sys/class/net/eth0/gso_max_size
cat /sys/class/net/eth0/gro_max_size
cat /sys/class/net/eth0/tso_max_size8.2 bpftrace 追踪 GRO 聚合效果
# 追踪 GRO 聚合后的分段数分布
bpftrace -e '
kprobe:napi_gro_complete {
$skb = (struct sk_buff *)arg1;
$shinfo = (struct skb_shared_info *)($skb->head + $skb->end);
@gro_segs = hist($shinfo->gso_segs);
}
'
# 追踪 GRO flush 原因
bpftrace -e '
kprobe:napi_gro_flush {
@flush_count = count();
}
kprobe:napi_gro_receive {
@receive_count = count();
}
interval:s:1 {
printf("GRO receive: %lld, flush: %lld, ratio: ",
@receive_count, @flush_count);
print(@gro_segs);
clear(@receive_count);
clear(@flush_count);
}
'8.3 bpftrace 追踪 GSO 分段
# 追踪软件 GSO 分段事件(应尽量少,说明硬件 TSO 在工作)
bpftrace -e '
kprobe:__skb_gso_segment {
$skb = (struct sk_buff *)arg0;
@gso_segment_count = count();
@gso_segment_dev[str($skb->dev->name)] = count();
}
interval:s:5 {
print(@gso_segment_count);
print(@gso_segment_dev);
clear(@gso_segment_count);
clear(@gso_segment_dev);
}
'如果物理网卡频繁触发
__skb_gso_segment(),说明 TSO
未生效或存在不匹配的 GSO 类型。
8.4 perf 分析 offload CPU 开销
# 对比开启/关闭 TSO 的 CPU 开销
ethtool -K eth0 tso off
perf stat -e cycles,instructions -a -- sleep 10
# 记录关闭 TSO 时的 cycles
ethtool -K eth0 tso on
perf stat -e cycles,instructions -a -- sleep 10
# 对比开启 TSO 时的 cycles
# GRO 开销分析
perf record -g -a -e cycles -- sleep 10
perf report --no-children | grep -E '(gro_receive|gro_complete|gso_segment)'8.5 监控 GRO 大小分布
# 统计 GRO 递交包的大小分布
bpftrace -e '
kprobe:netif_receive_skb_list_internal {
// 这是 GRO flush 后的路径
}
kprobe:netif_receive_skb_core {
$skb = (struct sk_buff *)arg0;
@pkt_len = hist($skb->len);
}
interval:s:10 {
print(@pkt_len);
clear(@pkt_len);
}
'正常情况下应该看到双峰分布:大量 > 10KB 的 GRO 聚合包,和少量无法聚合的小包(如 ACK、SYN)。
九、调优参数速查
| 参数 | 默认值 | 说明 |
|---|---|---|
ethtool -K eth0 gro on/off |
on | 启用/禁用 GRO |
ethtool -K eth0 tso on/off |
on | 启用/禁用 TSO |
ethtool -K eth0 gso on/off |
on | 启用/禁用 GSO |
ethtool -K eth0 lro off |
off | LRO 应保持关闭 |
/sys/class/net/eth0/gro_max_size |
65536 | GRO 聚合最大字节数 |
/sys/class/net/eth0/gso_max_size |
65536 | GSO 单包最大字节数 |
net.core.gro_flush_timeout |
0 | GRO flush 超时(纳秒) |
net.core.napi_defer_hard_irqs |
0 | 延迟中断重使能次数 |
net.ipv4.tcp_min_tso_segs |
2 | TSO 最小分段数 |
net.ipv4.tcp_limit_output_bytes |
131072 | TSQ 每 socket 排队上限 |
net.ipv4.tcp_tso_win_divisor |
3 | TSO 占用窗口比例限制 |
推荐调优组合
高吞吐场景(数据库、大文件传输):
ethtool -K eth0 tso on gso on gro on
echo 524280 > /sys/class/net/eth0/gro_max_size
sysctl -w net.core.gro_flush_timeout=20000
sysctl -w net.core.napi_defer_hard_irqs=10低延迟场景(交易系统、实时通信):
ethtool -K eth0 tso on gso on gro on
echo 65536 > /sys/class/net/eth0/gro_max_size # 保守聚合
sysctl -w net.core.gro_flush_timeout=0 # 不额外等待
sysctl -w net.core.napi_defer_hard_irqs=0转发场景(路由器、负载均衡器):
ethtool -K eth0 tso on gso on gro on lro off
# 确保 LRO 关闭!GRO + GSO 组合才能正确转发
# 检查隧道 TSO 支持
ethtool -k eth0 | grep tnl十、总结
| 机制 | 方向 | 位置 | 核心函数 | 性能收益 |
|---|---|---|---|---|
| GRO | 收包 | NAPI 轮询 | napi_gro_receive() |
减少协议栈遍历 |
| GSO | 发包 | dev_queue_xmit() |
__skb_gso_segment() |
延迟分段,减少头部开销 |
| TSO | 发包 | 网卡硬件 | 驱动 ndo_start_xmit() |
CPU 零开销分段 |
| LRO | 收包 | 网卡固件 | 已废弃 | 被 GRO 取代 |
分段卸载的核心原则是尽可能保持大包形态穿越协议栈:
- 收包:GRO 在入口聚合,大包走完 Netfilter、路由、socket 递交
- 发包:GSO/TSO 在出口分段,大包走完 socket、协议栈、TC qdisc
这减少了每包开销(分配 sk_buff、查路由、查
conntrack
等),但也意味着协议栈中间环节看到的是”不真实的”大包。理解这一点,是避免
Netfilter 规则误判、TC
整形突发、隧道性能下降等陷阱的关键。
参考文献
- Linux 内核源码
include/net/gro.h、include/net/gso.h(GRO/GSO 核心实现) - Linux 内核源码
include/linux/skbuff.h(skb_shared_info GSO 字段) - Linux 内核源码
include/linux/netdev_features.h(NETIF_F_* 特性标志) - Jonathan Corbet, “Generic receive offload”, LWN.net, 2007
- Jesper Dangaard Brouer, “XDP - eXpress Data Path”, Kernel Recipes, 2018
- Jakub Kicinski, “GRO improvements in recent kernels”, Netdev Conference, 2023
下一篇:网络子系统内存管理:sk_buff 分配、page pool 与 NUMA
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Linux 网络子系统深度拆解】发包路径全解:从 send() 到网线
一个用户态 send() 调用要走过 TCP 分段、IP 路由、Netfilter 钩子、Qdisc 排队、GSO 分段、驱动 DMA 映射六个阶段才能把数据送上网线。本文从 Linux 6.6 内核源码出发,逐函数拆解完整的 TX 发包路径,深入 TSQ 限流、Qdisc 调度、BQL 防膨胀、GSO/TSO 分段决策等核心机制。
【Linux 网络子系统深度拆解】收包路径全解:从 NIC 中断到 socket 接收队列
一个网络包从网卡 DMA 到用户态 recvmsg(),要走过硬中断、NAPI 轮询、GRO 聚合、协议分发、IP 路由、Netfilter 钩子、TCP/UDP 处理、socket 队列八个阶段。本文从 Linux 6.6 内核源码出发,逐函数拆解完整的 RX 收包路径,量化每一跳的 CPU 开销,并用 bpftrace 实测各阶段延迟分布。
【Linux 网络子系统深度拆解】sk_buff 全解:内核网络包的终极容器
sk_buff 是 Linux 内核网络栈的通用货币——每一个收到或发出的网络包,都必须装在这个容器里走完全程。本文从 Linux 6.6 内核源码出发,拆解 sk_buff 的内存布局、四大指针操作、clone 与 copy 的代价差异、skb_shared_info 的 fragment 机制,并用 bpftrace 实测 sk_buff 分配热点和生命周期。
【Linux 网络子系统深度拆解】内核网络调优方法论:从基准测试到生产验证
系统化的 Linux 内核网络调优方法论:从基准测试建立性能基线,到 sysctl 参数与内核数据结构的对应关系,再到中断亲和性、NUMA 拓扑、ring buffer、qdisc 的逐层调优,最终通过 A/B 对比验证生产效果。