代理节点端热路径的零分配改造:原则、五个模式与实测

TL;DR — 代理节点端的热路径是”每秒几十万次的小操作”:封一帧、加密一块、排队一个上传分片、发一个 UDP 报文。这类路径上每次操作多一次堆分配,单看 ns/op 几乎没有变化,但会以 GC assist、堆扫描和尾延迟抖动的形式在高负载下集体兑现。这篇记录一轮把 SMux、VLESS Encryption、XHTTP、AnyTLS、Hysteria2 等热路径改到 0 allocs/op 的工作:先定六条原则,再用五个可复用的模式落地,每个模式都配 wire 格式不变的 round-trip 测试与前后基准。最后一节讲零分配买不到的东西:当 profile 里排第一的是 write syscall 时,该做的是写合并,而我们否决了两种看起来更聪明的源侧方案。


一、为什么盯着 allocs/op 而不是 ns/op

先看这轮改造的结果表,注意时间列和分配列的反差:

路径 优化前 优化后 说明
SMux 写帧等待与缓冲 4.16 µs,160 B,3 allocs 4.04 µs,0 B,0 allocs 时间约降 3%,收益在 GC
VLESS Encryption 8 KiB 写 5.94 µs,24 B,1 alloc 5.95 µs,0 B,0 allocs 加密 CPU 不变,去掉包装分配
XHTTP 1 MiB 上传队列 16.1 µs,1 alloc 13.6 µs,0 allocs 时间约降 15%
AnyTLS 256 KiB 写 3.64 µs,320 B,5 allocs 3.20 µs,0 B,0 allocs 5 次底层 write,时间约降 12%
Hysteria2 1200 B 封包 无可比基线 22.9 ns,0 B,0 allocs 只测封包

时间几乎不动,分配全部归零。如果只看微基准的 ns/op,这轮工作看起来不值得做。它的价值在别处:

  • GC assist。分配速率越高,用户 goroutine 被拉去帮 GC 干活的比例越高,这部分时间不在任何单个函数的 profile 里,散落在所有路径上。
  • 堆扫描。每个存活的小对象都是标记阶段要访问的一个节点。热路径上的临时对象生命周期短,但在高并发下”同时存活”的数量不小。
  • 尾延迟。GC 触发时机与负载相关,P99 抖动的相当一部分来自这里。

所以这轮改造的度量口径是:先把 allocs/op 打到 0,再看 ns/op 有没有顺带改善。表里的数字来自同一台机器、内存或 discard writer、7 次取中位数,不包含真实网络、TLS、QUIC 拥塞控制和客户端开销。它们不能相加成”整体提升百分比”,最终必须用端到端压测看 CPU、RSS、吞吐和 P99。

二、六条原则

动手前先把规矩写下来,后面每个改动都要能对上其中一条:

  1. wire 格式不因性能优化改变。手写的封包必须有”编码后再解码”的 round-trip 测试;涉及外部实现的协议还要有互操作测试。
  2. 复用 backing array 和堆上常驻的 slice holder,不把 []byte 直接装进 any,那会产生一次 slice header 的 boxing 分配。
  3. 池化对象必须有明确的唯一所有者,或者像写入等待器那样显式记录双方完成状态。取消、超时和关闭路径必须能归还已授予的容量,且不能重复回收。
  4. 背压直接授予真正能继续执行的等待者,不用关闭共享 channel 唤醒全部协程。
  5. 分帧、分片和等待队列用迭代实现,不按数据量递归。缓冲区放对象池或堆上,goroutine 栈只保存 slice header、指针和固定大小状态。
  6. sync.Pool 是降低稳态分配的手段,不是容量上限。运行时可以在 GC 时清空池;真正的内存边界由队列、单包大小、共享预算和协议限制提供。

下面五个模式分别是原则 2、3、4、5 和”所有权接口”的落地。

三、模式一:不要把 []byte 放进 any

VLESS Encryption 的写路径每次要一块 5 + 8192 + 16 字节的输出缓冲。最直接的写法是 sync.Pool 存 []byte:

1
2
3
4
var outBytesPool = sync.Pool{New: func() any { return make([]byte, 5+8192+16) }}

out := outBytesPool.Get().([]byte)
defer outBytesPool.Put(out) // 这一行每次分配 24 字节

sync.Pool 的接口是 any。Put(out) 要把一个 24 字节的 slice header 装箱成接口值,这个 header 必须放在堆上,于是每次加密写多一次分配。基准里那个 “24 B,1 alloc/op” 就是它。

修法是池里放指向 slice 的指针,让 header 常驻堆上被复用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var outBytesHolderPool = sync.Pool{New: func() any {
buffer := make([]byte, 5+8192+16)
return &buffer
}}

