Go语言sync.Pool详解:对象复用、GC机制与实战避坑
很多性能问题并不是“计算太慢”,而是对象创建得太勤。
在高并发 Go 服务里,如果每个请求都创建临时 bytes.Buffer、编码器、解析上下文或者几十 KB 的工作缓冲区,请求结束后又立刻丢弃,CPU 会花掉一部分时间做内存分配,GC 还得继续处理这些短命对象。
sync.Pool 就是为这类场景准备的。
但有一个前提必须先说清楚:
sync.Pool是临时对象复用机制,不是传统意义上的对象池,更不是缓存。
当前 Go 标准库明确规定:放进 Pool 的对象可能在任何时候被运行时自动移除;Get 甚至可以直接忽略池中已有对象,把它当作空池处理。它保证线程安全,却不保证对象一定还在。
理解这一点,基本就理解了 sync.Pool 一半的设计。
最基本的 sync.Pool
sync.Pool 的 API 很小,核心只有三个东西:
type Pool struct {
New func() any
}
func (p *Pool) Get() any
func (p *Pool) Put(x any)
最常见的写法是:
package main
import (
"bytes"
"fmt"
"sync"
)
var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func main() {
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
buf.WriteString("hello sync.Pool")
fmt.Println(buf.String())
bufferPool.Put(buf)
}
第一次调用 Get() 时,如果池中没有可用对象,就调用 New 创建:
Get()
↓
Pool 里有对象?
├── 有 → 返回旧对象
└── 没有 → 调用 New()
使用完成以后:
bufferPool.Put(buf)
对象不会立即进入垃圾回收流程,而是获得了再次被使用的机会。
官方示例也建议 New 通常返回指针类型,因为指针放入 any 接口时通常不需要为了接口值额外分配。
不过真正值得研究的并不是这两个 API,而是:
它为什么能比“每次 new 一个”更划算?
Pool 真正减少的是分配频率
假设一个 HTTP 服务每秒处理 5 万次请求,每个请求都需要一个临时 Buffer:
func handle() {
buf := new(bytes.Buffer)
// 使用 buf
}
对象本身可能没多贵。
问题出在频率。
大量这样的短命对象会形成:
请求
↓
创建对象
↓
使用
↓
对象失去引用
↓
等待 GC
↓
GC 扫描、回收
如果改成:
请求 A
↓
Get
↓
Buffer
↓
使用
↓
Put
↓
请求 B → Get → 同一个 Buffer
一次内存分配就可能服务很多次请求。
因此 sync.Pool 的收益一般来自两个方向:
减少 malloc 次数
↓
减少短命堆对象
↓
降低 GC 压力
↓
降低 CPU 和内存抖动
所以判断一个东西是否适合放进 sync.Pool,不能只问:
“创建这个对象贵不贵?”
还应该问:
“这个对象是不是高频创建、生命周期很短,而且可以安全重置后复用?”
第二个问题往往更重要。
为什么 sync.Pool 不需要一把全局锁
如果最简单地实现一个线程安全对象池,很容易写成:
type Pool struct {
mu sync.Mutex
items []*Object
}
每次获取:
mu.Lock()
obj := items[len(items)-1]
items = items[:len(items)-1]
mu.Unlock()
并发不高时没什么问题。
并发量起来之后,所有 goroutine 都在争抢同一把锁:
G1 ──┐
G2 ──┼──> Mutex ──> Pool
G3 ──┤
G4 ──┘
这显然不是标准库想要的效果。
当前 sync.Pool 的实现采用了与 Go 调度器 P 绑定的本地 Pool。源码里的核心结构大致可以理解为:
sync.Pool
│
├── P0
│ ├── private
│ └── shared
│
├── P1
│ ├── private
│ └── shared
│
├── P2
│ ├── private
│ └── shared
│
└── P3
├── private
└── shared
每个 P 都有自己的局部存储。
源码中的 poolLocalInternal 就包含:
type poolLocalInternal struct {
private any
shared poolChain
}
其中:
private主要由当前 P 使用;shared可以存放更多对象;- 当前 P 没找到对象时,还可以尝试从其他 P 的共享队列中“偷”一个。
源码中的 Get 路径大致可以抽象为:
自己的 private
↓ 没有
自己的 shared
↓ 没有
其他 P 的 shared
↓ 没有
victim cache
↓ 没有
New()
实际实现会临时将 goroutine 固定到当前 P,以安全访问对应的本地 Pool。当前 Go 源码中仍能看到 private、shared、per-P local pool、对象窃取以及 victim 缓存这些机制。
这也是 sync.Pool 在高并发下仍然比较轻量的一个关键原因:
大部分 Get/Put 并不需要所有 goroutine 围着一把全局 Mutex 抢。
不过这些都是实现细节,不是 API 契约。业务代码不要依赖“对象一定存在某个 P”“一定经过 victim cache”之类的行为。
GC 来了以后,Pool 会发生什么
这是 sync.Pool 最容易被误解的地方。
很多人把它想象成:
Put(obj)
obj 永久待在 Pool
直到再次 Get()
实际上完全不是这样。
标准库直接规定:
Pool 中保存的对象可能随时被自动删除。
当前实现中还有一个很有意思的设计:victim cache。
GC 开始时,Pool 大致会做这样的事情:
GC 前:
local:
[A] [B] [C]
victim:
[X] [Y]
GC 开始:
旧 victim → 丢弃
local:
[A] [B] [C]
↓
victim:
[A] [B] [C]
新的 local:
空
也就是说,当前实现没有简单粗暴地在一次 GC 时把 Pool 全清空。
原来的 local 会进入 victim cache,仍然有机会在后续 Get() 时被重新利用。
如果一直没人使用,后续 GC 再把旧 victim 清掉。对应逻辑可以直接在 poolCleanup 中看到:旧 victim 被清理,当前 local 被移动到 victim。
这样做解决了一个现实问题。
假设系统刚刚经历一次流量峰值:
10 万 QPS
↓
Pool 中积累大量临时对象
随后流量下降:
1 万 QPS
如果 Pool 永远不释放对象,那一次流量峰值就可能把内存长期顶在高位。
sync.Pool 的思路恰恰相反:
忙的时候自动扩大复用能力,闲下来以后允许 GC 把多余对象带走。
这也是它特别适合临时 Buffer 的原因。
一个更接近生产环境的 Buffer Pool
最简单的 Buffer Pool:
var bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
使用:
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
// 使用
bufferPool.Put(buf)
能工作,但生产环境还差一步。
假设正常请求 Buffer 只有:
4 KB
8 KB
16 KB
某次请求突然处理了一份 20 MB 的数据:
buf.Write(bigData)
bytes.Buffer 扩容到了几十 MB。
请求结束:
bufferPool.Put(buf)
于是这个几十 MB 的底层数组也进入了 Pool。
之后业务请求明明只需要几 KB,这块大内存却可能继续被保留。
所以实际项目更适合增加一个容量上限:
package bufferpool
import (
"bytes"
"sync"
)
const maxRetainedBufferSize = 64 << 10 // 64 KB
var pool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func Get() *bytes.Buffer {
buf := pool.Get().(*bytes.Buffer)
buf.Reset()
return buf
}
func Put(buf *bytes.Buffer) {
if buf == nil {
return
}
if buf.Cap() > maxRetainedBufferSize {
// 太大的 Buffer 不复用,直接交给 GC
return
}
buf.Reset()
pool.Put(buf)
}
这个小判断很重要:
if buf.Cap() > maxRetainedBufferSize {
return
}
对象池优化最怕出现一种情况:
为了少分配一点内存,最后却长期留住了远超实际需求的大对象。
Pool 不是越满越好。
第一个大坑:忘记 Reset
sync.Pool 不负责初始化对象状态。
例如:
buf := bufferPool.Get().(*bytes.Buffer)
buf.WriteString("password=123456")
bufferPool.Put(buf)
另一个请求获取:
buf := bufferPool.Get().(*bytes.Buffer)
fmt.Println(buf.String())
如果拿到的恰好是刚才那个对象,旧数据仍然存在。
所以这类有状态对象一定要建立明确约定:
buf := pool.Get().(*bytes.Buffer)
buf.Reset()
或者统一封装:
func GetBuffer() *bytes.Buffer {
buf := pool.Get().(*bytes.Buffer)
buf.Reset()
return buf
}
不要把清理责任散落在几十个业务调用点里。
对于复杂对象,Reset 可能不只是:
buf.Reset()
还可能包括:
obj.UserID = 0
obj.Token = ""
obj.Items = obj.Items[:0]
obj.Err = nil
obj.Context = nil
尤其要注意指针字段。
如果:
obj.BigData = hugeObject
Put 之前一直没有:
obj.BigData = nil
Pool 就可能间接把一个很大的对象图一起留住。
第二个大坑:Put 以后继续使用
看下面这段代码:
func Encode(v any) []byte {
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
fmt.Fprint(buf, v)
result := buf.Bytes()
bufferPool.Put(buf)
return result
}
看起来非常漂亮。
实际上有严重问题。
buf.Bytes() 返回的是 Buffer 内部存储的切片,并没有复制底层数据。
随后:
bufferPool.Put(buf)
另一个 goroutine 可能立刻:
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset()
buf.Write(...)
于是前一个调用返回的 result 所指向的数据就可能被修改。
时间线变成:
G1:
Get Buffer
写入 hello
result = buf.Bytes()
Put Buffer
↓
G2: Get 同一个 Buffer
Reset
写入 world
↓
G1:
继续读取 result
这不是 sync.Pool 的线程安全问题。
Pool 自己是线程安全的。
问题在于:
对象放回 Pool 以后,调用者就应该视为已经失去这个对象的所有权。
如果必须返回数据,需要先复制:
result := append([]byte(nil), buf.Bytes()...)
bufferPool.Put(buf)
return result
或者:
result := bytes.Clone(buf.Bytes())
Pool 管的是对象的借出和归还,不会帮你管理对象内部数据的生命周期。
第三个大坑:把数据库连接也放进去
看到“Pool”这个名字,很容易产生这样的想法:
var dbPool sync.Pool
然后把:
- 数据库连接
- TCP 连接
- HTTP 长连接
- 文件句柄
都往里面塞。
这通常是不对的。
因为连接池一般需要:
最大连接数
最小空闲连接数
获取超时
空闲超时
连接健康检查
连接生命周期
连接关闭
排队策略
而 sync.Pool 的语义只有:
这里有几个临时对象,
有机会就复用,
没机会运行时可以直接扔掉。
它连“这个对象一定还能 Get 回来”都不保证。
所以:
临时 Buffer → sync.Pool
临时编码对象 → sync.Pool
解析上下文 → sync.Pool
数据库连接 → database/sql
HTTP 连接 → http.Transport
需要固定容量的资源池 → 专门的 Pool 实现
名字里都有 Pool,解决的问题完全不同。
第四个大坑:把 Pool 当缓存
例如:
pool.Put(userInfo)
然后期待:
userInfo := pool.Get()
还能把之前的数据拿回来。
这是从设计上就错了。
官方文档明确说明:
Get()
可以任意选择一个对象,也可以直接忽略 Pool,把它视为空池;调用者不能假设 Put 与未来某一次 Get 之间存在对应关系。
所以 sync.Pool 不能表达:
key → value
也不能表达:
我 Put 进去 100 个
以后一定还能拿到 100 个
它表达的只是:
如果恰好有一个可以复用的临时对象,
那就拿来用。
如果业务正确性依赖对象还在 Pool 里,设计本身就已经出问题了。
第五个大坑:什么东西都往 Pool 里塞
对象池不是免费的。
例如原本:
func f() {
x := SmallObject{}
// use x
}
编译器可能直接把对象放在栈上。
函数返回:
栈帧消失
成本非常低。
你强行改成:
x := pool.Get().(*SmallObject)
...
pool.Put(x)
反而增加了:
- Pool Get/Put;
any接口操作;- 类型断言;
- 对象状态重置;
- 更长的对象生命周期;
- 堆内存占用;
- Pool 自身管理成本。
尤其全局 Pool 中保存的对象必须能够跨调用继续存在,这会改变对象原本非常短的生命周期。
所以:
能复用,不代表值得复用。
几十字节的小对象、创建成本极低的对象、原本可以栈分配的对象,往往没必要上 sync.Pool。
真正适合它的通常是:
高频创建
+
生命周期短
+
创建或初始化存在成本
+
包含可复用内存
+
并发调用量较大
这五项满足得越多,Pool 的价值通常越明显。
不要猜性能,直接 Benchmark
是否应该使用 Pool,很适合用 Benchmark 判断。
例如比较 Buffer:
package poolbench
import (
"bytes"
"sync"
"testing"
)
var (
sink int
pool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
)
func BenchmarkWithoutPool(b *testing.B) {
payload := bytes.Repeat([]byte("a"), 1024)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var buf bytes.Buffer
buf.Grow(32 << 10)
buf.Write(payload)
sink = buf.Len()
}
}
func BenchmarkWithPool(b *testing.B) {
payload := bytes.Repeat([]byte("a"), 1024)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
buf := pool.Get().(*bytes.Buffer)
buf.Reset()
buf.Grow(32 << 10)
buf.Write(payload)
sink = buf.Len()
pool.Put(buf)
}
}
执行:
go test -bench=. -benchmem -count=5
比起只盯着:
ns/op
使用 Pool 时我更建议同时观察:
B/op
allocs/op
因为 sync.Pool 最直接的目标就是降低内存分配压力。
如果:
allocs/op
几乎没下降,吞吐也没有明显改善,那么这层 Pool 很可能只是增加了代码复杂度。
反过来,如果一个热点路径每秒执行几十万次,并且 Pool 能明显压低:
B/op
allocs/op
GC CPU
它就很有价值。
不要因为“对象池听起来像性能优化”就提前优化。
用数据决定。
并发安全不等于对象安全
官方保证:
sync.Pool
本身可以被多个 goroutine 并发使用。
例如:
go func() {
obj := pool.Get()
defer pool.Put(obj)
}()
go func() {
obj := pool.Get()
defer pool.Put(obj)
}()
Pool 的数据结构没有问题。
但如果你这样:
obj := pool.Get().(*Object)
go use(obj)
pool.Put(obj)
就危险了。
因为:
goroutine A ──────────────> use(obj)
↑
│
Put(obj)
│
↓
goroutine B → Get(obj) → 修改 obj
Pool 的线程安全只能保证:
Get / Put 操作安全
它不能保证:
你从里面取出来的业务对象可以被多个 goroutine 同时修改
比较合理的心智模型是:
Get
↓
获得对象所有权
↓
独占使用
↓
清理状态
↓
Put
↓
放弃对象所有权
一旦 Put,后面的代码最好连这个变量都不要再碰。
sync.Pool 的内存模型保证
sync.Pool 还有一个容易被忽略的保证。
官方内存模型规定:
Put(x)
会 synchronizes-before 后续某个:
Get()
返回同一个 x。
New 创建对象与 Get 返回这个对象之间也有对应的同步保证。
这意味着对象正确初始化后放入 Pool,再被另一个 goroutine 获取时,初始化写入具有相应的内存可见性保证。
但这仍然不意味着可以:
Put(x)
以后继续并发修改 x。
所有权规则依旧成立。
哪些对象最值得 Pool
一个很实用的判断表:
| 对象 | 是否适合 sync.Pool | 原因 |
|---|---|---|
bytes.Buffer |
很适合 | 高频、可 Reset、底层内存可复用 |
临时 []byte 包装对象 |
适合 | 可降低大批量内存分配 |
| JSON 编码临时上下文 | 视实现而定 | 高频调用时可能有价值 |
| 压缩/解压临时对象 | 经常适合 | 初始化和内部缓冲成本较高 |
| 日志格式化 Buffer | 很适合 | 标准库也采用类似思路 |
| 几十字节简单 Struct | 通常不值得 | 分配成本可能比 Pool 操作还低 |
| 用户 Session | 不适合 | 属于业务缓存 |
| 数据库连接 | 不适合 | 需要明确生命周期和容量管理 |
| TCP 连接 | 不适合 | 需要健康检查、超时等机制 |
| 必须长期存在的数据 | 不适合 | Pool 中对象可能被 GC 删除 |
Go 官方文档本身就以 fmt 包内部使用的动态临时输出 Buffer 作为合适的 Pool 场景:高负载时扩张,负载下降时又能收缩。
这其实已经把 sync.Pool 的最佳使用场景描述得非常准确了。
一个简单的决策方法
以后遇到一个对象,不确定要不要放进 sync.Pool,可以按这个顺序判断:
这个对象是不是高频创建?
│
否 ──> 不用 Pool
│
是
↓
创建过程中是否产生明显内存分配?
│
否 ──> 大概率不用
│
是
↓
对象能否干净地 Reset?
│
否 ──> 谨慎使用
│
是
↓
对象是否只是临时工作对象?
│
否 ──> 考虑真正的缓存/资源池
│
是
↓
Benchmark 是否证明有收益?
│
否 ──> 删除 Pool
│
是
↓
使用 sync.Pool
最后这一关最重要:
Benchmark 是否证明有收益?
Pool 属于性能工具,不属于代码架构的默认配置。
对象复用真正难的不是 Get 和 Put
sync.Pool 的 API 简单到几分钟就能学会:
obj := pool.Get()
pool.Put(obj)
真正困难的是对象生命周期。
需要想清楚的是:
- 什么状态必须 Reset;
- 哪些引用必须断开;
- Put 以后还有没有代码持有对象;
- 大对象是否应该继续保留;
- 这个对象原本是不是已经可以栈分配;
- Pool 是否真的降低了
allocs/op; - 业务是否错误地依赖 Pool 保存对象。
把这些问题处理好以后,sync.Pool 才真正体现出“对象复用”的价值。
它并不是让 Go 程序从此“不创建对象”。
它做的是另一件更实际的事情:
昂贵的对象已经创建了?
那就在合适的情况下,
尽量多用几次。
而当系统不再需要这些对象时,又把最终决定权交还给 GC。
这才是 sync.Pool 设计得漂亮的地方。
参考资料
- Go
sync.Pool标准库文档:定义了Get、Put、New、并发安全、GC 清理以及内存模型语义。 - Go
sync/pool.go源码:可以看到 per-P local pool、private、shared、对象窃取与 victim cache 的当前实现。 - 当前稳定版 Go 1.27 于 2026 年 8 月 19 日发布;本文涉及的标准库文档对应 Go 1.27。