跳到主要内容
约 11 分钟4 张卡

sync.Pool

sync.Pool 是 Go 标准库用来暂存可复用对象的容器。它允许多个 goroutine 并发取出和归还对象,目的是减少重复分配;池中的对象随时可能被移除,调用方必须能在取不到时重新创建。

因此,理解它的重点是对象复用的条件:谁可以修改取出的对象,归还前要清理什么,以及哪些资源不能依赖这种不保证保留的容器。本文以 Go 1.22.12 的 API 和实现为准。

1. 面试核心​

先记住这几条,再读正文
  • sync.Pool 适合在多个独立调用之间复用临时缓冲区等对象。池为空或对象被移除,都只能影响复用收益,不能影响程序正确性。它不提供稳定缓存、连接数上限或资源关闭协议。卡 · 临时对象与资源管理
  • 零值可直接使用,Get 取出任意对象;取不到时调用 New,未配置 New 则返回 nil。Get 不会等待其他调用者归还对象,也不保证返回此前 Put 的某个对象。
  • 容器操作支持并发,取出的对象不因此变成并发安全。按独占借用方式使用时,Put 之后要停止访问,不重复归还同一对象;首次使用后也不能复制整个池。卡 · 归还意味着结束本次使用
  • 重置逻辑状态、切断不再需要的引用和限制保留容量是不同责任。bytes.Buffer.Reset 保留底层存储;返回其 Bytes() 后又归还缓冲区,会让结果与后续使用共享存储。卡 · 返回独立结果
  • Go 1.22.12 为不同的 P 设置本地缓存,并允许从其他 P 的共享队列取对象。P 是运行 goroutine 所需的调度资源,不是某个 goroutine 永久拥有的私有池。
  • GC 开始时,Go 1.22.12 丢弃上一轮的 victim 缓存,再把当前本地缓存移入 victim。这是实现机制,不是对象至少保留几轮 GC 的 API 保证。卡 · 按 P 分片

2. 临时对象与资源管理​

当许多请求都需要短暂使用一块缓冲区时,每次新建、扩容、丢弃,可能产生重复分配。sync.Pool 把已经分配、当前不用的对象暂存起来,让后来的调用者有机会复用。标准库的 fmt 就会复用临时输出对象。

适合放入池的对象通常满足三个条件:创建或扩容有值得节省的成本;使用结束后可以重置;取不到旧对象时,重新创建也不改变结果。复用范围还要足够长,才能摊薄池本身的开销。只服务于一个很快销毁的小对象的空闲列表,不一定适合另建一个 sync.Pool。

它与以下几种需求不同:

需求为什么不能只靠 sync.Pool
按键保存业务数据没有按键查询,也不保证对象保留
限制同时借出的连接数量取不到时不会等待归还,可以继续调用 New 创建
归还连接后做健康检查、过期淘汰和关闭没有这些生命周期回调,也不会替对象调用 Close
任务排队并等待消费者处理对象可能被移除,且没有可靠队列和等待协议

连接、文件句柄等需要明确释放的资源,应由具有相应生命周期约束的管理机制负责。垃圾回收可以回收不可达的 Go 对象,但不能把 sync.Pool 的无通知移除理解成调用方所需的资源释放协议。

3. Get、Put 与借用责任​

3.1 取不到时重新创建​

声明 var p sync.Pool 后即可调用方法。p.Get() 取出一个对象并从池中移除;如果没有取得对象,就返回 nil。配置 New 后,取不到对象时改为调用 New,返回它的结果。

New 应在并发使用之前配置,不能与 Get 并发修改。多个 goroutine 都取不到对象时,可能同时调用 New,因此回调中访问的共享状态也要正确同步。New 没有“一生只执行一次”的含义,自己返回 nil 也不会被池自动修正。

Put 和 Get 通过 any 传递对象,应用应统一约定对象类型。存入 *bytes.Buffer,取出时就按这个类型断言;混入其他类型会让断言失败。通常保存指针,既方便复用同一个对象,也避免把大结构体作为值反复复制。

Put(nil) 会直接忽略,但 var b *bytes.Buffer = nil; p.Put(b) 传入的是带动态类型的接口值,不是 nil 接口。如果之后取回它,类型断言可以成功,得到的指针却仍为 nil。两者的区别见 Go 接口。池不会检查指针能否被正常使用。

3.2 归还意味着结束本次使用​

借用责任是应用对取出对象的访问约定:取到对象后由当前调用者使用,完成后再归还;归还之后不继续读取或修改,也不让尚在工作的 goroutine 保留它的可变别名。

这不是 sync.Pool 自动检查的所有权系统。如果同一指针被 Put 两次,池不会去重,后续调用者可能各自取到指向同一对象的指针。两次 Get 都成功,也不能证明它们取得的是两个独立对象。

