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 + 早返回」这个结构有关。