Go sync.Pool 入门:复用临时对象前先看清边界

用 bytes.Buffer 复用示例讲 sync.Pool 的基本用法、适用场景、GC 行为和为什么不要过早优化。

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。没有数据支撑的对象池,往往只是把简单代码变复杂。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页