func (c *CommonConn) Write(b []byte) (int, error) {
outBytesPtr := outBytesHolderPool.Get().(*[]byte)
outBytes := *outBytesPtr
defer func() {
// 复用 Get 返回的堆上 header;直接 Put(outBytes) 会装箱一个新 header
*outBytesPtr = outBytes
outBytesHolderPool.Put(outBytesPtr)
}()
// ... 按 8 KiB 分块加密写出
}

对外暴露的池类型保持原来的动态类型不变,record 格式不变,只是内部换了持有方式。结果:24 B / 1 alloc 到 0 / 0,时间持平。

这个模式的适用面很广:任何”池里存切片”的地方都值得检查一遍 Put 的参数类型。

四、模式二:池化对象的双方完成协议

SMux 的写请求会进入一个整形器(shaper)排队,由发送循环异步写出。调用方等结果时可能超时、连接可能关闭。问题来了:等待器对象是池化的,谁来回收它?

  • 调用方超时返回后回收:发送循环稍后还要往里写结果,写到已被别人复用的对象上。
  • 发送循环写完结果后回收:调用方可能还没读,读到已被复用的对象。

两边都不能单独决定。解法是一个原子状态字,双方各置一位,最后完成的一方回收:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const (
writeWaiterCallerDone uint32 = 1 << iota
writeWaiterSenderDone
writeWaiterDone = writeWaiterCallerDone | writeWaiterSenderDone
)

type writeWaiter struct {
result chan writeResult // 容量 1,随对象一起复用
state atomic.Uint32
}

func (waiter *writeWaiter) release(flag uint32) {
if waiter.state.Or(flag)|flag != writeWaiterDone {
return // 对方还没完成,由对方回收
}
select { // 调用方超时离场时,结果可能还留在 channel 里
case <-waiter.result:
default:
}
waiter.state.Store(0)
writeWaiterPool.Put(waiter)
}

发送循环写完结果后 release(SenderDone);调用方无论是正常读到结果、超时、还是连接关闭,都 release(CallerDone)。channel 是容量 1 的,随对象一起进池,下一帧不再分配。还有一条快路径:请求根本没进入整形器就失败时,调用方是唯一持有者,直接重置回收。

接收侧配套改了一处:接收环直接持有 []byte,不再为每帧包一层 *[]byte。整形器的稠密槽位保持复用。三处合起来,写帧路径从 160 B / 3 allocs 到 0。

这个模式解决的是一类通用问题:两个异步方共享一个池化对象,任何一方都可能先退出。原则 3 说的”显式记录双方完成状态”就是它。

五、模式三:背压直接授予,不广播

XHTTP 的上传侧要限制排队字节数。多个 HTTP handler 同时等容量时,旧做法是一个 generation channel:释放容量就关闭它,所有阻塞的 handler 一起醒来,重新抢锁、重新检查、大部分再次睡回去。上传并发高的时候,这是一场惊群。

新做法是一个侵入式双向链表记录等待者,释放容量时在锁内遍历链表,只把容量授予真正装得下的等待者,并且只唤醒它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type reservationWaiter struct {
queue *uploadQueue
sequence uint64
size int64
ready chan struct{} // 容量 1,随对象进池
granted bool
prev, next *reservationWaiter
}

func (group *reservationGroup) wakeLocked() {
for waiter := group.head; waiter != nil; {
next := waiter.next
if waiter.queue.reserveLocked(waiter.size, waiter.queue.isHead(waiter.sequence)) {
group.removeLocked(waiter)
waiter.granted = true
waiter.ready <- struct{}{}
}
waiter = next
}
}

真正难的是取消。等待者在 ctx 取消、队列关闭或停顿超时后要离场,而此刻可能刚好有一次释放把容量授予了它。两条路径在同一把锁下会合:

1
2
3
4
5
6
7
8
9
10
11
func (queue *uploadQueue) cancelReservationWaiter(waiter *reservationWaiter) {
group.mu.Lock()
if waiter.granted {
queue.releaseReservationLocked(waiter.size) // 已授予的容量还回去
} else {
group.removeLocked(waiter) // 还在排队,摘链
}
group.wakeLocked() // 让出的容量继续调度别人
group.mu.Unlock()
recycleReservationWaiter(waiter)
}

granted 标志在锁内读写,所以”取消”与”授予”不可能同时成立而互不知情。等待者对象池化,ready channel 随对象复用,回收时先排空。

代价要写清楚:释放容量时最坏要扫完整个等待链表,O(W)。在异常大的等待集合下会增加锁持有时间和尾延迟,但它是循环不是递归,不会增加调用栈深度。顺序上传的快路径还有一个细节:不对局部的上传分片结构体取会逃逸的地址,否则编译器会把它挪到堆上,零分配就破了。

