Go猜想录
大道至简,悟者天成
sync.Pool 源码分析

Pool

一般情况下,如果要考虑缓存资源,比如说创建好的对象,那么可以使用 sync.Pool。

  • sync.Pool 会先查看自己是否有资源,有则直接返回,没有则创建一个新的
  • sync.Pool 会在 GC 的时候释放缓存的资源

一般是用 sync.Pool 都是为了复用内存:

  • 它减少了内存分配,也减轻了 GC 压力(最主要)
  • 少消耗 CPU 资源(内存分配和 GC 都是 CPU 密集操作)

pool-1.png

Pool 细节

我们考虑一下,假如说我们要实现一个类似功能的 Pool,可以考虑用什么方案?

最简单的方案,就是用队列,而且是并发安全的队列。队头取,队尾放回去。在队列为空的时候创建一个新的。

pool-2.png

问题:队头和队尾都是竞争点,依赖于锁。

很显然,既然全局锁会成为瓶颈,我们就避免全局锁,逼不得已的时候再去尝试全局锁。

那么可以考虑的方案就是 TLB(thread-local-buffer)。

每个线程自己搞一个队列,再来一个共享的队列。

pool-3.png

Go 有更好的选择。Go 本身的 PMG 调度模型,其中 P 是一个神奇的东西,代表的是处理器(Processor)。

P 的优点:任何数据绑定在 P 上,都不需要竞争,因为 P 同一时间只有一个 G 在运行。

同时,Go 并没有采用全局共享队列的方案,而是采用了窃取的方案。

pool-4.png

Go 的设计:

  • 每个 P 一个 poolLocal 对象
  • 每个 poolLocal 有一个 private 和 shared
  • shared 指向的是一个 poolChain。poolChain 的数据会被别的 P 给偷走
  • poolChain 是一个链表 + ring buffer 的双重结构
    • 从整体上来说,它是一个双向链表
    • 从单个节点来说,它指向了一个 ring buffer。后一个节点的 ring buffer 都是前一个节点的两倍

ring buffer 优势(实际上也可以说是数组的优势):

  • 一次性分配好内存,循环利用
  • 对缓存友好

pool-5.png

所以,稍微思考一下就可以总结 Get 的步骤:

  • 看 private 可不可用,可用就直接返回
  • 不可用则从自己的 poolChain 里面尝试获取一个
    • 从头开始找。注意,头指向的其实是最近创建的 ring buffer
    • 从队头往队尾找
  • 找不到则尝试从别的 P 里面偷一个出来。偷的过程就是全局并发,因为理论上来说,其它 P 都可能恰好一起来偷了
    • 偷是从队尾偷的
  • 如果偷也偷不到,那么就会去找缓刑(victim)的
  • 连缓刑的也没有,那就去创建一个新的

pool-6.png

总结 Put 的步骤:

  • private 要是没放东西,就直接放 private
  • 否则,准备放 poolChain
    • 如果 poolChain 的 HEAD 还没创建,就创建一个 HEAD,然后创建一个 8 容量的 ring buffer,把数据丢过去
    • 如果 poolChain 的 HEAD 指向的 ring buffer 没满,则丢过去 ring buffer
    • 如果 poolChain 的 HEAD 指向的 ring buffer 已经满了,就创建一个新的节点,并且创建一个两倍容量的 ring buffer,把数据丢过去

pool-7.png

Pool 与 GC

正常情况下,我们设计一个 Pool 都要考虑容量和淘汰问题(基本类似于缓存):

  • 我们希望能够控制住 Pool 的内存消耗量
  • 在这个前提下,我们要考虑淘汰的问题

Go 的 sync.Pool 就不太一样。它存粹依赖于 GC,用户完全没办法手工控制。

sync.Pool 的核心机制是依赖于两个:

  • locals
  • victim:缓刑 pool-8.png

GC 的过程也很简单:

  • locals 会被挪过去变成 victim
  • victim 会被直接回收掉

复活:如果 victim 的对象再次被使用,那么它就会被丢回去 locals,逃过了下一轮被 GC 回收掉的命运

优点:防止 GC 引起性能抖动

pool-9.png

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-10.png

利用 Pool 实现简单的 buffer 池

Pool 还是比较简单的,大多数情况下都可以直接使用。 但是在一些场景下,我们需要更加精细的控制,那么就会尝试自己封装一下 Pool。

要考虑:

  • 如果一个 buffer 占据了很多内存,要不要放回去?
  • 怎么控制整个池的内存使用量?因为依托于 GC 是比较不可控的
  • 控制单个 buffer 上限?
  • 控制 buffer 数量?
  • 控制总体内存?

pool-11.png

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 国际许可协议进行许可。