前面一篇讲解了Sync.Pool的底层数据结构 poolDequeue,接着看看Sync.Pool的具体实现原理。如果想看看 Sync.Pool 的使用 可以看看我的 Go 入门 26-Issue,使用的时候最好分配固定大小的对象否则注意清理
带着问题看世界
Sync.Pool与Goroutine的关系Sync.Pool是如何释放的(并没有主动释放接口)
代码:src/sync/pool.go,1.20版本
首先来看内存池的说明,翻译过来就是就是:
- 存放在
Pool中的元素任何时候都有可能在没有被其他引用的情况下释放掉 Pool是并发安全的- 使用
Pool之后不能再复制它。假设缓存池对象 A 被对象 B 拷贝了,如果 A 被清空,B 的缓存对象指针指向的对象将会不可控
先来看看整体的结构图

全局变量
1 | var poolRaceHash [128]uint64 |
allPoolsMu:用于对allPools的更新进行保护allPools:[]*Pool切片,存储具有非空私有缓存的对象池。可以被多个goroutine访问oldPools:[]*Pool类型的切片,用于存储可能具有非空受害者缓存的对象池,由于只有在STW时候才会更新,不会被并发访问
基本结构
1 | type Pool struct { |
-
noCopy用于提示不要进行对象复制 -
local与victim的关系在后面 内存池清理 中说明 -
New则是自定义的分配对象函数
noCopy
noCopy 支持 使用 go vet 检查对象是否被复制,它是一个内置的空结构体类型,当然也可以自行实现类似功能
1 | type noCopy struct{} |
它第一次使用后不能被复制,其实代码是能够编译通过运行的,只是在 go vet 或者 部分编辑器会提示而已。可以看看没有成为标准的原因 https://golang.org/issues/8005#issuecomment-190753527
poolLocal
每个处理器 P 都有一个 poolLocal 的本地池对象
1 | // 每个 P 的本地对象池 |
private是一个仅用于当前 P 进行读写的字段(即没有并发读写的问题)shared可以在多个 P 之间进行共享读写,是一个poolChain链式队列结构, 当前 P 上可以进行pushHead和popHead操作(队头读写), 在所有 P 上都可以进行popTail(队尾出队)操作pad用于 伪共享 保证poolLocal的大小是 128 字节的倍数
runtime_procPin
1 | //go:nosplit |
locks:通过增加锁的计数,表明当前线程被固定(pinned)在处理器上mp.p表示当前线程所绑定的处理器,.ptr()方法返回处理器的指针,.id表示处理器的唯一标识符
pinSlow
将当前的goroutine绑定到Pool中的一个poolLocal上,并返回该poolLocal及其索引
1 | func (p *Pool) pinSlow() (*poolLocal, int) { |
这个逻辑的前提是当前 P 已经发生了动态调整,需要重新计算localPool
- 首先解除
goroutine与Process的绑定,让goroutine可以重新绑定 - 获取
allPools的全局锁 - 将当前
goroutine与Process重新绑定 - 如果
Process未发生变化,返回Process的localPool - 如果没有变化则
- 如果
Pool.local为空,则需要将Pool加入到allPools中,用于 GC 扫描回收 - 重新创建
[p]poolLocal - 重新将
Pool.local指向[p]poolLocal
- 如果
pin
获取当前 Process 中的 poolLocal,将当前的goroutine绑定到一个特定的Process上,禁用抢占并返回Process的poolLocal本地池和P的标识
1 | func (p *Pool) pin() (*poolLocal, int) { |
尝试通过加载local和localSize字段的方式来判断是否可以直接返回一个可用的poolLocal,如果不满足条件,则调用pinSlow方法来重新分配并返回一个新的poolLocal。调用者在使用完poolLocal之后,必须调用runtime_procUnpin()来解除与P的绑定关系。
1 | func indexLocal(l unsafe.Pointer, i int) *poolLocal { |
内存池清理
在使用 init 仅执行了一个逻辑,就是注册内存池回收机制
1 | func init() { |
poolCleanup 用于实现内存池的清理
1 | func poolCleanup() { |
垃圾回收的策略就是
- 将
oldPools中也就是所有localPool的victim对象丢弃 - 将
allPools的local复制给victim,并local重置 - 最后将
allPools复制给oldPools,allPools置空
Get
整体流程如下
- 首先获取当前
Process的poolLocal,也就是说当 Goroutine 在哪个 Process 运行的时候就会从哪个 Process 的 localPool中获取对象 - 优先从
private中选择对象,并将private = nil - 若取不到,则尝试从
shared队列的队头进行读取 - 若取不到,则尝试从其他的
Process中进行偷取getSlow(跨 Process 读写) - 若还是取不到,则使用自定义的
New方法新建对象
获取对象代码如下操作
1 | func (p *Pool) Get() any { |
其中的 getSlow 就是从 其他 Process 或者 victim 中获取
1 | func (p *Pool) getSlow(pid int) any { |
Put
Put 的操作如下
存放策略是:
- 如果存放
nil直接返回 - 获取当前
Process的poolLocal - 如果
private == nil则放到private中 - 如果
private != nil则将起放入到 链表头部
1 | func (p *Pool) Put(x any) { |
总结
- Pool 本质是为了提高临时对象的复用率;
- Pool 使用两层回收策略(local + victim)避免性能波动;
- Pool 本质是一个杂货铺属性,啥都可以放,Pool 池本身不做限制;
- Pool 池里面 cache 对象也是分层的,一层层的 cache,取用方式从最热的数据到最冷的数据递进;
- Pool 是并发安全的,但是内部是无锁结构,原理是对每个 P 都分配 cache 数组(
poolLocalInternal数组),这样 cache 结构就不会导致并发; - 永远不要 copy 一个 Pool,明确禁止,不然会导致内存泄露和程序并发逻辑错误;
- 代码编译之前用
go vet做静态检查,能减少非常多的问题; - 每轮 GC 开始都会清理一把 Pool 里面 cache 的对象,注意流程是分两步,当前 Pool 池 local 数组里的元素交给 victim 数组句柄,victim 里面 cache 的元素全部清理。换句话说,引入 victim 机制之后,对象的缓存时间变成两个 GC 周期;
- 不要对 Pool 里面的对象做任何假定,有两种方案:要么就归还的时候 memset 对象之后,再调用
Pool.Put,要么就Pool.Get取出来的时候 memset 之后再使用;