Go 的 sync.Pool 实战:复用对象把 GC 压力打下来

Go 的 sync.Pool 实战:复用对象把 GC 压力打下来

高并发的 Go 服务里,有一类性能问题很隐蔽:代码逻辑没毛病,QPS 也不算高,但 GC 频繁触发、P99 延迟毛刺不断。用 pprof 一看,大量时间花在内存分配和垃圾回收上。罪魁祸首常常是------你在热路径上反复 new 又扔掉同一种临时对象

这篇讲 sync.Pool 怎么复用这些临时对象、怎么用对(它有几个非常容易踩的坑),以及什么时候压根不该用它。

先看问题:每个请求都分配一个大 buffer

一个很典型的场景:HTTP handler 里需要一个 buffer 拼接响应,或者做 JSON 序列化的中转:

go 复制代码
func handler(w http.ResponseWriter, r *http.Request) {
    // 每个请求都新建一个 buffer
    buf := make([]byte, 0, 4096)
    buf = append(buf, "prefix:"...)
    buf = append(buf, r.URL.Path...)
    // ... 一堆拼接
    w.Write(buf)
}

单看没问题。但如果这个 handler 每秒被调用几万次,就意味着每秒新建几万个 4KB 的切片,用完立刻变成垃圾。这些短命对象会快速填满堆,逼着 GC 频繁运行,而 GC 的 STW(stop-the-world)阶段会直接体现为请求延迟毛刺。

核心矛盾:这些 buffer 生命周期极短、形状完全一样、用完就扔。与其反复 new/GC,不如建一个池子循环利用它们。 这正是 sync.Pool 的用武之地。

sync.Pool 基本用法

sync.Pool 是一个可以存放临时对象的池子。Get 拿一个出来(池空时调用 New 造一个),用完 Put 放回去下次复用:

go 复制代码
import (
    "bytes"
    "sync"
)

// New 定义池空时怎么造新对象
var bufPool = sync.Pool{
    New: func() any {
        return new(bytes.Buffer)
    },
}

func handler(w http.ResponseWriter, r *http.Request) {
    // 从池里拿一个 buffer
    buf := bufPool.Get().(*bytes.Buffer)

    // 关键:放回前必须重置,否则拿到的是上次的脏数据
    buf.Reset()
    defer bufPool.Put(buf)   // 用完放回池子

    buf.WriteString("prefix:")
    buf.WriteString(r.URL.Path)
    w.Write(buf.Bytes())
}

三个要点:Get 返回的是 any,要类型断言;New 只在池空时才调用;Put 之前(或 Get 之后)一定要 Reset,把上一个使用者留下的数据清掉。

在压测里,这种改法通常能让分配次数下降一个数量级,GC 频率和延迟毛刺明显改善。你可以用 benchmark 对比:

go 复制代码
func BenchmarkWithPool(b *testing.B) {
    b.ReportAllocs()   // 报告每次操作的分配次数
    for i := 0; i < b.N; i++ {
        buf := bufPool.Get().(*bytes.Buffer)
        buf.Reset()
        buf.WriteString("hello world")
        _ = buf.String()
        bufPool.Put(buf)
    }
}

go test -bench=. -benchmem,对比不用池的版本,allocs/op 会从 1~2 降到接近 0。

坑一:忘了 Reset,拿到脏数据

这是最常见的 bug。sync.Pool 存的是「上一个人用完原样放回」的对象,里面还留着旧内容。如果你 Get 出来直接用,就会读到别的请求的残留数据------在并发下这是极难复现的串数据 bug。

go 复制代码
// 错误:没 Reset,buf 里可能有上个请求的内容
buf := bufPool.Get().(*bytes.Buffer)
buf.WriteString("new data")   // 拼在旧数据后面!

// 正确:先 Reset
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
buf.WriteString("new data")

对切片同理,拿出来要 buf = buf[:0] 把长度归零(保留底层容量)。

坑二:Put 进去的对象还被别处引用

Put 意味着「我不再用它了,交给池子」。如果你 Put 之后还持有这个对象的引用继续读写,就会和下一个 Get 到它的人产生数据竞争:

go 复制代码
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
buf.WriteString("data")

result := buf.Bytes()   // 危险:Bytes() 返回的是底层数组的引用
bufPool.Put(buf)        // 放回后,别人可能拿到并覆写

process(result)         // result 可能已经被改!

bytes.Buffer.Bytes() 返回的切片指向 buffer 内部数组,Put 之后这块内存归池子了。要么在 Put 前把数据 copy 出来,要么等真正用完再 Put规则:一旦 Put,就当这个对象已经不属于你了。

