Go 的 sync.Pool 实战:复用对象把 GC 压力打下来
高并发的 Go 服务里,有一类性能问题很隐蔽:代码逻辑没毛病,QPS 也不算高,但 GC 频繁触发、P99 延迟毛刺不断。用 pprof 一看,大量时间花在内存分配和垃圾回收上。罪魁祸首常常是------你在热路径上反复 new 又扔掉同一种临时对象。
这篇讲 sync.Pool 怎么复用这些临时对象、怎么用对(它有几个非常容易踩的坑),以及什么时候压根不该用它。
先看问题:每个请求都分配一个大 buffer
一个很典型的场景:HTTP handler 里需要一个 buffer 拼接响应,或者做 JSON 序列化的中转:
go
func handler(w http.ResponseWriter, r *http.Request) {
// 每个请求都新建一个 buffer
buf := make([]byte, 0, 4096)
buf = append(buf, "prefix:"...)
buf = append(buf, r.URL.Path...)
// ... 一堆拼接
w.Write(buf)
}
单看没问题。但如果这个 handler 每秒被调用几万次,就意味着每秒新建几万个 4KB 的切片,用完立刻变成垃圾。这些短命对象会快速填满堆,逼着 GC 频繁运行,而 GC 的 STW(stop-the-world)阶段会直接体现为请求延迟毛刺。
核心矛盾:这些 buffer 生命周期极短、形状完全一样、用完就扔。与其反复 new/GC,不如建一个池子循环利用它们。 这正是 sync.Pool 的用武之地。
sync.Pool 基本用法
sync.Pool 是一个可以存放临时对象的池子。Get 拿一个出来(池空时调用 New 造一个),用完 Put 放回去下次复用:
go
import (
"bytes"
"sync"
)
// New 定义池空时怎么造新对象
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func handler(w http.ResponseWriter, r *http.Request) {
// 从池里拿一个 buffer
buf := bufPool.Get().(*bytes.Buffer)
// 关键:放回前必须重置,否则拿到的是上次的脏数据
buf.Reset()
defer bufPool.Put(buf) // 用完放回池子
buf.WriteString("prefix:")
buf.WriteString(r.URL.Path)
w.Write(buf.Bytes())
}
三个要点:Get 返回的是 any,要类型断言;New 只在池空时才调用;Put 之前(或 Get 之后)一定要 Reset,把上一个使用者留下的数据清掉。
在压测里,这种改法通常能让分配次数下降一个数量级,GC 频率和延迟毛刺明显改善。你可以用 benchmark 对比:
go
func BenchmarkWithPool(b *testing.B) {
b.ReportAllocs() // 报告每次操作的分配次数
for i := 0; i < b.N; i++ {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
buf.WriteString("hello world")
_ = buf.String()
bufPool.Put(buf)
}
}
跑 go test -bench=. -benchmem,对比不用池的版本,allocs/op 会从 1~2 降到接近 0。
坑一:忘了 Reset,拿到脏数据
这是最常见的 bug。sync.Pool 存的是「上一个人用完原样放回」的对象,里面还留着旧内容。如果你 Get 出来直接用,就会读到别的请求的残留数据------在并发下这是极难复现的串数据 bug。
go
// 错误:没 Reset,buf 里可能有上个请求的内容
buf := bufPool.Get().(*bytes.Buffer)
buf.WriteString("new data") // 拼在旧数据后面!
// 正确:先 Reset
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
buf.WriteString("new data")
对切片同理,拿出来要 buf = buf[:0] 把长度归零(保留底层容量)。
坑二:Put 进去的对象还被别处引用
Put 意味着「我不再用它了,交给池子」。如果你 Put 之后还持有这个对象的引用继续读写,就会和下一个 Get 到它的人产生数据竞争:
go
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
buf.WriteString("data")
result := buf.Bytes() // 危险:Bytes() 返回的是底层数组的引用
bufPool.Put(buf) // 放回后,别人可能拿到并覆写
process(result) // result 可能已经被改!
bytes.Buffer.Bytes() 返回的切片指向 buffer 内部数组,Put 之后这块内存归池子了。要么在 Put 前把数据 copy 出来,要么等真正用完再 Put。规则:一旦 Put,就当这个对象已经不属于你了。
坑三:池里的对象随时会被 GC 清空
sync.Pool 不保证对象一直在。它的设计就是「临时缓存」------每轮 GC 都可能清空池子里的对象(Go 1.13 后改成两级缓存,能扛过一轮 GC,但你不能依赖)。
这带来两个约束:
- 不能用它做需要「一定存在」的资源池 ,比如数据库连接、长连接。那些要用专门的连接池(
database/sql自带,或者 channel 实现的固定容量池),它们保证连接不会被 GC 悄悄回收。 New必须能随时造出可用对象 。因为你无法预期Get会命中缓存还是走New。
一句话:sync.Pool 适合「有更好,没有就现造,造起来不贵」的临时对象,不适合「必须复用、创建昂贵、数量要受控」的资源。
坑四:小对象用池反而更慢
sync.Pool 本身有开销------内部要处理 per-P 的本地池、跨 P 偷取、GC 清理。如果你池化的对象很小(比如一个只有几个字段的 struct),Get/Put 的开销可能比直接 new 还大,得不偿失。
判断标准:只有当对象「大」或者「分配频率极高」时,池化才划算。 拿不准就写 benchmark 用 -benchmem 对比 allocs/op 和 ns/op,别凭感觉。典型值得池化的:大 buffer(几 KB 以上)、序列化中转对象、gzip.Writer 这类构造成本高的对象。
一个更完整的例子:池化带容量的 byte slice
buffer 之外,直接池化 []byte 也很常见,比如做加密、压缩、IO 的中转缓冲:
go
var slicePool = sync.Pool{
New: func() any {
// 返回指针,避免 Put 时切片 header 被复制装箱又产生分配
b := make([]byte, 0, 8192)
return &b
},
}
func compress(data []byte) []byte {
ptr := slicePool.Get().(*[]byte)
buf := (*ptr)[:0] // 长度归零,保留 8192 容量
defer func() {
*ptr = buf // 写回可能被 append 扩容后的切片
slicePool.Put(ptr)
}()
buf = append(buf, doCompress(data)...)
// 注意:要返回给调用方的数据得 copy 一份,不能直接返回 buf
out := make([]byte, len(buf))
copy(out, buf)
return out
}
这里两个细节:池化切片存指针 (*[]byte)而不是切片本身,避免 Put(slice) 时切片头被装箱进 interface 又产生一次分配;返回给外部的数据必须 copy,因为 buf 马上要还回池子。
小结
sync.Pool复用生命周期短、形状一致的临时对象,把「反复 new/GC」变成「循环利用」,显著降低 GC 压力和延迟毛刺。- 用法三件套:
New定义兜底构造、Get后类型断言、Put前Reset/[:0]清脏数据。 - 四个坑:①忘 Reset 读到脏数据;②Put 后仍持有引用导致数据竞争;③池对象随时被 GC 清空,不能当连接池用;④小对象池化反而更慢。
- 池化切片存指针、返回数据要 copy。
- 判断口诀:「对象够大 or 分配够频繁,且没有它也能现造」才池化 ;拿不准就
go test -benchmem用数据说话,别凭感觉优化。