Tailscale 为什么不再把 1 KiB 的包塞进 64 KiB 缓冲区

2026-09-24 31 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:9 分钟

在网络程序里,缓冲区越大并不总是越高效。Tailscale 最近分享的一项性能优化就说明了这一点:大多数网络包只有约 1 KiB,但为了配合 Linux 高效的 GRO(Generic Receive Offload)收包路径,程序必须随时准备处理最多 64 KiB 的数据。

如果底层只提供一种 64 KiB 缓冲区,拆包时就会出现一个隐蔽的成本:每个小包都要先被复制进大缓冲区,再交给后续逻辑处理。单次复制看起来很便宜,但在高包速率场景下,内存带宽、缓存污染和额外的分配都会叠加起来。

Tailscale 的思路很直接:既然大多数包根本不需要 64 KiB,就不要让每个小包都走大缓冲区的路径。

GRO 带来了吞吐,也带来了尺寸不确定性

GRO 会在内核接收路径上把多个相关的小包合并,减少协议栈需要处理的包数量。对高吞吐网络服务来说,这通常意味着更少的系统调用、更少的包处理开销,以及更好的 CPU 利用率。

但应用层看到的数据尺寸因此变得不再固定。一次读取可能只得到一个约 1 KiB 的包,也可能得到多个包拼接后的大块数据。为了覆盖最坏情况,传统实现可能简单地准备一个 64 KiB 缓冲区。

问题在于,最坏情况的容量不应该自动变成每个请求的实际成本:

实际包大小:       1 KiB
预留缓冲区大小:  64 KiB
每个小包的复制:   1 次

这类复制不会改变业务结果,却会消耗 CPU 和内存带宽。更大的缓冲区还可能把有用的数据挤出 CPU 缓存,尤其是在包速率远高于单纯吞吐量的场景中。

优化重点:把“最大容量”和“实际长度”分开

这类问题的关键不是简单地把 64 KiB 改成 1 KiB。GRO 仍然可能交付更大的数据块,因此实现必须同时满足两个条件:

  • 小包按照实际长度处理,避免无意义的复制。
  • 大包仍然能够扩展或切分,不能因为默认缓冲区变小而截断数据。

可以把接收路径抽象成两种策略:

  1. 固定大缓冲区:每次都准备 64 KiB,并把输入复制到这个缓冲区。
  2. 按需缓冲:先使用适合常见包大小的缓冲区,只有发现数据超过容量时才扩展或切换到大块处理。

第二种策略把资源消耗从“按最坏情况付费”改成“按实际输入付费”。这通常更符合网络流量的真实分布。

一个可运行的最小示例

下面的 Go 程序模拟两种处理方式。它不实现 GRO 或 WireGuard,只用来展示复制成本:固定大缓冲区策略无论输入多小,都先分配 64 KiB;按需策略则只创建与输入长度匹配的副本。

将下面内容保存为 main.go,然后运行 go run main.go

package main

import (
    "fmt"
    "runtime"
)

const largeBufferSize = 64 * 1024

func fixedLargeBuffer(packet []byte) []byte {
    buffer := make([]byte, largeBufferSize)
    copy(buffer, packet)
    return buffer[:len(packet)]
}

func rightSizedBuffer(packet []byte) []byte {
    buffer := make([]byte, len(packet))
    copy(buffer, packet)
    return buffer
}

func allocatedBytes(fn func([]byte) []byte, packet []byte, rounds int) uint64 {
    var before, after runtime.MemStats
    runtime.GC()
    runtime.ReadMemStats(&before)

    for i := 0; i < rounds; i++ {
        result := fn(packet)
        if len(result) != len(packet) {
            panic("packet length changed")
        }
    }

    runtime.ReadMemStats(&after)
    return after.TotalAlloc - before.TotalAlloc
}

func main() {
    packet := make([]byte, 1024)
    rounds := 100_000

    fixed := allocatedBytes(fixedLargeBuffer, packet, rounds)
    rightSized := allocatedBytes(rightSizedBuffer, packet, rounds)

    fmt.Printf("packet size: %d bytes\n", len(packet))
    fmt.Printf("rounds: %d\n", rounds)
    fmt.Printf("fixed 64 KiB strategy: %d bytes allocated\n", fixed)
    fmt.Printf("right-sized strategy:  %d bytes allocated\n", rightSized)
}

这个例子刻意保持简单,但能说明两个工程判断:缓冲区的容量用于容纳数据,切片的长度用于描述当前有效数据;同时,基准测试必须观察真实分配量,而不是只看函数是否返回了正确结果。

真实网络路径还需要处理池化、复用、并发访问和所有权转移。因此,“按需分配”不一定意味着每个包都调用一次 make。更成熟的实现可能会结合小对象池、分级缓冲区或底层 API 的直接写入能力,目标是避免多余复制,同时控制分配次数。

为什么这是一个值得推广的优化模式

这项优化的价值不局限于 VPN 或 WireGuard。它适用于所有同时面对“常见输入很小”和“偶发输入很大”的数据通道,例如:

  • 网络代理和用户态协议栈。
  • 日志、消息队列和 RPC 客户端。
  • 压缩、解压和加密流水线。
  • 从内核、驱动或文件系统读取数据的程序。

排查时可以沿着这条路径检查:

  1. 统计真实输入长度分布,而不是只看协议允许的最大值。
  2. 使用 CPU profile、内存 profile 或分配计数确认复制是否占据热点。
  3. 区分“需要能够容纳的最大尺寸”和“每次实际处理的尺寸”。
  4. 让大输入走扩展路径,让常见小输入保持短路径。
  5. 用吞吐、包速率、P99 延迟和 CPU 消耗一起验证收益。

还要注意,复制并非总是坏事。复制有时能简化内存生命周期、避免持有过大的底层缓冲区,也能让并发代码更容易维护。如果去掉复制后引入了复杂的引用计数、锁竞争或数据竞态,优化可能得不偿失。

落地时的检查清单

  • 是否因为支持最坏情况,而让所有请求都使用最大缓冲区?
  • 输入数据是否通常远小于缓冲区容量?
  • 是否存在“读取一次、复制一次、再拆包一次”的重复路径?
  • 是否可以让常见小包直接进入目标处理缓冲区?
  • 大包路径是否仍能正确扩容、拆分或回退?
  • 基准测试是否覆盖了 1 KiB 小包、接近上限的大包,以及混合流量?

Tailscale 这类优化提醒我们:高性能网络代码的瓶颈,常常不在显眼的加密或协议逻辑里,而在一条每个包都会经过的内存复制路径。面对不确定的最大输入,保留扩展能力是必要的;让所有输入都提前支付最大成本,则未必必要。


相关推荐