结果:1 MiB 上传队列从 16.1 µs / 1 alloc 到 13.6 µs / 0 allocs。时间下降 15% 的来源不是少了那一次分配,是少了惊群。

六、模式四:分帧与分片迭代化

两个协议路径原来都有”先构建完整中间结构、再逐个发送”的形状。

AnyTLS 的数据帧载荷上限是 65535 字节。大写入要拆帧,上游某个版本的做法是把完整载荷先拷进一个可能很大的临时缓冲。改成在一把序列化锁下逐帧编码、写出、释放:

1
2
3
4
5
6
7
8
9
10
11
12
13
err := s.withSerializedWrite(deadline, 0, func() error {
for len(data) > 0 {
chunkSize := min(len(data), maxFramePayload)
buffer := encodeWireFrame(cmdPSH, sid, data[:chunkSize]) // 池化字节切片
_, writeErr := s.writeConnLocked(buffer)
releaseFrameBuffer(buffer)
if writeErr != nil {
return writeErr
}
data = data[chunkSize:]
}
return nil
})

数据帧、控制帧和 padding 合并都直接用池化字节切片编码,不再为每帧创建一个 buffer 对象的 header。锁保证一次 Stream.Write 产生的所有帧保持调用方顺序。wire header 和 padding 语义不变。256 KiB 写从 320 B / 5 allocs 到 0,时间降 12%。

Hysteria2 的 UDP 超过 MTU 要分片。原来先构建一个 fragment 切片,每片再包一个 buffer view;现在逐片创建、发送、释放:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fragmentCount := (len(payload) + udpMTU - 1) / udpMTU
if fragmentCount <= 1 || fragmentCount > math.MaxUint8 {
return c.writePacket(message) // 8 位 wire 字段装不下就走原有的过大错误路径,不递归重试
}
for index, offset := 0, 0; offset < len(payload); index, offset = index+1, offset+udpMTU {
fragment := allocMessage() // 池化
*fragment = *message
fragment.fragmentData = payload[offset:min(offset+udpMTU, len(payload))]
fragment.fragmentID, fragment.fragmentTotal = uint8(index), uint8(fragmentCount)
err := c.writePacket(fragment)
fragment.release()
if err != nil {
return err
}
}

同一模式套到 Hysteria 与 TUIC 上。三个协议都有 pack 到 decode 的 round-trip 测试,TUIC 覆盖 IPv4、IPv6、域名和空目标。

原则 5 在这里的含义很具体:分片数由数据量决定,如果用递归表达”剩余部分继续分”,栈深就随数据量增长;用循环,栈上只有几个整数。

七、模式五:所有权接口代替拷贝

MTProxy 的记录层与加密层原来每条记录分配一次 pending 切片再做一次中间复制。改造后接入带所有权语义的读写接口:

  • ReadBuffer 把 TLS record 的载荷直接读进调用方的 buffer,未读完的长度和部分 header 留在连接状态里,不分配中间切片。
  • WriteBuffer 接管 buffer 所有权,原地加密;有 5 字节 headroom 时直接写记录头,否则用最多 32 KiB 的池化批次。成功或失败都由本层消费或释放,交给下一层后不重复释放。
  • 向量化写保持连续的 CTR 状态与批次顺序,错误时未发送的 buffer 也释放。
  • 底层同时返回数据和错误时,先交付数据、下次读再返回错误,否则拷贝引擎会丢掉最后一段。

结果:512 B 与 32 KiB 的写从 1 alloc 到 0,512 B 与 16 KiB 的读从 2 allocs 到 0。

入站侧用同样思路复用现成基础设施:SOCKS5 与 HTTP CONNECT 的握手预读数据转成受所有权管理的缓存,成功回复后允许公共拷贝引擎解开包装直接用底层连接;UDP 从队列取出池化 buffer 直接交给数据面,只在出站需要更多 headroom 时才复制;HTTP 上游 Transport 从每请求新建改为每入站复用。这一组改动没有带来吞吐的显著变化,只是每往返少一到两次分配,如实记录。

八、sync.Pool 不是容量上限

零分配改造最容易滋生的错觉是”有了池就有了边界”。没有。运行时可以在任意一次 GC 时清空池,池只决定稳态下分配多少,不决定最坏情况下内存有多少。表里 XHTTP 那行”少量 B/op 来自池被清空后的均摊补充”就是在提醒这件事。

