sync.Pool 中的对象被 GC 清空后 Put 新对象的复用边界

面试里问 sync.Pool,多数人能背出「临时对象复用、减轻 GC 压力」,但真正能区分候选人的是细节:GC 到底怎么清空 Pool?清空之后马上 Put 进去的对象,会不会在下一轮 GC 又被清掉?为什么必须调用 runtime.GC() 两次才能观察到一个对象从 Pool 中彻底消失?Put 里的对象什么时候会被「私吞」而不是进入共享队列? 本文从 G

面试里问 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{}
}

看懂这四行就明白了全部边界:

  1. 旧 victim 被置 nil,上一轮残存的对象真正失去引用,等待下一轮 GC 回收内存。
  2. 当前 local 整体降级为 victim。对象没有被立刻释放,只是挪进「候补席」。
  3. 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
}

边界很清楚:

  1. 本 P 的 private 与 shared 优先,无锁路径。
  2. 本 P 没有,就偷别的 P 的 shared 尾部,最多遍历 localSize 次,从 pid+1 开始轮转。这是锁竞争路径。
  3. 全部落空,才去本 P 对应的 victim 里找。
  4. 都失败,调用 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 取走。

三个可验证事实:

  1. 要观察一个 pooled 对象彻底消失,必须 runtime.GC() 两次,因为 clearpools 在 mark 开始时执行且只做降级。
  2. private 每 P 最多一个对象,其余走 shared;跨 P 复用代价高,不可把 Pool 当资源池做精确容量控制。
  3. 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 是成本最低的选择。