golang sync.Pool 的基本原理


sync.Pool 包寥寥不过300行代码,却可以为我们提供类似对象池的功能,减少了对象的重复创建于销毁,减少了性能损耗,增加内存复用,同时自带 mutex 锁可以保证 Put/Get 操作的并发安全特性,在一些对象需要重复创建销毁的场景中很是实用,今天来看看 sync.Pool 的基本原理。

1.源码解读

sync.Pool 就在标准库中,注释开篇就是(go 1.19):

// A Pool is a set of temporary objects that may be individually saved and
// retrieved.
//
// Any item stored in the Pool may be removed automatically at any time without
// notification. If the Pool holds the only reference when this happens, the
// item might be deallocated.
//
// A Pool is safe for use by multiple goroutines simultaneously.

翻译过来就是:

1.对象池是提供一种存储、获取临时对象的集合
2.不要把对象池当做缓存来处理,因为任何存储的对象,在某个时刻都可能会被回收掉,而且没有任何通知的情况下
3.这个对象池的使用是并发安全的

继续往下看:

// Pool's purpose is to cache allocated but unused items for later reuse,
// relieving pressure on the garbage collector. That is, it makes it easy to
// build efficient, thread-safe free lists. However, it is not suitable for all
// free lists.
//
// An appropriate use of a Pool is to manage a group of temporary items
// silently shared among and potentially reused by concurrent independent
// clients of a package. Pool provides a way to amortize allocation overhead
// across many clients.

翻译过来:

1.对象池的目的是用来缓存那些已分配但是在后续的重复使用的对象,可以减轻GC压力
2.对象池一种合适的使用场景,是在多个客户端之间共享重用对象,得以分摊内存分配的开销

后面的注释也提到,其实在 fmt 包中也是有 Pool 使用的体现,内部维护了一个动态大小的临时输出 buffer 空间。

注意

需要注意的点:

1.短生命周期的对象不太适合放对象池中
2.对象池不要复制
3.一般的使用场景,先声明 New(),然后再去使用 Get()/Put() 方法,可以 put 任何对象,但在 get 对象的时候,需要用到断言,即 Get().(Type)
4.对象池中的对象数量不能做任何假设,什么时候新建什么时候被GC,这些都是不确定的,对象池主要是能尽可能复用对象,减少GC
5.Pool本身的数据结构是并发安全,但是 Pool.New() 方法需要使用者注意场景使用,如并发下,则加上并发安全策略,如原子操作或者加互斥锁等

开放的 API

sync.Pool 开放了以下的 api:

  • New func() any,在创建 Pool 实例时需要填入对应的对象新建函数
  • Get() any,从 Pool 获取相应对象,一般要通过断言获取到确定类型对象
  • Put(any),用完对象后,直接放入对象池

下面主要看看 Put() 和 Get() 两个函数,也是我们用 Pool 用得最多的 API。

Put()

源码:

// Put adds x to the pool.
func (p *Pool) Put(x any) {
	if x == nil { // 1.对象是空,直接返回
		return
	}
	if race.Enabled {
		if fastrandn(4) == 0 {
			// Randomly drop x on floor.
			return
		}
		race.ReleaseMerge(poolRaceAddr(x))
		race.Disable()
	}
	l, _ := p.pin() // 绑定 p,不可抢占(go v1.14后可抢占,防止 g 执行时间过长,长期占用 p),主要防止操作 put/get 时,对应到不同的 p,乱了顺序
	if l.private == nil { // 2.如果对应 p 的 private 为空,直接放进 private 中,单纯的一个对象,类型也是 any
		l.private = x
	} else {
		l.shared.pushHead(x) // 3.private 已经有对象了,就放到 对应的 shared 共享队列(ring buffer)中,由于是绑定了 p 的,这里就直接操作对头插入,对应到 Get 中,就是 popHead
	}
	runtime_procUnpin()
	if race.Enabled {
		race.Enable()
	}
}

所以我们看到 Put 的操作很简单,就是单纯地放回 Pool 中,涉及到不同的存放位置。基本上可以总结为下面的操作流程:

1、首先调用 p.pin() 抢占p,获取 localpool
2、优先放归还到 private 中
3、如果 private 有值,则放到 shared 中

Get()

源码:

