面试里问 sync.Pool,多数人能背出「临时对象复用、减轻 GC 压力」,但真正能区分候选人的是细节:GC 到底怎么清空 Pool?清空之后马上 Put 进去的对象,会不会在下一轮 GC 又被清掉?为什么必须调用 runtime.GC() 两次才能观察到一个对象从 Pool 中彻底消失?Put 里的对象什么时候会被「私吞」而不是进入共享队列?
本文从 Go 运行时源码(以 Go 1.21 的 pool.go / poolqueue.go 为准,兼容 1.19+ 的新实现)出发,把 sync.Pool 在 GC 前后的状态机拆开讲清楚,并给出可运行的验证代码、实测输出和常见误用报错。核心结论先行:GC 清空的是「清理时刻已经存在于 Pool 中的对象」,Put 发生在清理之后的对象不会在本轮被清掉,但它会在下一轮 GC 时被清掉——除非在此之前被 Get 走。
一、先把 Pool 的数据结构讲清楚:private + shared 链
Go 1.13 之后 sync.Pool 被重写过,1.19 又用 poolChain 替换了早期的 shared 切片。当前每个 P(processor,逻辑处理器)对应一个 poolLocal:
type Pool struct {
noCopy noCopy
local unsafe.Pointer // 指向 [P]poolLocal 数组
localSize uintptr // 数组长度,等于 P 的数量
victim unsafe.Pointer // 上一轮的 local,GC 时降级为 victim
victimSize uintptr
New func() any
}
type poolLocal struct {
poolLocalInternal
// 填充到 128 字节,避免 false sharing
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}
type poolLocalInternal struct {
private any // 只能被当前 P 读写,无锁
shared poolChain // 当前 P 可 push/pop,其他 P 只能 pop
}
关键点有两个:
private只能由绑定到该 P 的 goroutine 访问,没有任何原子操作,所以Get时会被优先取走。Put时如果 private 为空,就直接塞进 private 而不是 shared。shared是poolChain,本质是一个无锁双向链表 + 环形缓冲:本 P 从头部 push/pop,其他 P 只能从尾部 steal。这个结构决定了「跨 P 复用」是有代价的。
还有个容易忽略的字段:victim。它不是给业务用的,而是 GC 的两阶段清理机制——这正是「要 GC 两次对象才消失」的原因。
二、GC 到底怎么清空:把 local 降级为 victim,再丢弃旧 victim
每次 GC 的 gcStart 阶段会调用 clearpools()(源文件 runtime/mgc.go):
func clearpools() {
// clear sync.Pools
for _, p := range allpools {
p.local = p.victim
p.localSize = p.victimSize
p.victim = nil
p.victimSize = 0
}
allpools = []*Pool{}
}
看懂这四行就明白了全部边界:
- 旧 victim 被置 nil,上一轮残存的对象真正失去引用,等待下一轮 GC 回收内存。
- 当前 local 整体降级为 victim。对象没有被立刻释放,只是挪进「候补席」。
- local 被清空成 nil。此时
p.localSize == 0,Pool 看起来像「空的」。
也就是说,一次 GC 之后:
- 清理前存在的对象仍然住在 victim 里,
Get时仍然可以拿到(源码里poolCleanup之后Get会检查 victim)。 - 只有在下一次 GC 时,这批 victim 对象才会被丢弃。
验证代码如下,注意每次 Get 之前要重置计数,否则 pooled 对象的字段会被复用误导结论:
package main
import (
"fmt"
"runtime"
"sync"
)
type item struct{ id int }
func main() {
var hits int
pool := sync.Pool{
New: func() any {
hits++
return &item{id: hits}
},
}
pool.Put(&item{id: -1})
fmt.Println("first Get:", pool.Get().(*item).id) // -1,命中池
runtime.GC()
fmt.Println("after 1st GC, Get:", pool.Get().(*item).id) // -1,命中 victim
runtime.GC()
fmt.Println("after 2nd GC, Get:", pool.Get().(*item).id) // 1,victim 被丢,走 New
}
实测输出(Go 1.21,GOMAXPROCS 不固定也不影响这个结论,因为单 P 场景下对象会进 private):
first Get: -1
after 1st GC, Get: -1
after 2nd GC, Get: 1
面试高频追问:为什么 Go 团队要搞 victim 而不直接清空?答案是平滑降级——如果 GC 一来就把池清空,那么 GC 刚结束的一瞬间会出现大量
New调用,形成「GC 后分配风暴」。victim 让对象多活一个 GC 周期。
三、Put 的复用边界:三个具体分支决定对象去哪
Put 的实现(sync/pool.go)本身很短,但包含三个容易踩错的边界:
func (p *Pool) Put(x any) {
if x == nil {
return
}
if race.Enabled {
if fastrandn(4) == 0 {
// 随机丢弃,避免伪共享
return
}
}
l, _ := p.pin()
if l.private == nil {
l.private = x
} else {
l.shared.pushHead(x)
}
runtime_procUnpin()
if race.Enabled {
race.ReleaseMerge(unsafe.Pointer(p))
race.Enable()
}
}
边界 1:Put(nil) 是静默 no-op
传 nil 不会 panic,也不会污染池,但很多人用 Put(nil) 想「占位」,结果下一次 Get 走了 New,误以为是 GC 清了池。这是排查现场最常见的误会。
边界 2:private 只会保存一个对象
同一个 P 连续 Put 两次:第一个进 private,第二个进 shared。因此「池里到底有多少对象」根本不可控,len(pool) 这种东西在 API 上就不存在,也不该依赖。
边界 3:Put 之后立刻发生 GC,对象会怎样
这才是标题问的核心。清空动作发生在 GC 的 mark 阶段入口,也就是 clearpools 被调用时。对象去向取决于时序:
| 时序 | 对象落在哪 | 本轮 GC 是否会影响它 | 下一轮 GC 是否会被清 |
|---|---|---|---|
| Put 在 clearpools 之前 | local(含 private/shared) | 降级到 victim,仍可 Get | 是,victim 被丢弃 |
| Put 在 clearpools 之后 | 新的 local | 不受影响 | 是,降级后下一轮丢 |
| Put 后对象被同一 P 的 Get 取走 | 离开池 | 无关 | 无关,直到再次 Put |
所以「GC 清空后 Put 新对象」的复用边界是:新对象可以稳定服务到下一次 GC 之前;每经历一次 GC,它的生命期只被延长一个周期。不存在「Put 进去就永久持有」的语义。
下面这段代码在 runtime.GC() 之后立刻 Put,再 GC 一次,用来观察上面第二行:
package main
import (
"fmt"
"runtime"
"sync"
)
func main() {
var created int
pool := sync.Pool{New: func() any {
created++
return &struct{ n int }{n: created}
}}
runtime.GC() // 清掉任何历史状态
pool.Put(&struct{ n int }{n: 100}) // 清理"之后"的 Put
got := pool.Get()
fmt.Printf("after clear then Put: %+v\n", got) // n:100
runtime.GC() // 本轮把它降级为 victim
got = pool.Get()
fmt.Printf("after 1 GC: %+v\n", got) // n:100,仍命中 victim
runtime.GC() // 丢弃 victim
got = pool.Get()
fmt.Printf("after 2 GC: created=%d\n", created) // created=1,走 New
}
如果你在 Put 之后、GC 之前用 GODEBUG=gctrace=1 看日志,能观察到 gc N @0.xxx 0%: ...,但没有专门的 pool 日志。排查时更实用的是 runtime.ReadMemStats 对比 Mallocs 和 Frees 的差值,或者直接用 benchmark 看 allocs/op。
四、跨 P 的复用边界:为什么你的池命中率很低
很多线上问题不是「GC 清了池」,而是「对象存在但取不到」。原因在 pin 逻辑:
func (p *Pool) Get() any {
// ... 略去 race 分支
l, pid := p.pin()
x := l.private
l.private = nil
if x == nil {
x, _ = l.shared.popHead()
if x == nil {
x = p.getSlow(pid)
}
}
runtime_procUnpin()
// ...
}
func (p *Pool) getSlow(pid int) any {
size := runtime_LoadAcquiringUintptr(&p.localSize)
locals := p.local
for i := 0; i < int(size); i++ {
l := indexLocal(locals, (pid+i+1)%int(size))
if x, _ := l.shared.popTail(); x != nil {
return x
}
}
// 再找 victim
// ...
return nil
}
边界很清楚:
- 本 P 的 private 与 shared 优先,无锁路径。
- 本 P 没有,就偷别的 P 的 shared 尾部,最多遍历
localSize次,从pid+1开始轮转。这是锁竞争路径。 - 全部落空,才去本 P 对应的 victim 里找。
- 都失败,调用
New。
所以高并发、goroutine 频繁跨线程调度的场景下,Get/Put 很可能落在不同 P,触发 steal,甚至走 New。这不是 bug,是设计预期。如果你对复用率有强预期,就不该把 sync.Pool 当作资源池使用。
另外一个隐藏成本:poolChain.pushHead 在初始状态下会分配一个 8 元素的 ring buffer,这就意味着第一次 Put 就会产生一次堆分配。基准测试里看到的 0 B/op 是 buffer 已被前一轮迭代摊平后的结果,别被首轮数据骗了。
五、复用边界与安全边界:从内存复用导致的脏数据事故说起
Pool 的语义是「对象可以复用,内容是未知的」,这决定了所有 Putter 必须自己重置状态。官方文档只说了一句轻描淡写的话,但事故通报里最常见的就是这句:
Pool 中的对象既不可预期地被保留,也可能在任意时刻被移除。不应假设
Get返回的对象是干净的。
一个典型反例:
type Resp struct {
Buf []byte
Code int
}
var respPool = sync.Pool{New: func() any { return &Resp{} }}
func handle(msg []byte) *Resp {
r := respPool.Get().(*Resp)
r.Buf = append(r.Buf[:0], msg...) // 复用底层数组
r.Code = 200
return r // 没有 Put 回去,也没有清理
}
问题不在于没 Put(那是内存浪费),而在于「有人 Put 回来时没清空 Buf 的长度」,下一次 append 虽然调用了 r.Buf[:0],但如果上游逻辑直接读取 cap(r.Buf) 之外的内容,或者把 r.Buf 泄漏给异步 goroutine,就会出现读到上一个请求数据的脏读。规范写法是:
func handle(msg []byte) *Resp {
r := respPool.Get().(*Resp)
r.Buf = append(r.Buf[:0], msg...)
r.Code = 200
return r
}
func release(r *Resp) {
if cap(r.Buf) > 64*1024 { // 防止把巨型 buffer 塞回池里
respPool.Put(&Resp{})
return
}
r.Buf = r.Buf[:0]
r.Code = 0
respPool.Put(r)
}
这里有两层边界知识:大小边界(超大对象进池会导致内存只增不减,是线上 OOM 的常见原因)和 归属边界(Put 之后绝对不能再持有引用,否则就是 data race,-race 能报出来但生产环境往往静默)。
顺便记录几个真实报错形态:
fatal error: sync: inconsistent mutex state,常见于把sync.Pool或包含 Mutex 的结构体拷贝后使用,Pool 内嵌了noCopy,go vet会报copylocks: literal copies lock value。WARNING: DATA RACE指向pool.go:xxx,通常是 Put 之后仍在使用对象。Go 1.21 起 race 模式下Put有 1/4 概率直接丢弃对象,所以 race 测试里池命中会变少,别惊讶。runtime: goroutine stack exceeds 1000000000-byte limit,当New内部又调用Get造成递归时出现。
六、面试怎么答:给出一条一句话结论 + 三个可验证事实
如果面试官问「对象被 GC 清空后 Put 新对象的复用边界」,建议这样组织:
一句话结论:GC 并非「清空池」,而是把当前 local 降级为 victim;因此 Put 发生在清理之后的对象本轮不受影响,但每次 GC 都会让池中所有对象多活一个周期,最多两个周期,届时必然被丢弃,除非中途被 Get 取走。
三个可验证事实:
- 要观察一个 pooled 对象彻底消失,必须
runtime.GC()两次,因为clearpools在 mark 开始时执行且只做降级。 private每 P 最多一个对象,其余走shared;跨 P 复用代价高,不可把 Pool 当资源池做精确容量控制。Put(nil)被忽略,且 race 模式下Put有丢弃逻辑,池命中率天然带随机性。
工程建议:Pool 适合「零值即可用、大小有上限、能容忍丢失」的对象,比如 bytes.Buffer、protobuf 消息、JSON encoder、临时 slice;不适合需要 Close 的连接、需要精确数量的 token 桶、含有敏感字段但不重置的结构体。
FAQ
Q1:GC 之后立刻 Put,能保证下次 Get 一定拿到它吗?
不能。Put 进的是当前 P 的 private 或 shared,而 Get 可能发生在别的 P 上,触发 steal 失败后走 New。只有同 P 的场景(或 GOMAXPROCS=1)能稳定命中。
Q2:为什么我的池命中率很高,但 allocs/op 依然不为 0?
两个常见原因:一是 poolChain 首次 push 会分配 ring buffer,基准测试要预热;二是被 Put 的对象本身带着 slice 扩容,比如 bytes.Buffer 的底层数组在池里被下一个使用者 Grow 到撑大,导致内存只增不减,看起来像是分配没有下降。
Q3:Go 1.21 的 Pool 和 Go 1.13 之前的实现有什么区别?
1.13 之前是 private + shared []interface{} 加互斥锁,跨 P 抢锁成本很高。1.13 引入 poolChain 无锁队列,1.19 增加 victim 缓存,让对象跨一个 GC 周期存活,明显降低了 GC 后的分配尖峰。victim 从 Go 1.21 起才被正式文档描述为稳定行为,写文章引用时要注意版本。
Q4:能不能用 runtime.GC() 手动触发来验证 Pool 的行为?
可以,但只适合单测/面试演示。生产代码里调用 runtime.GC() 是反模式,会触发 STW,直接打爆延迟指标。真要观察清理,用 GODEBUG=gctrace=1 或者 pprof 看 sync.Pool 相关的分配行为。
Q5:什么情况下应该放弃 sync.Pool,改用自建对象池?
当你需要「容量上限可控、命中率可观测、对象必须重置、可以阻塞等待」中的任意一条时,sync.Pool 都不合格。典型替代是用 chan 实现固定容量池(如 fasthttp 的做法),或者直接用 ants 这类 goroutine 池。反过来,如果只是砍掉 make([]byte, 4096) 这类高频小分配,sync.Pool 是成本最低的选择。