真正的边界另外给:

  • 每条 UDP 会话占一个 goroutine、一个上游 socket、一条 NAT 表项,而 NAT 的全局 LRU 是所有用户共享的。于是按用户限制并发 UDP 会话,默认 1024,超出以明确错误拒绝,TCP 不计入。一个用户喷射目标地址只能耗尽自己的配额,挤不掉别人。
  • UDP 重组器有独立的最大包数、最大缓冲字节数和最大分片数。
  • 上传队列有字节预算,请求超过单包上限直接拒绝,而不是排队。

原则 6 的另一半是:这些边界要和池分开设计。池管快,预算管稳,混在一起两边都做不好。

九、零分配买不到的东西

分配归零之后再看 profile,排第一的变成了 net.(*netFD).Write:Hysteria2 上传累计 21.7%,VLESS 加 TLS 上传累计 33.9%。这是 syscall 与拷贝进内核的成本,与分配无关。

先量化天花板

向 loopback 写同样总量,只改每次写的大小:

每次写 CPU/字节 相对 16 KiB
1350 B 0.525 ns +62%
4 KiB 0.427 ns +32%
16 KiB 0.324 ns 基准
32 KiB 0.291 ns −10%
256 KiB 0.282 ns −13%

从 1.3 KiB 到 16 KiB 能省约 38% 的写 CPU,之后曲线走平。换算出每次写的固定开销约 330 ns,其余是把数据拷进内核。所以合并写的上限是明确的:只能回收固定开销那部分。

两种被否决的源侧方案

合并读装饰器:首次读之后把读超时设成”已过期”,非阻塞地把已排队的数据继续读进同一个缓冲,再恢复超时。加计数器实测 180 万次读、探测 0 次命中 0 次:转发 goroutine 永远快于 QUIC 连接的生产者,任何时刻队列里只有一个数据报,源端根本没有可合并的积压。更致命的是正确性:裸 TCP、TLS、QUIC 流在读超时后可恢复,带帧的读取器不行,WebSocket 场景直接建连失败、吞吐为零。带帧的源恰恰是合并唯一可能获益的一类,也正是不能用这个技术的一类。

放开 MTU 钳制:让流式拷贝用更大的读缓冲。实测每核吞吐反而下降 8.3%。

采用的写侧方案

写侧合并不碰源、不设读超时,用显式等待换 CPU:累积到目标尺寸、窗口到期或流结束三者先到即刷出。三条约束:

  • 默认关闭时不包装目标,数据路径与改动前完全一致,有测试钉住。
  • 只有已传输超过 512 KB 的流才算批量流,请求响应式流量永远走直路,不会被窗口延迟。
  • 限速用户不合并,令牌桶已经在为那条流定速。

Hysteria2 上传开 2 ms 窗口:吞吐从 0.458 到 0.623 Gbit/s,每核效率从 0.318 到 0.395,分别 +35.9% 和 +24.3%。增幅大于成本曲线的预测,因为合并同时消掉了连接 goroutine 与转发 goroutine 之间的唤醒乒乓。1000 连接定速场景下积压 0.01 MiB,远低于判定阈值。

这一节想说的是:零分配是热路径优化的第一层,不是最后一层。分配归零后 profile 会诚实地告诉你下一个瓶颈在哪,而下一个瓶颈往往需要完全不同的手段,并且每个手段都要先量化上限、再测、再决定。

十、怎么保证协议没被改坏

这轮改动的每一项都动了封包路径,验证分四层:

  1. round-trip 测试:每个手写编码器都有”编码后解码回来逐字段相等”的用例,覆盖边界尺寸、空载荷、最大分片数。
  2. 互操作测试:涉及外部实现的协议,用真实客户端或参考实现对打。
  3. 分配回归:基准以 -benchmem 运行,allocs/op 进入对比表;池被清空导致的均摊分配单独注明,不混进”回归”。
  4. 端到端矩阵:微基准之外跑协议压力矩阵,看 CPU、RSS、吞吐与 P99 有没有系统性变化。写合并的每核吞吐数字来自这里,不来自微基准。

以及一条纪律:基准表里的每个数字都写明机器、版本、样本数和”不包含什么”。上面那张表不包含真实网络、TLS、QUIC 拥塞控制和客户端开销,所以它只能用于同机回归,不能用于宣传。


结语

回头看,五个模式其实是同一个问题的五个切面:

热路径上的每一次分配,都是一次没想清楚所有权的结果。

slice 被装箱,是因为没想清楚 header 归谁持有;等待器不能回收,是因为没想清楚两个异步方谁最后离场;惊群,是因为把”谁能继续”的判断推给了被唤醒者自己;递归分片,是因为让数据量决定了栈的形状;中间拷贝,是因为接口没有表达”这块内存现在归你”。把所有权说清楚,分配自然就没了。

然后 profile 会告诉你,下一个瓶颈是 syscall。那是另一篇文章的事,但决策方法是一样的:先量上限,再测方案,把否决的也写下来。