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 环境的常见陷阱:

  1. veth TSO 被全局关闭:管理员为降低 CPU 关闭宿主机的 TSO,导致容器的 TSO 也因 NETIF_F_TSO_MANGLEID 回退而失效
    1. overlay 网络双重分段:VXLAN/Geneve 外层封装 + TSO 内层分段,NIC 必须支持 NETIF_F_GSO_UDP_TUNNEL 才能卸载双层分段
      1. CNI 插件未正确设置 mtu:容器 MTU(默认 1500)与物理 NIC MTU(9000)不匹配,导致 GSO 需要在 veth 端二次分段
      2. // 内核中 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 的合并效率:

        • Linux 5.x:引入 GRO 哈希表优化,减少链式遍历的 O(n) 开销
        • Linux 6.x:GRO 支持 GRO_REJECT 语义,允许某些流不被合并(如 MixedMode)
        • Linux 6.9+:GRO 支持按流策略选择性合并(per-flow GRO policy),可根据应用需求对实时流禁用 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 网络栈的卸载机制是一个多层次的体系:

        1. NAPI 解决了高频中断问题,是高效接收的前提
          1. GRO 将接收路径的 per-packet overhead 合并,大幅减少协议栈调用次数
            1. GSO 延迟分段至设备层,避免不必要的拷贝
              1. TSO 将 GSO 的"最终分段"推给硬件,彻底解放发送路径
                1. 校验和卸载 避免冗余的 CPU 校验计算
                2. 理解这些机制的交互关系,比简单地将它们全部 on/off 更有价值。工程决策必须基于实际流量模式(大包/小包、单流/多流、延迟敏感/吞吐优先),并通过 eBPF 和 perf 工具进行验证。

                  在高性能网络工程实践中,一个正确的卸载配置方案可以将 25Gbps 服务的 CPU 占用从接近 100% 降至 30% 以下——这不是玄学,而是对内核协议栈机制的深入理解所带来的必然回报。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部