内存同步保证同样有明确范围:Put(x) 与之后返回同一个 x 的 Get 建立同步关系,使归还前完成的状态修改对接手者可见;New 返回 x 与返回该 x 的 Get 之间也有对应关系。这不保护 Put 之后原调用者继续进行的修改。

因此,一个正确的调用链是:取得对象,完成本次使用,清理状态,最后归还。若要让结果在函数返回后继续使用,应复制需要保留的内容,或把对象的归还责任一起交给接收方。不能一边让外部继续使用,一边把同一个可变对象重新交给池。

sync.Pool 首次使用后不能复制。把池作为结构体字段时,也要避免按值复制这个已经使用过的结构体;传指针不会复制池本身。

4. 缓冲区复用与重置​

4.1 返回独立结果​

下面复用 bytes.Buffer 拼接一个简单的 key=value 字节串。它只演示缓冲区借用,不处理参数转义,也不是某种协议编码器。

package main

import (
"bytes"
"fmt"
"sync"
)

const maxRetained = 64 << 10

var buffers = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}

func encodePair(key, value string) []byte {
b := buffers.Get().(*bytes.Buffer)
b.Reset()
defer func() {
if b.Cap() <= maxRetained {
b.Reset()
buffers.Put(b)
}
}()

b.WriteString(key)
b.WriteByte('=')
b.WriteString(value)
return append([]byte(nil), b.Bytes()...)
}

func main() {
first := encodePair("id", "7")
second := encodePair("id", "8")
fmt.Printf("%s %s\n", first, second)
}

输出 id=7 id=8。append 把 b.Bytes() 的内容复制到独立切片,所以 first 不受后续缓冲区修改影响。函数取得的是新缓冲区还是复用对象,都不改变结果。

这里不能改成 return b.Bytes():它返回的切片与缓冲区共享底层数组,函数返回前执行的 defer 已经重置并可能归还 b。其他调用者再次写入时,先前的返回值也可能变化。切片复制与共享的区别见 Go 切片。

maxRetained 是这个示例自行选择的 64 KiB 单对象保留阈值,不是 sync.Pool 的默认限制。超出阈值的缓冲区不归还,避免偶发的大请求让大块存储继续留在池里。它没有限制同时借出的对象数量,也没有给整个池设置内存上限。

4.2 Reset 不等于释放存储​

bytes.Buffer.Reset 把缓冲区的逻辑长度和读取状态重置,但保留底层字节数组,供下一次写入使用。下面直接观察同一个缓冲区,无需假设池一定会返回哪个对象:

package main

import (
"bytes"
"fmt"
)

func main() {
var b bytes.Buffer
b.WriteString("old")
alias := b.Bytes()
saved := append([]byte(nil), alias...)
before := b.Cap()

b.Reset()
fmt.Println(b.Len(), b.Cap() == before)
b.WriteString("new")
fmt.Printf("%s %s\n", alias, saved)
}

在 Go 1.22.12 中输出 0 true 和 new old。重置后长度为零,容量仍然保留;相同长度的新内容写回了原有存储,alias 随之变化,独立复制的 saved 不变。Bytes() 的 API 已明确要求在下一次缓冲区修改后不再依赖旧切片,这段代码用于展示违反使用期限的后果。

其他对象也要按自己的字段重置。例如复用一条请求记录,需要清除上次请求的指针、错误状态和 map 条目;只把一个长度字段改成零,并不代表整条记录已经适合下一次使用。含指针切片的尾部引用还可能继续保留对象,见 Go 切片的尾部清理。

重置还不等于擦除历史字节。Reset 不会把底层数组逐字节清零;是否需要清除内容、哪些大容量对象不再保留,应由使用者根据对象语义决定。

5. Go 1.22.12 的缓存与 GC​

5.1 按 P 分片​

P 是 Go 调度器中运行 goroutine 所需的调度资源。Go 1.22.12 的 sync.Pool 为不同的 P 设置本地缓存,减少所有取还操作集中争用同一把锁的情况。goroutine 并不永久绑定某个 P,不能把它理解成 goroutine 局部存储。

每份本地缓存主要有两个位置:

位置用途
private保存一个对象,由对应 P 访问
shared保存更多对象,对应 P 从队列头放入和取出,其他 P 可以从尾部取走

Put 优先填入本地 private,已有对象时再放入 shared。Get 先检查本地 private 和 shared,取不到再尝试其他 P 的共享队列,然后访问上一轮保留下来的 victim 缓存;仍取不到才考虑 New。

实现通过短暂的 pin 区间保证当前操作使用正确的 P 本地结构,再配合原子操作协调共享队列。初始化本地结构等慢路径仍需要锁,不能把整个 sync.Pool 简化成“所有操作都无锁”。shared 的队列结构可以增长,也不代表有可配置的对象总数上限。

5.2 victim 与清理​

