代理节点端热路径的零分配改造:原则、五个模式与实测
TL;DR — 代理节点端的热路径是”每秒几十万次的小操作”:封一帧、加密一块、排队一个上传分片、发一个 UDP 报文。这类路径上每次操作多一次堆分配,单看 ns/op 几乎没有变化,但会以 GC assist、堆扫描和尾延迟抖动的形式在高负载下集体兑现。这篇记录一轮把 SMux、VLESS Encryption、XHTTP、AnyTLS、Hysteria2 等热路径改到 0 allocs/op 的工作:先定六条原则,再用五个可复用的模式落地,每个模式都配 wire 格式不变的 round-trip 测试与前后基准。最后一节讲零分配买不到的东西:当 profile 里排第一的是
writesyscall 时,该做的是写合并,而我们否决了两种看起来更聪明的源侧方案。
一、为什么盯着 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。
二、六条原则
动手前先把规矩写下来,后面每个改动都要能对上其中一条:
- wire 格式不因性能优化改变。手写的封包必须有”编码后再解码”的 round-trip 测试;涉及外部实现的协议还要有互操作测试。
- 复用 backing array 和堆上常驻的 slice holder,不把
[]byte直接装进any,那会产生一次 slice header 的 boxing 分配。 - 池化对象必须有明确的唯一所有者,或者像写入等待器那样显式记录双方完成状态。取消、超时和关闭路径必须能归还已授予的容量,且不能重复回收。
- 背压直接授予真正能继续执行的等待者,不用关闭共享 channel 唤醒全部协程。
- 分帧、分片和等待队列用迭代实现,不按数据量递归。缓冲区放对象池或堆上,goroutine 栈只保存 slice header、指针和固定大小状态。
sync.Pool是降低稳态分配的手段,不是容量上限。运行时可以在 GC 时清空池;真正的内存边界由队列、单包大小、共享预算和协议限制提供。
下面五个模式分别是原则 2、3、4、5 和”所有权接口”的落地。
三、模式一:不要把 []byte 放进 any
VLESS Encryption 的写路径每次要一块 5 + 8192 + 16 字节的输出缓冲。最直接的写法是 sync.Pool 存 []byte:
1 | var outBytesPool = sync.Pool{New: func() any { return make([]byte, 5+8192+16) }} |
sync.Pool 的接口是 any。Put(out) 要把一个 24 字节的 slice header 装箱成接口值,这个 header 必须放在堆上,于是每次加密写多一次分配。基准里那个 “24 B,1 alloc/op” 就是它。
修法是池里放指向 slice 的指针,让 header 常驻堆上被复用:
1 | var outBytesHolderPool = sync.Pool{New: func() any { |
对外暴露的池类型保持原来的动态类型不变,record 格式不变,只是内部换了持有方式。结果:24 B / 1 alloc 到 0 / 0,时间持平。
这个模式的适用面很广:任何”池里存切片”的地方都值得检查一遍 Put 的参数类型。
四、模式二:池化对象的双方完成协议
SMux 的写请求会进入一个整形器(shaper)排队,由发送循环异步写出。调用方等结果时可能超时、连接可能关闭。问题来了:等待器对象是池化的,谁来回收它?
- 调用方超时返回后回收:发送循环稍后还要往里写结果,写到已被别人复用的对象上。
- 发送循环写完结果后回收:调用方可能还没读,读到已被复用的对象。
两边都不能单独决定。解法是一个原子状态字,双方各置一位,最后完成的一方回收:
1 | const ( |
发送循环写完结果后 release(SenderDone);调用方无论是正常读到结果、超时、还是连接关闭,都 release(CallerDone)。channel 是容量 1 的,随对象一起进池,下一帧不再分配。还有一条快路径:请求根本没进入整形器就失败时,调用方是唯一持有者,直接重置回收。
接收侧配套改了一处:接收环直接持有 []byte,不再为每帧包一层 *[]byte。整形器的稠密槽位保持复用。三处合起来,写帧路径从 160 B / 3 allocs 到 0。
这个模式解决的是一类通用问题:两个异步方共享一个池化对象,任何一方都可能先退出。原则 3 说的”显式记录双方完成状态”就是它。
五、模式三:背压直接授予,不广播
XHTTP 的上传侧要限制排队字节数。多个 HTTP handler 同时等容量时,旧做法是一个 generation channel:释放容量就关闭它,所有阻塞的 handler 一起醒来,重新抢锁、重新检查、大部分再次睡回去。上传并发高的时候,这是一场惊群。
新做法是一个侵入式双向链表记录等待者,释放容量时在锁内遍历链表,只把容量授予真正装得下的等待者,并且只唤醒它们:
1 | type reservationWaiter struct { |
真正难的是取消。等待者在 ctx 取消、队列关闭或停顿超时后要离场,而此刻可能刚好有一次释放把容量授予了它。两条路径在同一把锁下会合:
1 | func (queue *uploadQueue) cancelReservationWaiter(waiter *reservationWaiter) { |
granted 标志在锁内读写,所以”取消”与”授予”不可能同时成立而互不知情。等待者对象池化,ready channel 随对象复用,回收时先排空。
代价要写清楚:释放容量时最坏要扫完整个等待链表,O(W)。在异常大的等待集合下会增加锁持有时间和尾延迟,但它是循环不是递归,不会增加调用栈深度。顺序上传的快路径还有一个细节:不对局部的上传分片结构体取会逃逸的地址,否则编译器会把它挪到堆上,零分配就破了。
结果:1 MiB 上传队列从 16.1 µs / 1 alloc 到 13.6 µs / 0 allocs。时间下降 15% 的来源不是少了那一次分配,是少了惊群。
六、模式四:分帧与分片迭代化
两个协议路径原来都有”先构建完整中间结构、再逐个发送”的形状。
AnyTLS 的数据帧载荷上限是 65535 字节。大写入要拆帧,上游某个版本的做法是把完整载荷先拷进一个可能很大的临时缓冲。改成在一把序列化锁下逐帧编码、写出、释放:
1 | err := s.withSerializedWrite(deadline, 0, func() error { |
数据帧、控制帧和 padding 合并都直接用池化字节切片编码,不再为每帧创建一个 buffer 对象的 header。锁保证一次 Stream.Write 产生的所有帧保持调用方顺序。wire header 和 padding 语义不变。256 KiB 写从 320 B / 5 allocs 到 0,时间降 12%。
Hysteria2 的 UDP 超过 MTU 要分片。原来先构建一个 fragment 切片,每片再包一个 buffer view;现在逐片创建、发送、释放:
1 | fragmentCount := (len(payload) + udpMTU - 1) / udpMTU |
同一模式套到 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 会诚实地告诉你下一个瓶颈在哪,而下一个瓶颈往往需要完全不同的手段,并且每个手段都要先量化上限、再测、再决定。
十、怎么保证协议没被改坏
这轮改动的每一项都动了封包路径,验证分四层:
- round-trip 测试:每个手写编码器都有”编码后解码回来逐字段相等”的用例,覆盖边界尺寸、空载荷、最大分片数。
- 互操作测试:涉及外部实现的协议,用真实客户端或参考实现对打。
- 分配回归:基准以
-benchmem运行,allocs/op进入对比表;池被清空导致的均摊分配单独注明,不混进”回归”。 - 端到端矩阵:微基准之外跑协议压力矩阵,看 CPU、RSS、吞吐与 P99 有没有系统性变化。写合并的每核吞吐数字来自这里,不来自微基准。
以及一条纪律:基准表里的每个数字都写明机器、版本、样本数和”不包含什么”。上面那张表不包含真实网络、TLS、QUIC 拥塞控制和客户端开销,所以它只能用于同机回归,不能用于宣传。
结语
回头看,五个模式其实是同一个问题的五个切面:
热路径上的每一次分配,都是一次没想清楚所有权的结果。
slice 被装箱,是因为没想清楚 header 归谁持有;等待器不能回收,是因为没想清楚两个异步方谁最后离场;惊群,是因为把”谁能继续”的判断推给了被唤醒者自己;递归分片,是因为让数据量决定了栈的形状;中间拷贝,是因为接口没有表达”这块内存现在归你”。把所有权说清楚,分配自然就没了。
然后 profile 会告诉你,下一个瓶颈是 syscall。那是另一篇文章的事,但决策方法是一样的:先量上限,再测方案,把否决的也写下来。