// Get selects an arbitrary item from the Pool, removes it from the
// Pool, and returns it to the caller.
// Get may choose to ignore the pool and treat it as empty.
// Callers should not assume any relation between values passed to Put and
// the values returned by Get.
//
// If Get would otherwise return nil and p.New is non-nil, Get returns
// the result of calling p.New.
func (p *Pool) Get() any {
	if race.Enabled {
		race.Disable()
	}
	l, pid := p.pin() // 绑定 p,保持抢占,获取 localpool,通过 pid 进行后续操作
	x := l.private // 1.优先从本地的private获取对象
	l.private = nil // 置空
	if x == nil { // 没有获取到,走其他方式获取
		// Try to pop the head of the local shard. We prefer
		// the head over the tail for temporal locality of
		// reuse.
		x, _ = l.shared.popHead() // 2.从本地 p 的 shared 队列获取,因为是本地 p,所以可以直接 popHead
		if x == nil { // 在本地的 p 没有获取到,需要从其他的 p 窃取
			x = p.getSlow(pid) // 3.从其他 p 上获取对象
		}
	}
	runtime_procUnpin()
	if race.Enabled {
		race.Enable()
		if x != nil {
			race.Acquire(poolRaceAddr(x))
		}
	}
	if x == nil && p.New != nil { // 上面的几条路径没有获取到,最后通过 New 重新创建对象
		x = p.New()
	}
	return x
}

注释见源码,我们可以看到,其实 Get() 方法也是有不同的优先级获取对象:

  • 1.从本地 p 获取 private 对象
  • 2.上面没有获取到,则从本地 p 的 shared 队列 队头获取对象
  • 3.本地的 p 没有获取到,接下来从其他 p 获取,先从 victim 处获取,没有就走其他 p 的 shared队列的队尾 popTail 获取对象
  • 4.上面的方式都没有获取到,最后通过传入的 New 兜底创建新对象

接下来继续追源码,一起来了解更多的细节。

pin() && pinSlow()

// pin pins the current goroutine to P, disables preemption and
// returns poolLocal pool for the P and the P's id.
// Caller must call runtime_procUnpin() when done with the pool.
func (p *Pool) pin() (*poolLocal, int) {
	pid := runtime_procPin()
	// In pinSlow we store to local and then to localSize, here we load in opposite order.
	// Since we've disabled preemption, GC cannot happen in between.
	// Thus here we must observe local at least as large localSize.
	// We can observe a newer/larger local, it is fine (we must observe its zero-initialized-ness).
	s := runtime_LoadAcquintptr(&p.localSize) // load-acquire
	l := p.local                              // load-consume
	if uintptr(pid) < s {
		return indexLocal(l, pid), pid
	}
	return p.pinSlow()
}

func (p *Pool) pinSlow() (*poolLocal, int) {
	// Retry under the mutex.
	// Can not lock the mutex while pinned.
	runtime_procUnpin()
	allPoolsMu.Lock()
	defer allPoolsMu.Unlock()
	pid := runtime_procPin()
	// poolCleanup won't be called while we are pinned.
	s := p.localSize
	l := p.local
	if uintptr(pid) < s {
		return indexLocal(l, pid), pid
	}
	if p.local == nil {
		allPools = append(allPools, p)
	}
	// If GOMAXPROCS changes between GCs, we re-allocate the array and lose the old one.
	size := runtime.GOMAXPROCS(0)
	local := make([]poolLocal, size)
	atomic.StorePointer(&p.local, unsafe.Pointer(&local[0])) // store-release
	runtime_StoreReluintptr(&p.localSize, uintptr(size))     // store-release
	return &local[pid], pid
}

这里涉及到的就是要保持抢占 p,在 pin() 中没有绑定到 p,就通过 pinSlow() 加 互斥锁 获取 p,其中也涉及一些 poolLocal 的新建。

Pool.getSlow(pid int) any

// 主要是从其他的 p 窃取对象
func (p *Pool) getSlow(pid int) any {
	// See the comment in pin regarding ordering of the loads.
	size := runtime_LoadAcquintptr(&p.localSize) // load-acquire
	locals := p.local                            // load-consume
	// Try to steal one element from other procs. 
	for i := 0; i < int(size); i++ { // 遍历 p,看是否能从 其他 p 窃取到 对象,从 other p 的 tail 来 pop 对象
		l := indexLocal(locals, (pid+i+1)%int(size))
		if x, _ := l.shared.popTail(); x != nil { // 获取到对象就返回
			return x
		}
	}

	// Try the victim cache. We do this after attempting to steal
	// from all primary caches because we want objects in the
	// victim cache to age out if at all possible.
	size = atomic.LoadUintptr(&p.victimSize)
	if uintptr(pid) >= size {
		return nil
	}
	locals = p.victim // 从存活一轮的 victim 中获取对象,只要获取到就返回对象
	l := indexLocal(locals, pid)
	if x := l.private; x != nil { // 先看 private,没有再从 shared队列获取
		l.private = nil
		return x
	}
	for i := 0; i < int(size); i++ {
		l := indexLocal(locals, (pid+i)%int(size))
		if x, _ := l.shared.popTail(); x != nil {
			return x
		}
	}

	// Mark the victim cache as empty for future gets don't bother
	// with it.
	atomic.StoreUintptr(&p.victimSize, 0) // 走到这里说明没有获取到对象,victimSize 置 0

	return nil
}