坑三:池里的对象随时会被 GC 清空

sync.Pool 不保证对象一直在。它的设计就是「临时缓存」------每轮 GC 都可能清空池子里的对象(Go 1.13 后改成两级缓存,能扛过一轮 GC,但你不能依赖)。

这带来两个约束:

  1. 不能用它做需要「一定存在」的资源池 ,比如数据库连接、长连接。那些要用专门的连接池(database/sql 自带,或者 channel 实现的固定容量池),它们保证连接不会被 GC 悄悄回收。
  2. New 必须能随时造出可用对象 。因为你无法预期 Get 会命中缓存还是走 New

一句话:sync.Pool 适合「有更好,没有就现造,造起来不贵」的临时对象,不适合「必须复用、创建昂贵、数量要受控」的资源。

坑四:小对象用池反而更慢

sync.Pool 本身有开销------内部要处理 per-P 的本地池、跨 P 偷取、GC 清理。如果你池化的对象很小(比如一个只有几个字段的 struct),Get/Put 的开销可能比直接 new 还大,得不偿失。

判断标准:只有当对象「大」或者「分配频率极高」时,池化才划算。 拿不准就写 benchmark 用 -benchmem 对比 allocs/opns/op,别凭感觉。典型值得池化的:大 buffer(几 KB 以上)、序列化中转对象、gzip.Writer 这类构造成本高的对象。

一个更完整的例子:池化带容量的 byte slice

buffer 之外,直接池化 []byte 也很常见,比如做加密、压缩、IO 的中转缓冲:

go 复制代码
var slicePool = sync.Pool{
    New: func() any {
        // 返回指针,避免 Put 时切片 header 被复制装箱又产生分配
        b := make([]byte, 0, 8192)
        return &b
    },
}

func compress(data []byte) []byte {
    ptr := slicePool.Get().(*[]byte)
    buf := (*ptr)[:0]        // 长度归零,保留 8192 容量
    defer func() {
        *ptr = buf           // 写回可能被 append 扩容后的切片
        slicePool.Put(ptr)
    }()

    buf = append(buf, doCompress(data)...)

    // 注意:要返回给调用方的数据得 copy 一份,不能直接返回 buf
    out := make([]byte, len(buf))
    copy(out, buf)
    return out
}

这里两个细节:池化切片存指针 (*[]byte)而不是切片本身,避免 Put(slice) 时切片头被装箱进 interface 又产生一次分配;返回给外部的数据必须 copy,因为 buf 马上要还回池子。

小结

  • sync.Pool 复用生命周期短、形状一致的临时对象,把「反复 new/GC」变成「循环利用」,显著降低 GC 压力和延迟毛刺。
  • 用法三件套:New 定义兜底构造、Get 后类型断言、PutReset/[:0] 清脏数据。
  • 四个坑:①忘 Reset 读到脏数据;②Put 后仍持有引用导致数据竞争;③池对象随时被 GC 清空,不能当连接池用;④小对象池化反而更慢。
  • 池化切片存指针、返回数据要 copy。
  • 判断口诀:「对象够大 or 分配够频繁,且没有它也能现造」才池化 ;拿不准就 go test -benchmem 用数据说话,别凭感觉优化。
相关推荐
优橙教育3 小时前
5G网优培训 vs Java开发:转行选哪个?
java·开发语言·5g
掘金码甲哥3 小时前
这块终端神器, 必须吹爆!
后端
Csvn4 小时前
📊 SQL 入门 Day 8:集合操作 — 用 SQL 做数学里的"并交差"
后端·sql
SeaTunnel5 小时前
从 Python Script 地狱到标准化数据集成框架
大数据·开发语言·python·程序员·代码·seatunnel
CodexDave5 小时前
MySQL事务隔离级别与MVCC机制解析
前端·数据库·mysql·nginx·性能优化·负载均衡
碎光拾影5 小时前
ARM交叉工具链各工具作用及IMX6ULL平台LED+蜂鸣器裸机程序实现
java·开发语言·数据库
Marst Code6 小时前
(python)2026Plotly 库评估:交互式可视化到底值不值得引入?
开发语言·python
Miao121316 小时前
微服务 API 测试实践:海外某民宿平台如何构建模式驱动测试基础设施
java·开发语言
William.csj6 小时前
BaiduPCS-Go——下载百度网盘文件的工具
golang·baidupc
码事漫谈6 小时前
告别数据孤岛与AI“水土不服”:金仓多模融合时序库如何让数据真正服务于业务
后端