Pool
一般情况下,如果要考虑缓存资源,比如说创建好的对象,那么可以使用 sync.Pool。
- sync.Pool 会先查看自己是否有资源,有则直接返回,没有则创建一个新的
- sync.Pool 会在 GC 的时候释放缓存的资源
一般是用 sync.Pool 都是为了复用内存:
- 它减少了内存分配,也减轻了 GC 压力(最主要)
- 少消耗 CPU 资源(内存分配和 GC 都是 CPU 密集操作)
Pool 细节
我们考虑一下,假如说我们要实现一个类似功能的 Pool,可以考虑用什么方案?
最简单的方案,就是用队列,而且是并发安全的队列。队头取,队尾放回去。在队列为空的时候创建一个新的。
问题:队头和队尾都是竞争点,依赖于锁。
很显然,既然全局锁会成为瓶颈,我们就避免全局锁,逼不得已的时候再去尝试全局锁。
那么可以考虑的方案就是 TLB(thread-local-buffer)。
每个线程自己搞一个队列,再来一个共享的队列。
Go 有更好的选择。Go 本身的 PMG 调度模型,其中 P 是一个神奇的东西,代表的是处理器(Processor)。
P 的优点:任何数据绑定在 P 上,都不需要竞争,因为 P 同一时间只有一个 G 在运行。
同时,Go 并没有采用全局共享队列的方案,而是采用了窃取的方案。
Go 的设计:
- 每个 P 一个 poolLocal 对象
- 每个 poolLocal 有一个 private 和 shared
- shared 指向的是一个 poolChain。poolChain 的数据会被别的 P 给偷走
- poolChain 是一个链表 + ring buffer 的双重结构
- 从整体上来说,它是一个双向链表
- 从单个节点来说,它指向了一个 ring buffer。后一个节点的 ring buffer 都是前一个节点的两倍
ring buffer 优势(实际上也可以说是数组的优势):
- 一次性分配好内存,循环利用
- 对缓存友好
所以,稍微思考一下就可以总结 Get 的步骤:
- 看 private 可不可用,可用就直接返回
- 不可用则从自己的 poolChain 里面尝试获取一个
- 从头开始找。注意,头指向的其实是最近创建的 ring buffer
- 从队头往队尾找
- 找不到则尝试从别的 P 里面偷一个出来。偷的过程就是全局并发,因为理论上来说,其它 P 都可能恰好一起来偷了
- 偷是从队尾偷的
- 如果偷也偷不到,那么就会去找缓刑(victim)的
- 连缓刑的也没有,那就去创建一个新的
总结 Put 的步骤:
- private 要是没放东西,就直接放 private
- 否则,准备放 poolChain
- 如果 poolChain 的 HEAD 还没创建,就创建一个 HEAD,然后创建一个 8 容量的 ring buffer,把数据丢过去
- 如果 poolChain 的 HEAD 指向的 ring buffer 没满,则丢过去 ring buffer
- 如果 poolChain 的 HEAD 指向的 ring buffer 已经满了,就创建一个新的节点,并且创建一个两倍容量的 ring buffer,把数据丢过去
Pool 与 GC
正常情况下,我们设计一个 Pool 都要考虑容量和淘汰问题(基本类似于缓存):
- 我们希望能够控制住 Pool 的内存消耗量
- 在这个前提下,我们要考虑淘汰的问题
Go 的 sync.Pool 就不太一样。它存粹依赖于 GC,用户完全没办法手工控制。
sync.Pool 的核心机制是依赖于两个:
- locals
- victim:缓刑
GC 的过程也很简单:
- locals 会被挪过去变成 victim
- victim 会被直接回收掉
复活:如果 victim 的对象再次被使用,那么它就会被丢回去 locals,逃过了下一轮被 GC 回收掉的命运
优点:防止 GC 引起性能抖动
type Pool struct {
noCopy noCopy
local unsafe.Pointer // local fixed-size per-P pool, actual type is [P]poolLocal
localSize uintptr // size of the local array
victim unsafe.Pointer // local from previous cycle
victimSize uintptr // size of victims array
// New optionally specifies a function to generate
// a value when Get would otherwise return nil.
// It may not be changed concurrently with calls to Get.
New func() any
}
poolLocal 和 false sharding
每一个 poolLocal 都有一个 pad 字段,是用于将 poolLocal 所占用的内存补齐到 128 的整数倍。
在并发话题下:所有的对齐基本上都是为了独占 CPU 高速缓存的 CacheLine。
type poolLocal struct {
poolLocalInternal
// Prevents false sharing on widespread platforms with
// 128 mod (cache line size) = 0 .
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}
Pool 为什么最后才去找 victim 的
前面的步骤,有一个令人困惑的点是:偷不到别的 P 的,再去找缓刑(victim)的。
那么问题来了:偷是一个全局竞争的过程,但是找 victim 不是,找 victim 和找正常的是一样的过程。显然先找 victim 会有更好的性能,那么为什么偷一把呢?
因为 sync.Pool 希望 victim 里面的对象尽可能被回收掉。
利用 Pool 实现简单的 buffer 池
Pool 还是比较简单的,大多数情况下都可以直接使用。 但是在一些场景下,我们需要更加精细的控制,那么就会尝试自己封装一下 Pool。
要考虑:
- 如果一个 buffer 占据了很多内存,要不要放回去?
- 怎么控制整个池的内存使用量?因为依托于 GC 是比较不可控的
- 控制单个 buffer 上限?
- 控制 buffer 数量?
- 控制总体内存?
type MyPool struct {
p sync.Pool
maxCnt int32
cnt int32
}
func (p *MyPool) Get() any {
return p.p.Get()
}
func (p *MyPool) Put(val any) {
// 大对象不放回去
if unsafe.Sizeof(val) > 1024 {
return
}
// 超过数量限制
// 注:以下仅在自己维护实现的 pool 中可以使用,如果是 sync.Pool , 则 cnt 值不可信,因为有可能被 gc
cnt := atomic.AddInt32(&p.cnt, 1)
if cnt >= p.maxCnt {
atomic.AddInt32(&p.cnt, -1)
return
}
p.p.Put(val)
}
bytebufferpool 实现要点
Github: https://github.com/valyala/bytebufferpool
- 也是依托于 sync.Pool 进行了二次封装
- defaultSize 是每次创建的 buffer 的默认大小,超过 maxSize 的 buffer 就不会被放回去
- 统计不同大小的 buffer 的使用次数。例如 0~64 bytes 的 buffer 被使用了多少次。这个我们称为分组统计使用次数
- 引入了所谓的校准机制,其实就是动态计算 defaultSize 和 maxSize
sync.Pool 总结
基本上,sync.Pool 面试的热点就是两个:
- sync.Pool 和 GC 的关系:数据默认在 local 里面,GC 的时候会被挪过去 victim 里面。如果这时候有 P 用了 victim 的数据,那么数据会被放回去 local 里面。
- poolChain 的设计:核心在于理解 poolChain 是一个双向链表加 ring buffer 的双重结构。
由这两个核心衍生出来的各种问题:
- 什么时候 P 会用 victim 的数据:偷都偷不到的时候。
- 为什么 Go 会设计这种结构?一个全局共享队列不好吗?这个问题要结合 TLB 来回答,TLB 解决全局锁竞争的方案,Go 结合自身 P 这么一个优势,设计出来的。
- 窃取:这个可以作为一个刷亮点的东西,结合 GMP 调度里面的工作窃取,原理都是一样的。
- 使用 sync.Pool 有什么注意点(缺点、优点)?高版本的 Go 里面的 sync.Pool 没特别大的缺点,硬要说就是内存使用量不可控,以及 GC 之后即便可以用 victim,Get 的速率还是要差点。
- 大对象不放回 pool
- 放回时考虑是否重置为初始状态
本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。