浪白的博客の小站
首页项目归档照片墙音乐灵境说说杂谈友链关于
封面

sync.Pool 到底什么时候该用

写作时间:2026-07-04 22:30:00
# Go
# 性能
# 内存

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:

  1. JSON 编码的大 buffer(每个请求 64KB 起)
  2. 图片处理的像素缓冲区(几百 KB)
  3. gzip writer(每次请求新建开销明显)

其它地方都是普通分配。加了 GOMEMLIMIT 之后 GC 表现已经够好,没必要为了几个小对象把代码搞复杂。

一句话总结

sync.Pool 是个优化手段,不是设计模式。 先用 profile 找到真正的分配热点,再加;加完必须重新压测对比。如果收益不明显,删掉——代码简单比省那点分配重要。

avatar

浪白

邪恶的浪白

RECOMMENDED

Composer 用了这么多年,几个我认为值得坚持的习惯

2026-04-27 19:15:00

context 传参这件事,我踩过的三个坑

2026-05-06 20:05:00

Go 的 error 处理,用久了反而觉得还行

2026-04-19 22:15:00

Table of Contents