Pool结构

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
}

源码解读:

  • noCopy,表示对象不可复制
  • local,[P]poolLocal,固定数量的 p 的对象储存,每个元素即 private && shared 队列
  • localSize,p 的数量
  • victim,一轮 GC 后,local -> victim,实际就是上轮的 local 转为 victim ,local = nil,缓存的临时对象多存活一轮
  • victimSize,同 localSize
  • New,即定义创建新对象的方法

需要注意的是, Pool 本身是并发安全的,通过大量的原子操作及本地 p 的无锁操作实现无锁,但是 New 可能不是并发安全的,需要使用者自己定义。

相关结构体

poolLocal

type poolLocal struct {
	poolLocalInternal

	// Prevents false sharing on widespread platforms with
	// 128 mod (cache line size) = 0 .
	pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}


// Local per-P Pool appendix. // 每个 p 上独有的结构,包含一个任意类型的 private 和 一个 环形队列的 poolChain
type poolLocalInternal struct {
	private any       // Can be used only by the respective P. // p自己可操作的 private
	shared  poolChain // Local P can pushHead/popHead; any P can popTail. // 绑定 p 的操作限于队头的pop/push,其他 p 则从队尾操作,popTail
}

poolCleanup()

// 此函数或在 GC 前注册到 gc 处理中,由于 gc 也是由 runtime 控制的,所以是无法预计对象何时被回收
func poolCleanup() {
	// This function is called with the world stopped, at the beginning of a garbage collection.
	// It must not allocate and probably should not call any runtime functions.

	// Because the world is stopped, no pool user can be in a
	// pinned section (in effect, this has all Ps pinned).

	// Drop victim caches from all pools. // 回收 victim 对象,去除引用
	for _, p := range oldPools {
		p.victim = nil
		p.victimSize = 0
	}

	// Move primary cache to victim cache.
	for _, p := range allPools { // 注意此处的处理,local -> victim,相当于 local 的对象来到 victim 后,最多多活一轮 GC
		p.victim = p.local
		p.victimSize = p.localSize
		p.local = nil
		p.localSize = 0
	}

	// The pools with non-empty primary caches now have non-empty
	// victim caches and no pools have primary caches.
	oldPools, allPools = allPools, nil // 处理后,原来的 pools 就变成了 oldPools
}

// 初始化时就注册进运行时的处理函数
func init() {
	runtime_registerPoolCleanup(poolCleanup)
}

上面是个人的一些解读,如想了解更详细,请前往下方的参考文档。

2.更多

下面说说 sync.Pool 的一些注意事项。

1.为什么用 Pool,而不是在运行的时候直接实例化对象呢?
原因:Go 的内存释放是由 runtime 来自动处理的,有 GC 过程。
创建 Pool 实例的时候,只要求填充了 New 函数,而根本没有声明或者限制这个 Pool 的大小。所以,记住一点,程序员作为使用方不能对 Pool 里面的元素个数做假定。如果速度足够快,可能里面可以只有一个元素就可以服务 若干个 个并发的 Goroutine 。

2.sync.Pool 是并发安全的吗?
本质原因:Pool.New 函数可能会被并发调用。
因为 sync.Pool 只是本身的 Pool 数据结构是并发安全的,并不是说 Pool.New 函数一定是线程安全的。Pool.New 函数可能会被并发调用 ,如果 New 函数里面的实现是非并发安全的,那就会有问题。

3.为什么 sync.Pool 不适合用于像 socket 长连接或数据库连接池?
因为,我们不能对 sync.Pool 中保存的元素做任何假设,以下事情是都可以发生的:
Pool 池里的元素随时可能释放掉,释放策略完全由 runtime 内部管理;
Get 获取到的元素对象可能是刚创建的,也可能是之前创建好 cache 住的。使用者无法区分;
Pool 池里面的元素个数你无法知道;
所以,只有的你的场景满足以上的假定,才能正确的使用 Pool 。sync.Pool 本质用途是增加临时对象的重用率,减少 GC 负担。划重点:临时对象。所以说,像 socket 这种带状态的,长期有效的资源是不适合 Pool 的。

参考文章:
go中的sync.pool源码剖析
深度分析 Golang sync.Pool 底层原理

posted on 2024-07-11 11:34  进击的davis  阅读(181)  评论(0编辑  收藏  举报

导航