sync.Pool 是 Go 里一个容易被误用的东西。我经历过"到处加"和"到处都不加"两个阶段,现在有一些自己的判断标准。
它解决什么问题
简单说:减少高频短命对象的分配,从而降低 GC 压力。
典型场景是一个处理请求的函数,每次都要创建一个 buffer:
func handle(w http.ResponseWriter, r *http.Request) {
buf := make([]byte, 0, 32*1024) // 每次分配 32K
// ... 用 buf 干活
}
QPS 一万的话,每秒分配 320MB。GC 就得频繁跑。
用 Pool 之后:
var bufPool = sync.Pool{
New: func() any { return make([]byte, 0, 32*1024) },
}
func handle(w http.ResponseWriter, r *http.Request) {
buf := bufPool.Get().([]byte)
defer bufPool.Put(buf[:0]) // 注意重置长度
// ... 用 buf
}
关键认知:Pool 里的东西随时会消失
这是最重要的一点,文档里写了但很容易忽略:GC 会清空 Pool。
具体来说,每一轮 GC 都会把 Pool 里的对象移到 victim cache,下一轮 GC 如果还没被取走就彻底释放。也就是说 Pool 里的东西存活时间大概是两轮 GC。
所以:
不能把 Pool 当缓存用。 我见过有人用 sync.Pool 缓存数据库连接、缓存解析结果,这是错的。命中率完全不可控,说不定下一秒就没了。
不能依赖 Put 进去的东西还能 Get 到。 Get 可能返回 New 出来的新对象。
放进去的对象不能在别处还被引用。 放回去之后你就当它没了。
我现在的判断标准
加 Pool 之前我会问三个问题:
一、这个对象是不是每次请求都分配、而且大小可观?
小于 1KB 的对象,Pool 带来的收益可能被 Get/Put 本身的开销吃掉。我一般只在 8KB 以上才考虑。
二、它的生命周期是不是很短?
如果对象在请求结束后就没人用了,适合。如果会被塞进某个长期存活的结构里,不适合——那等于把 Pool 的对象偷出去了,后续 Put 回去的其实是别的对象,逻辑就乱了。
三、分配热点是不是真的在 profile 里?
这个最关键。我现在的流程是:先跑 pprof 看到底哪里在分配,再决定要不要 Pool。
go test -bench=. -benchmem -memprofile=mem.out
go tool pprof -alloc_space mem.out
不看 profile 就加 Pool 是瞎猜。我有一次在一个函数里加了 Pool,优化掉 3% 的分配,然后加代码让函数变复杂了,维护成本远超收益,后来删了。
真正值得用的场景
场景一:序列化/反序列化的大 buffer
var jsonBufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func encode(v any) ([]byte, error) {
buf := jsonBufPool.Get().(*bytes.Buffer)
buf.Reset() // 必须 Reset!
defer jsonBufPool.Put(buf)
if err := json.NewEncoder(buf).Encode(v); err != nil {
return nil, err
}
// 注意:这里要拷贝一份,因为 buf 会被复用
out := make([]byte, buf.Len())
copy(out, buf.Bytes())
return out, nil
}
注意那个拷贝。如果直接返回 buf.Bytes(),返回的 slice 指向的内存随时会被下一个用户改写——这是个很隐蔽的 bug。
场景二:压缩/编码的中间缓冲区
gzip 的 writer、protobuf 的 buffer,这类都有比较大的初始分配。
场景三:固定大小的对象池
比如解析器里的 token 对象,大小固定、数量可预测。
几个容易踩的坑
坑一:忘记重置状态
buf := bufPool.Get().(*bytes.Buffer)
// 忘了 buf.Reset()
// 上次的数据还在里面
slice 的话用 buf[:0] 重置长度。但注意容量还是原来的,如果对象里存了敏感数据,得考虑要不要清零。
坑二:Put 了还在用的对象
buf := bufPool.Get().([]byte)
defer bufPool.Put(buf)
result := process(buf)
// 如果 process 返回的 result 内部引用了 buf...
return result // 危险
坑三:defer Put 但中途 panic
其实 defer 会执行,所以 Put 会调用。但如果对象状态是坏的(比如长度被改乱了),下一个使用者会拿到脏数据。我的习惯是 Put 之前统一重置。
坑四:以为它能减少内存占用
它减少的是分配次数和 GC 压力,但内存占用可能反而增加——因为每个 P 都会缓存一份。
// sync.Pool 内部是按 P 分片的
// GOMAXPROCS=8 的话,最多会有 8 份(甚至 16 份,含 victim)
如果单个对象是 1MB,8 核机器上光 Pool 就占 8MB+ 常驻内存。对于内存本来就紧张的服务,这可能是负优化。
替代方案
有些场景用别的更合适:
对象大小固定且很小 → 考虑改成 [N]byte 数组,直接放在栈上,零分配。
只是为了避免切片扩容 → 用 make([]T, 0, n) 预分配容量就够了,不用 Pool。
Go 1.19+ 的 GOMEMLIMIT → 很多 GC 压力问题可以通过设置软内存上限解决,比到处加 Pool 简单:
debug.SetMemoryLimit(512 << 20) // 512MB
Go 1.21+ 的 slices 包 → 一些以前需要复用缓冲区的操作现在可以直接返回新 slice,可读性更好。
我的现状
项目里现在只有三处用了 sync.Pool:
- JSON 编码的大 buffer(每个请求 64KB 起)
- 图片处理的像素缓冲区(几百 KB)
- gzip writer(每次请求新建开销明显)
其它地方都是普通分配。加了 GOMEMLIMIT 之后 GC 表现已经够好,没必要为了几个小对象把代码搞复杂。
一句话总结
sync.Pool 是个优化手段,不是设计模式。 先用 profile 找到真正的分配热点,再加;加完必须重新压测对比。如果收益不明显,删掉——代码简单比省那点分配重要。