---
title: "代理节点端热路径的零分配改造：原则、五个模式与实测"
source: https://www.embermoth.blog/2026/09/27/proxy-node-hot-path-zero-allocation/
date: 2026-09-27
updated: 2026-09-27
tags: [golang, performance, zero-allocation, gc, sync-pool, backpressure, proxy, benchmark]
categories: [开发笔记]
---

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

> **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`：

```go
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 常驻堆上被复用：

```go
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）排队，由发送循环异步写出。调用方等结果时可能超时、连接可能关闭。问题来了：等待器对象是池化的，**谁来回收它**？

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

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

```go
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 一起醒来，重新抢锁、重新检查、大部分再次睡回去。上传并发高的时候，这是一场惊群。

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

```go
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` 取消、队列关闭或停顿超时后要离场，而此刻可能刚好有一次释放把容量授予了它。两条路径在同一把锁下会合：

```go
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 字节。大写入要拆帧，上游某个版本的做法是把完整载荷先拷进一个可能很大的临时缓冲。改成在一把序列化锁下逐帧编码、写出、释放：

```go
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；现在逐片创建、发送、释放：

```go
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。那是另一篇文章的事，但决策方法是一样的：先量上限，再测方案，把否决的也写下来。
