在网络程序里,缓冲区越大并不总是越高效。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 仍然可能交付更大的数据块,因此实现必须同时满足两个条件:
- 小包按照实际长度处理,避免无意义的复制。
- 大包仍然能够扩展或切分,不能因为默认缓冲区变小而截断数据。
可以把接收路径抽象成两种策略:
- 固定大缓冲区:每次都准备 64 KiB,并把输入复制到这个缓冲区。
- 按需缓冲:先使用适合常见包大小的缓冲区,只有发现数据超过容量时才扩展或切换到大块处理。
第二种策略把资源消耗从“按最坏情况付费”改成“按实际输入付费”。这通常更符合网络流量的真实分布。
一个可运行的最小示例
下面的 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 客户端。
- 压缩、解压和加密流水线。
- 从内核、驱动或文件系统读取数据的程序。
排查时可以沿着这条路径检查:
- 统计真实输入长度分布,而不是只看协议允许的最大值。
- 使用 CPU profile、内存 profile 或分配计数确认复制是否占据热点。
- 区分“需要能够容纳的最大尺寸”和“每次实际处理的尺寸”。
- 让大输入走扩展路径,让常见小输入保持短路径。
- 用吞吐、包速率、P99 延迟和 CPU 消耗一起验证收益。
还要注意,复制并非总是坏事。复制有时能简化内存生命周期、避免持有过大的底层缓冲区,也能让并发代码更容易维护。如果去掉复制后引入了复杂的引用计数、锁竞争或数据竞态,优化可能得不偿失。
落地时的检查清单
- 是否因为支持最坏情况,而让所有请求都使用最大缓冲区?
- 输入数据是否通常远小于缓冲区容量?
- 是否存在“读取一次、复制一次、再拆包一次”的重复路径?
- 是否可以让常见小包直接进入目标处理缓冲区?
- 大包路径是否仍能正确扩容、拆分或回退?
- 基准测试是否覆盖了 1 KiB 小包、接近上限的大包,以及混合流量?
Tailscale 这类优化提醒我们:高性能网络代码的瓶颈,常常不在显眼的加密或协议逻辑里,而在一条每个包都会经过的内存复制路径。面对不确定的最大输入,保留扩展能力是必要的;让所有输入都提前支付最大成本,则未必必要。