编程 crypto/tls 的 Read/Write 每次都白付一次 deferreturn:2 万连接 profile 里占 2.7% CPU

2026-11-06 20:03:33

crypto/tls 的 Read/Write 每次都白付一次 deferreturn:2 万连接 profile 里占 2.7% CPU

Issue 地址:golang/go#82754(JGamboa 提交)

环境:go1.27.3 darwin/arm64,linux/amd64 上同样能复现。go env 无关,问题出在标准库的编译输出上。

现象

一个向 20,000 条 TLS 连接广播的 WebSocket 服务,profile 里整机 2.7% 的 CPU 落在 crypto/tls.(*Conn).handshakeContext 下的 runtime.(*_panic).nextDefer / nextFrame——而这些连接的握手早就完成很久了。

调用链是:(*Conn).Write 和 (*Conn).Read 每次都调 c.Handshake(),后者调 handshakeContext。handshakeContext 的快路径在碰到任何 defer 之前就已经返回:

if c.isHandshakeComplete.Load() {
    return nil
}

但编译器并没有对该函数的 defer 做 open-code(开放编码)。函数入口仍然挂了 defer 相关的收尾逻辑,所以哪怕这次走的是早返回,返回时也要过一次 runtime.deferreturn。

编译器输出

直接看标准库的编译结果:

$ go build -gcflags='crypto/tls=-d=defer' crypto/tls
.../src/crypto/tls/conn.go:1530:2: stack-allocated defer
.../src/crypto/tls/conn.go:1540:3: stack-allocated defer
.../src/crypto/tls/conn.go:1549:2: stack-allocated defer
.../src/crypto/tls/conn.go:1559:2: stack-allocated defer

四处 defer 全是 stack-allocated,也就意味着没有开放编码,退栈路径必须走运行时。这四处是没法绕开的——它们在慢路径上确实有用,问题只是它们的存在让快路径也一起变贵了。

基准

用同样形状的函数做了独立复现:四个 defer、其中一个是有条件的、开头一个原子读的早返回,握手状态标记为已完成。

BenchmarkCurrent-10   7.2 ns/op   // 照现在的 handshakeContext 写
BenchmarkSplit-10     2.1 ns/op   // 快检查放在无 defer 的函数里,慢路径单独一个函数

差距 3 倍多。单次看不多,但这是每条连接的每次 Read 和每次 Write 都要付的固定成本。20,000 条连接、每条连接持续收发,这部分就摊成了整机 2.7% 的 CPU——纯属白烧。

提议的写法

把原子检查挪进一个不含 defer 的小函数,原来的函数体整体作为慢路径:

func (c *Conn) handshakeContext(ctx context.Context) error {
    if c.isHandshakeComplete.Load() {
        return nil
    }
    return c.handshakeContextSlow(ctx)
}

handshakeContextSlow 就是现在 handshakeContext 的完整实现,四处 defer 原样保留。快路径上只剩一次 atomic load 和一次普通函数调用,不再触碰 deferreturn。Issue 作者表示愿意提 CL。

需要注意的边界

这个优化只对特定形态的负载有意义:万级长连接、每条连接上频繁读写。这时候 per-Read/per-Write 的固定开销会被放大成可观测的 CPU 占比。

普通的 HTTPS 请求场景基本不用在意。一次请求一次握手,handshakeContext 本来就要走完整流程,7.2ns 和 2.1ns 的差别在请求总耗时里看不出来。反过来,如果服务不是长连接广播这种形态,也不用为了这点开销去改代码或等版本。

自己验证的方法

两个动作就够:

一是查标准库的 defer 情况。对任意版本的 Go 都可以跑:

go build -gcflags='crypto/tls=-d=defer' crypto/tls

看目标函数里有没有 stack-allocated defer 的输出。有,就说明这个函数的 defer 没被开放编码。

二是在 pprof 里对着火的地方看。抓一份 CPU profile,检索 runtime.(*_panic).nextDefer,如果它挂在 crypto/tls.(*Conn).handshakeContext 下面,并且调用量跟连接上的读写次数对得上,那命中的就是这条路径。也可以顺手确认一下这些连接是不是早就握手完成了——如果握手确实还在进行,那 deferreturn 就不是这次开销的来源。

复现脚本本身用的是同形状的合成函数,所以基准数字(7.2ns/op vs 2.1ns/op)跟具体业务无关,只跟「函数里有未开放编码的 defer + 早返回」这个结构有关。

复制全文 生成海报 Go TLS 性能优化

推荐文章

程序员茄子在线接单