原创

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 源码中仍能看到 privateshared、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 标准库文档:定义了 GetPutNew、并发安全、GC 清理以及内存模型语义。
  • Go sync/pool.go 源码:可以看到 per-P local pool、privateshared、对象窃取与 victim cache 的当前实现。
  • 当前稳定版 Go 1.27 于 2026 年 8 月 19 日发布;本文涉及的标准库文档对应 Go 1.27。
正文到此结束
评论插件初始化中...
Loading...