victim 缓存是 Go 1.22.12 中保存上一轮本地缓存的位置。GC 开始时,运行时在暂停用户代码执行的阶段调用池清理逻辑:

  1. 丢弃已有的 victim 缓存引用。
  2. 把本轮本地缓存移到 victim,清空本地缓存入口。
  3. 后续 Get 仍有机会从 victim 取对象;下一次清理会继续丢弃旧 victim。

这让缓存对象有机会跨过一轮 GC 继续被复用,也允许长期闲置的对象逐渐退出池。移除池的引用不等于立即释放对象:如果调用方还保留引用,对象仍然可达。

在 Go 1.22.12 的受控实验中,关闭自动 GC、固定 GOMAXPROCS=1,把对象放入池后显式触发一次 GC,仍可从 victim 取到;另外一批未被重新取还的对象经过两次 GC 后,池中已取不到。这个结果解释了该版本的清理机制,不能用来规定业务对象的生存期限。

API 允许任何池内对象随时被自动移除,也允许 Get 把池视为空。测试不能把“上次放入的对象必须还在”作为正确性条件。Go 1.22.12 的 race 检测构建还会在 Put 时随机丢弃部分对象,因此复用率也不能靠这种构建来衡量。

6. 复用成本的判断​

sync.Pool 的收益来自减少实际分配,不来自“用了池”这个形式。取还对象、类型断言、重置字段和跨 P 访问都有成本,保留大容量对象还会增加存活内存。原本就能在栈上使用的小对象,引入池也可能没有收益。

评估时应使用接近真实对象大小、并发量和 GC 条件的负载,对比分配次数、分配字节、延迟以及保留内存。既要观察稳定请求,也要观察偶发的大对象和低负载阶段。前面的 encodePair 仍会为返回结果复制字节,它演示的是借用责任,并不能证明整体比直接构造结果更快。

如果程序必须依赖固定容量、精确淘汰时刻或确定的关闭动作,应先满足这些正确性需求,再判断内部是否还有适合用 sync.Pool 复用的纯临时对象。

7 · 面试题卡片​

本页题目与参考回答

7.1 sync.Pool 适合解决什么问题,可以直接作为数据库连接池吗?​

考察重点:临时对象复用与容量、保留时间、外部资源生命周期的区别。

相关内容:临时对象与资源管理、复用成本的判断

参考回答

它适合复用可重新创建、使用后可重置的临时对象,不能单独承担完整的数据库连接池职责。 例如多次请求之间复用缓冲区,有机会减少重复分配。

池中的对象可能被无通知移除;取不到时,Get 不等待归还,而是调用 New 或返回 nil。它没有连接数量限制、健康检查、超时借用和关闭回调,需要明确管理的连接生命周期不能交给这些规则。

是否值得复用,还要对比重置和取还成本、分配减少量与保留内存,不能承诺使用后一定更快。

7.2 Pool 支持并发,为什么 Put 后继续修改对象仍然有问题?​

考察重点:容器安全、对象访问责任和同一对象上的同步关系。

相关内容:归还意味着结束本次使用

参考回答

Pool 只保证容器操作的并发安全,不会保护对象在取出之后的所有读写。 归还后,另一个调用者可能取到同一对象,原调用者继续修改就可能与新使用者竞争。

Put(x) 与返回相同 x 的后续 Get 建立同步关系,覆盖的是归还之前的状态。它不许可归还后继续访问,也不会检查重复归还;把同一指针放入两次,还可能让两个借用者同时拿到它。

7.3 复用 bytes.Buffer 时,Reset 后能否直接返回原来的 Bytes 切片?​

考察重点:逻辑状态、底层存储、可变别名和容量保留。

相关内容:返回独立结果、Reset 不等于释放存储

参考回答

如果缓冲区还会被归还并再次修改,就不能把这个旧切片作为独立结果继续使用。 Bytes() 与缓冲区共享底层数组,Reset 只清空逻辑状态并保留存储,后续写入可能改变返回值。

可以在归还前复制所需字节,或者把对象与归还责任一并交给接收方。大容量缓冲区还可以按应用规则不再归还,但单对象阈值并不等于整个池的内存上限。

7.4 Go 1.22.12 的 Pool 怎样复用和清理对象,能保证保留两轮 GC 吗?​

考察重点:按 P 分片、共享队列、victim 轮换与 API 保证的边界。

相关内容:按 P 分片、victim 与清理

参考回答

实现允许对象跨过一轮 GC 后继续被复用,但没有至少保留两轮 GC 的保证。 Go 1.22.12 在不同 P 的本地缓存中保存 private 对象和 shared 队列;本地取不到时,可以检查其他 P 的共享队列及 victim。

GC 开始时会清除旧 victim,再把当前本地缓存移入 victim。这是该版本的缓存轮换方式;API 仍允许随时移除对象,调用方必须能在取不到时重新创建。对象退出池也不意味着它已不可达或已经释放。

8. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明