sync.Pool 经常出现在性能优化文章里。它可以复用临时对象,减少分配压力。比如高频请求里不断创建 bytes.Buffer,可以考虑用 pool 复用。但 sync.Pool 也很容易被滥用:代码复杂了,性能却没明显提升,甚至因为对象没清理干净引入 bug。
本文用 bytes.Buffer 做例子,讲 sync.Pool 怎么用、什么时候值得用、以及它不保证什么。
最小示例
var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func RenderLine(name string, score int) string {
buf := bufferPool.Get().(*bytes.Buffer)
defer bufferPool.Put(buf)
buf.Reset()
fmt.Fprintf(buf, "%s:%d", name, score)
return buf.String()
}
Get 从池里取对象,池为空时调用 New。用完后 Put 回去。Reset 很重要,否则上一次内容会残留。凡是从 pool 取出的对象,都应该在使用前恢复到干净状态,或者在放回前清理。
注意返回值复制
上面 buf.String() 返回字符串。对 bytes.Buffer 来说,字符串可能引用 buffer 内部数据。为了避免放回 pool 后内容被下次使用影响,更稳妥的方式是复制:
out := buf.String()
return string([]byte(out))
或者在实际项目中直接把内容写到 io.Writer,而不是返回依赖 buffer 生命周期的数据。对象池最怕“对象已经归还,但外面还拿着它的一部分”。
适合什么场景
适合:
- 高频临时对象
- 对象创建成本明显
- 生命周期短
- 可以彻底重置
- benchmark 证明减少分配有收益
不适合:
- 低频代码
- 长生命周期对象
- 带复杂状态的业务对象
- 需要确定缓存多少对象
- 为了“看起来专业”而优化
sync.Pool 不是普通缓存。Go 运行时可能在 GC 时清掉 pool 里的对象,所以你不能依赖它保存状态。它只是减少临时分配的工具。
用 benchmark 判断
先写普通版本:
func RenderLinePlain(name string, score int) string {
var buf bytes.Buffer
fmt.Fprintf(&buf, "%s:%d", name, score)
return buf.String()
}
benchmark:
func BenchmarkRenderLinePlain(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = RenderLinePlain("alice", 90)
}
}
func BenchmarkRenderLinePool(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = RenderLine("alice", 90)
}
}
运行:
go test -bench=. -benchmem
如果 pool 版本分配少很多且代码仍然清楚,可以考虑保留。如果差距很小,普通版本更好。优化要用数据说话。
并发安全不等于对象安全
sync.Pool 本身并发安全,多个 goroutine 可以同时 Get/Put。但从池里拿出的对象不能同时被多个 goroutine 共享,除非对象本身也并发安全。比如一个 *bytes.Buffer 被两个 goroutine 同时写,仍然会数据竞争。
正确模式是:一个 goroutine 取出对象,独占使用,用完清理并归还。归还后不要再访问。
不要 Put nil 或错误类型
sync.Pool 存的是 any。如果不同代码往同一个 pool 放不同类型,取出时会 panic。通常一个 pool 只服务一种具体类型,并且定义在离使用位置近的包里。不要建一个全局 ObjectPool 到处塞东西。
var jsonBufferPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
名字明确,使用边界也明确。
放回前限制容量
bytes.Buffer 可能因为一次大请求扩容到很大。如果直接放回 pool,后续小请求会复用一个很大的底层数组,导致内存保留时间变长。可以在 Put 前判断容量:
func putBuffer(buf *bytes.Buffer) {
if buf.Cap() > 64*1024 {
return
}
buf.Reset()
bufferPool.Put(buf)
}
使用:
buf := bufferPool.Get().(*bytes.Buffer)
defer putBuffer(buf)
这不是绝对规则,阈值要看场景。关键是理解:pool 复用能减少分配,也可能让大对象停留更久。优化不是只看 allocs/op,还要看常驻内存和请求形状。
和 JSON 编码结合
有些人会用 pool 缓冲 JSON 响应:
func encodeJSON(value any) ([]byte, error) {
buf := bufferPool.Get().(*bytes.Buffer)
defer putBuffer(buf)
if err := json.NewEncoder(buf).Encode(value); err != nil {
return nil, err
}
out := append([]byte(nil), buf.Bytes()...)
return out, nil
}
这里必须复制 buf.Bytes(),因为 buffer 会被归还。很多 pool bug 都来自返回了内部 slice。只要对象要放回 pool,就不要把它的内部可变数据交给外部长期持有。
先用 pprof 找热点
sync.Pool 应该出现在性能证据之后,而不是之前。比较稳的流程是:先用 pprof 或 benchmark 找到高频分配,再写 pool 版本,再用 -benchmem 验证分配下降,最后确认代码复杂度可以接受。
如果热点不在对象分配,pool 不会带来明显收益。比如慢在数据库、网络或大 JSON 编码,复用一个 buffer 可能只是微小优化。工程时间应该花在真正瓶颈上。
排查对象是否清理干净
使用 pool 后,测试要覆盖“连续使用两次”的场景。因为很多残留状态只有第二次取出同一个对象时才会暴露:
func TestRenderLineDoesNotLeakPreviousContent(t *testing.T) {
first := RenderLine("alice", 90)
second := RenderLine("bob", 7)
if strings.Contains(second, "alice") {
t.Fatalf("second output contains previous content: %q after %q", second, first)
}
}
这个测试看起来简单,但能提醒你每次使用前必须 Reset。对于更复杂对象,比如带 map、slice、状态字段的结构,Reset 函数要把所有可变字段恢复干净。
Pool 不负责生命周期语义
对象池只是性能工具,不应该改变业务对象的所有权。比如请求对象、用户对象、订单对象通常不适合放 pool,因为它们带有明确业务含义,复用后很容易泄露旧字段。适合 pool 的对象往往是纯技术缓冲区:buffer、临时编码器、压缩器的包装结构。
如果你很难解释对象什么时候拿出、什么时候归还、归还后谁还可能引用它,那就不要用 pool。清晰的生命周期比省一点分配更重要。
小结
sync.Pool 可以复用临时对象,减少分配压力,但它不是普通缓存,也不保证对象一直存在。使用时要清理状态,避免归还后继续引用,确保对象不被多个 goroutine 同时使用。
对初学者来说,先写清楚代码,再用 benchmark 和 -benchmem 判断是否需要 pool。没有数据支撑的对象池,往往只是把简单代码变复杂。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。