Go语言并发实践(一)-Go sync.Pool 及其背后机制

208 阅读25分钟

前言

本篇为笔者翻译 VictoriaMetrics 公司博客 《handling concurrency in Go》系列的第一篇,主要介绍了sync.Pool包及其底层相关原理。

Go sync.Pool 及其背后机制

VictoriaMetrics的 源代码中,我们使用了大量使用了sync.Pool库,而且老实说,它非常适合我们处理临时对象,尤其是字节缓冲区或片段。

它常用于标准库中。例如,在 json 包中:

package json

var encodeStatePool sync.Pool

// An encodeState encodes JSON into a bytes.Buffer.
type encodeState struct {
	bytes.Buffer // accumulated output

	ptrLevel uint
	ptrSeen  map[any]struct{}
}

在上述代码中,sync.Pool被用来重复使用 *encodeState 对象,该对象负责处理将 JSON 编码到 bytes.Buffer 的过程。

除了在这些对象使用结束后将他们交由垃圾回收器(GC)处理,我们将他们暂存到一个池子当中(sync.Pool)。下次当我们需要相似的对象的时候,我们可以直接将对象从池子中取出而不是从头创建一个对象。

你还会在 net/http 软件包中发现多个 sync.Pool 实例,用于优化 I/O 操作:

package http

var (
	bufioReaderPool   sync.Pool
	bufioWriter2kPool sync.Pool
	bufioWriter4kPool sync.Pool
)

当服务器读取请求体或写入响应时,它可以快速从这些池中提取一个预分配的 reader 或 writer,从而跳过额外的分配。此外,*bufioWriter2kPool*bufioWriter4kPool 这两个 writer 是为处理不同的写入需求而设置的。

package json

var encodeStatePool sync.Pool

// An encodeState encodes JSON into a bytes.Buffer.
type encodeState struct {
	bytes.Buffer // accumulated output

	ptrLevel uint
	ptrSeen  map[any]struct{}
}

好了,让我们结束介绍。

今天,我们会深入理解sync.Pool是什么,定义,以及如何使用,它的背后是什么以及你可能想知道的其他一切。

顺便说一下,如果你想要更实用的东西,我们的 Go 专家提供了一篇很好的文章,介绍了我们如何在 VictoriaMetrics 中使用 sync.Pool: Performance optimization techniques in time series databases: sync.Pool for CPU-bound operations

sync.Pool 是啥?

简单来说,sync.Pool在 Go 语言中你可以将零时的对象暂存以便后续使用的地方。

但问题是,你无法控制有多少对象会留在 pool 中,而且你放进去的任何东西都可能随时被移走,没有任何警告。

好在 pool 是线程安全的,因此多个 goroutine 可以同时使用。考虑到它是同步软件包的一部分,这并不令人意外。

但是我们为什么要重复利用对象呢?

当你同时运行大量 goroutine 时,它们通常需要使用相似的对象。想象一下多次并发运行 go f() 的情况。

如果每个 goroutine 都独立创建自己的对象,内存使用量会迅速增加,同时会给垃圾回收器带来压力,因为垃圾回收器需要清理这些不再使用的对象。

这种情况会形成一个恶性循环:高并发导致高内存使用,而高内存使用又会减缓垃圾回收器的效率。sync.Pool 的设计目标正是为了打破这一循环。

type Object struct {
	Data []byte
}

var pool sync.Pool = sync.Pool{
	New: func() any {
		return &Object{
			Data: make([]byte, 0, 1024),
		}
	},
}

要创建一个 sync.Pool,可以提供一个 New() 函数,当池为空时该函数会返回一个新对象。这个函数是可选的,如果不提供,当池为空时会返回 nil

在上述代码片段中,目标是复用 Object 结构体实例,尤其是其中的切片。

复用切片可以有效减少不必要的增长。

例如,如果切片在使用过程中增长到了 8192 字节,可以在将其放回池之前将切片的长度重置为零。底层的数组仍然有 8192 字节的容量,这样下一次需要使用时,这 8192 字节可以被直接复用。(译者注:新手同学这里需要去了解一下 Go 中的切片扩容机制)。

func (o *Object) Reset() {
	o.Data = o.Data[:0]
}

func main() {
	testObject := pool.Get().(*Object)

	// do something with testObject

	testObject.Reset()
	pool.Put(testObject)
}

使用流程是非常清晰的: 你从池子中拿出一个对象,使用它,再重置它,最后将它再放回池子当中。在将对象放回池中之前或从池中取出之后重置对象是可以的,但这并非强制要求,只是一种常见的实践。

如果你不喜欢使用类型断言 pool.Get().(*Object),可以通过以下几种方式来避免:

  1. 使用专用函数从池中获取对象:
func getObjectFromPool() *Object {
	obj := pool.Get().(*Object)
	return obj
}

2. 封装泛型类型的sync.Pool:

type Pool[T any] struct {
	sync.Pool
}

func (p *Pool[T]) Get() T {
	return p.Pool.Get().(T)
}

func (p *Pool[T]) Put(x T) {
	p.Pool.Put(x)
}

func NewPool[T any](newF func() T) *Pool[T] {
	return &Pool[T]{
		Pool: sync.Pool{
			New: func() interface{} {
				return newF()
			},
		},
	}
}

泛型封装为操作 sync.Pool 提供了一种更安全的类型方式,避免了类型断言的使用。

需要注意的是,这种方法会引入一层间接操作,因此会带来微小的开销。在大多数情况下,这种开销是可以忽略的,但如果你处在对 CPU 敏感的环境中,建议运行基准测试以评估是否值得使用这种方法。

但还有更多需要注意的内容。

sync.Pool 和 内存分配陷阱

如果你观察过许多示例(包括标准库中的示例),通常存储在 sync.Pool 中的不是对象本身,而是对象的指针。

下面让我们通过示例解释:

var pool = sync.Pool{
	New: func() any {
		return []byte{}
	},
}

func main() {
	bytes := pool.Get().([]byte)

	// do something with bytes
	_ = bytes

	pool.Put(bytes)
}

我们在使用 []byte 类型的池。通常情况下(尽管并非总是如此),当你将一个值传递给接口时,该值可能会被分配到堆上。这种情况在这里也会发生,不仅是切片,任何传递给 pool.Put() 的非指针类型都有可能导致堆分配。

如果你通过逃逸分析进行检查:

// escape analysis
$ go build -gcflags=-m

bytes escapes to heap

现在,我并不是说变量 bytes 本身被移动到了堆上,而是应该说“bytes 的值通过接口逃逸到了堆上”。

要真正理解为什么会发生这种情况,我们需要深入研究逃逸分析的工作原理(这可能会在另一篇文章中探讨)。不过可以明确的是,如果我们传递一个指针给 pool.Put(),就不会有额外的分配发生:

var pool = sync.Pool{
	New: func() any {
		return new([]byte)
	},
}

func main() {
	bytes := pool.Get().(*[]byte)

	// do something with bytes
	_ = bytes

	pool.Put(bytes)
}

再次运行逃逸分析,你会发现没有出现到堆上的逃逸,如果你想要了解更多,这里是Go 源代码的例子

sync.Pool 的内部机制

在深入了解 sync.Pool 的工作原理之前,有必要先了解一下 Go 的 PMG 调度模型,这是 sync.Pool 高效运行的核心基础。

关于 PMG 模型,有一篇非常棒的文章进行了可视化解释:PMG 模型详解
如果你今天不想深入研究,下面是简化的概述:

  • P 代表逻辑处理器(Logical Processors)。
  • M 代表机器线程(Machine Threads)。
  • G 代表 Goroutine(协程)。 关键点在于每个逻辑处理器(P)在任意时刻只能运行一个机器线程(M)。Goroutine(G)必须绑定到一个线程(M)才能运行。

image.png 那么,如果有 n 个逻辑处理器(P),只要有至少 n 个线程(M),就可以并行运行 n 个协程(G)。

在任意时间点,单个逻辑处理器(P)只能运行一个协程(G)。如果当前协程阻塞、完成任务或其他情况释放处理器,该处理器才能运行新的协程。

但问题是,sync.Pool 并不是一个统一的大池,而是由多个本地池(Local Pools)组成,每个本地池与一个逻辑处理器(P)绑定,如下图所示。

image.png

当在处理器(P)上运行的协程需要从池中获取对象时,它会首先检查自己本地的 P-local 池,然后才会到其他地方查找。

这是一个聪明的设计选择,因为这意味着每个逻辑处理器(P)都有自己的一组对象可以使用。这减少了协程之间的竞争,因为每次只有一个协程可以访问其所在的 P-local 池。

因此,这个过程非常快速,因为不存在两个协程同时尝试从同一个本地池中获取相同对象的情况。

本地池(locql)和伪共享问题

之前我们提到过“每次只有一个协程可以访问 P-local 池”,但实际上这个问题稍微复杂一些。 看看下面的图,每个 P-local 池实际上有两个主要部分:共享部分(shared)和私有部分(private)。

image.png 以下是 Go 源码中的对于本地池的定义:

type poolLocalInternal struct {
	private any
	shared  poolChain
}

type poolLocal struct {
	poolLocalInternal
	pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

私有字段(private)用于存储单个对象,只有拥有该 P-local 池的 P 才能访问它,我们称之为私有对象。

这个设计的目的是让协程能够快速获取可重用的对象(即私有对象),而不需要进行任何互斥锁或同步操作。换句话说,只有一个协程能够访问它自己的私有对象,其他协程无法与之竞争。

但是,如果私有对象不可用时,就会使用共享池链(shared)。

“为什么私有对象不可用?我以为每次只有一个协程能取出并放回私有对象,那么谁会是竞争者呢?”

好问题!

虽然每次只有一个协程能访问 P 的私有对象,但实际上有一个问题。如果 Goroutine A 获取了私有对象,并且随后被阻塞或被抢占,Goroutine B 可能会开始在同一个 P 上运行。此时,Goroutine B 就无法访问私有对象,因为它还被 Goroutine A 占用。

现在,与简单的私有对象不同,共享池链(shared)则更为复杂。

因此,Get() 的流程可以简单地想象成这样:

image.png (注意:上面的图并不完全准确,因为它没有考虑到“受害者池”(victim pool)。)

如果共享池链为空,sync.Pool 将会创建一个新对象(假设你提供了 New() 函数)或者直接返回 nil。顺便提一下,shared 池中也有一个“受害者机制”,不过我们会在最后再讲到这个。

"等等,我看到 P-local 池中有 pad 字段。这是怎么回事?”

当你查看 P-local 池的结构时,一个显眼的地方是 pad 字段。VictoriaMetrics 的首席技术官 Aliaksandr Valialkin 在这个提交中做了调整:

type poolLocal struct {
	poolLocalInternal
	pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

这个 pad 字段可能看起来有点奇怪,因为它没有直接的功能,但它实际上是为了防止在现代多核处理器上出现一个叫做 伪共享(false sharing)的问题。

要理解为什么这个问题很重要,我们需要了解 CPU 如何处理内存和缓存。接下来,我们将深入探讨 CPU 内部工作原理(但不用担心,我们会控制在可理解的范围内)。

现代 CPU 使用一个叫做 CPU 缓存 的组件来加速内存访问,这个缓存被划分为单位,称为 缓存行(cache lines),每个缓存行通常保存 64 或 128 字节的数据。当 CPU 需要访问内存时,它不会只读取一个字节或一个字,而是会加载整个缓存行。

这意味着,如果两个数据在内存中靠得很近,它们可能会被放在同一个缓存行中,即使它们在逻辑上是分开的。

现在,在 Go 的 sync.Pool 中,每个逻辑处理器(P)有一个自己的 poolLocal,它们被存储在一个数组中。如果 poolLocal 结构体小于缓存行的大小,那么不同的 poolLocal 实例可能会被放在同一个缓存行里。这就可能会引发问题。如果两个不同的 P(分别运行在不同的 CPU 核心上)尝试同时访问它们各自的 poolLocal,它们可能会不小心互相干扰。 尽管每个 P 只会处理自己的 poolLocal,但这些结构体可能会共享同一个缓存行。

当一个处理器修改缓存行中的某些内容时,即使其他处理器访问的是该行中的不同变量,该缓存行也会在其他处理器的缓存中失效。这会导致不必要的缓存失效和额外的内存流量,从而引发显著的性能问题.

image.png

这就是 128 - unsafe.Sizeof(poolLocalInternal{})%128 的作用。

它计算了填充 P-local 池所需的字节数,以确保其总大小是 128 字节的倍数。这个填充有助于确保每个 poolLocal 都拥有自己的缓存行,从而防止伪共享,并保持性能,避免冲突。

Pool Chain & Pool Dequeue

sync.Pool 中,共享池链由一个名为 poolChain 的类型表示。

从名字上看,你可能会猜测它是一个双向链表,实际上是这样没错。但这里有一个细节:链表中的每个节点不仅仅是一个可重用的对象,而是另一个结构体,叫做 poolDequeue

type poolChain struct {
	head *poolChainElt
	tail atomic.Pointer[poolChainElt]
}

type poolChainElt struct {
	poolDequeue
	next, prev atomic.Pointer[poolChainElt]
}

poolChain 的设计非常有策略性,下面的图展示了这一点:

image.png

当当前的 pool dequeue(链表头部的那个)满了时,会创建一个新的 pool dequeue,其大小是前一个的两倍。这个新的、更大的池被添加到链表中。

如果你查看 poolChain 结构体,你会注意到它有两个字段:一个指向 head 的指针 *poolChainElt 和一个原子指针 tail atomic.Pointer[poolChainElt]

这两个字段揭示了机制是如何工作的:

  • 生产者(即拥有当前 P-local 池的 P)只向最近的 pool dequeue 添加新项,我们称之为 head。由于只有生产者在操作 head,因此不需要锁或复杂的同步机制,所以操作非常快速。
  • 消费者(其他 Ps)从链表尾部的 pool dequeue 中取出项。由于多个消费者可能同时尝试从尾部弹出项,因此对尾部的访问使用原子操作来同步,以保持顺序。

image.png

关键部分在于:

当尾部的 pool dequeue 被完全清空时,它会从链表中移除,排在后面的 pool dequeue 会成为新的尾部。但头部的情况则有所不同。

当头部的 pool dequeue 没有剩余的项时,它不会被移除。相反,它会保持原地,准备好在有新项添加时进行重新填充。

image.png

现在,让我们来看一下 pool dequeue 是如何定义的。正如名字“dequeue”所暗示的,它是一个双端队列(deque)。

与普通队列只能在后端添加元素、从前端移除元素不同,双端队列允许在前端和后端都能插入和删除元素。

它的机制实际上与 pool chain 很相似。设计上使得一个生产者可以从队列的头部添加或移除项,而多个消费者可以从队列的尾部取出项。

type poolDequeue struct {
	headTail atomic.Uint64
	vals []eface
}

生产者(即当前的 P)可以向队列的前端添加新项或从中取出项。

同时,消费者只能从队列的尾部取出项。这个队列是无锁的,这意味着它不会使用锁来管理生产者和消费者之间的协调,而是仅使用原子操作。

你可以把这个队列看作是一种环形缓冲区(ring buffer)。

环形缓冲区(Ring Buffer),也叫循环缓冲区,是一种数据结构,利用固定大小的数组以环状方式存储元素。之所以称之为“环”缓冲区,是因为缓冲区的末尾会与开始部分相连,形成一个闭合的圆形。

image.png 在我们讨论的池队列(pool dequeue)上下文中,headTail 字段是一个 64 位整数,它将两个 32 位的索引打包成一个单一的值。

image.png

这些索引代表队列的头和尾,帮助跟踪数据在缓冲区中的存储和访问位置。

  • 尾索引(tail) :指向缓冲区中最旧的元素,当消费者(如其他 goroutine)从缓冲区读取数据时,从这个位置开始,向前移动。
  • 头索引(head) :指向下一个将要写入数据的位置。新数据会被放置在这个头索引位置,之后头索引会移动到下一个可用的槽位。

为什么不用两个字段来表示头和尾呢?

将头和尾索引打包成一个 64 位值,可以一次性更新这两个索引,使得操作是原子的。这样做特别有用,当两个消费者(或一个消费者与一个生产者)尝试同时从队列中取出项时,CompareAndSwap (CAS)操作d.headTail.CompareAndSwap(ptrs, ptrs2)能够确保只有一个成功,另一个则失败并重试,从而保持顺序而不需要复杂的锁机制。

队列中的实际数据存储在一个称为 vals 的环形缓冲区中,该缓冲区的大小必须是 2 的幂。

这个设计选择使得在缓冲区到达末尾时处理队列环绕变得更容易。这个缓冲区的每个槽位存储一个 eface 值,这是 Go 内部如何表示空接口(interface{})的方式。

type eface struct {
	typ, val unsafe.Pointer
}

缓冲区中的一个槽位会保持“已使用”状态,直到满足以下两个条件:

  1. 尾索引越过该槽位,意味着该槽中的数据已经被消费者消费。
  2. 消费者访问该槽并将其设置为 nil,表示生产者可以重新使用该槽来存储新数据。

简而言之,池链将链表和环形缓冲区结合在一起用于每个节点。当一个队列(dequeue)填满时,会创建一个新的、更大的队列,并将其链接到池链的头部。这种设置有助于高效地管理大量的对象。

简而言之,池链将链表和环形缓冲区结合在一起用于每个节点。当一个队列(dequeue)填满时,会创建一个新的、更大的队列,并将其链接到池链的头部。这种设置有助于高效地管理大量的对象。

image.png

现在,到了深入了解整个流程的时刻:对象是如何被取出、放回以及自动释放的。这将有助于澄清 Go 对 sync.Pool 的声明:“存储在池中的任何项可能随时自动被移除而不会通知。”

Pool.Put()

让我们从 Put() 流程开始,因为它比 Get() 流程稍微简单一些,并且它与另一个过程相关:将 goroutine 固定到 P 上。

当一个 goroutine 调用 sync.Pool.Put() 时,首先它会尝试将对象存储在当前 P 所在的 P-local 池的私有位置。如果该私有位置已经被占用,对象会被推送到池链的头部,即共享部分。

func (p *Pool) Put(x interface{}) {
	// If the object is nil, it will do nothing
	if x == nil {
		return
	}

	// Pin the current P's P-local pool
	l, _ := p.pin()

	// If the private pool is not there, create it and set the object to it
	if l.private == nil {
		l.private = x
		x = nil
	}

	// If the private object is there, push it to the head of the shared chain
	if x != nil {
		l.shared.pushHead(x)
	}

	// Unpin the current P
	runtime_procUnpin()
}

我们还没有讨论 pin()runtime_procUnpin() 函数,但它们对 Get()Put() 操作非常重要,因为它们确保 goroutine 保持“固定”在当前的 P 上。下面是我的意思:

从 Go 1.14 开始,Go 引入了抢占式调度,这意味着运行时可以暂停一个 goroutine,如果它在处理器 P 上运行时间过长(通常约为 10 毫秒),以便给其他 goroutine 运行的机会。

这通常有助于保持公平性和响应性,但在处理 sync.Pool 时可能会造成一些问题。

sync.Pool 中的 Put()Get() 等操作假设 goroutine 会在整个操作过程中保持在同一个处理器上(比如 P1)。如果在这些操作进行中,goroutine 被抢占并在不同的处理器(P2)上恢复运行,那么它正在处理的本地数据可能来自错误的处理器。

那么,pin() 函数是做什么的呢?以下是 Go 源代码中的注释,它解释了这一点:

// 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) { ... }

基本上,pin() 在 goroutine 将对象放入池中时,临时禁用调度器抢占该 goroutine 的能力。

尽管它说的是“将当前 goroutine 固定到 P 上”,但实际上发生的是当前线程(M)被锁定到处理器(P)上,这样就防止了它被抢占。因此,在该线程上运行的 goroutine 也不会被抢占。

作为副作用,pin() 还会在运行时如果你更改了 GOMAXPROCS(n)(控制处理器数量的参数)时,更新处理器的数量(Ps)。但这不是这里的主要关注点。

image.png

关于共享池链(shared pool chain)呢?

当需要将一个对象添加到链中时,操作首先会检查链的头部。记住那个 head *poolChainElt 指针吗?它指向列表中最新的 pool dequeue

根据不同的情况,可能会发生以下几种情况:

  1. 如果链的头部缓冲区为 nil,意味着链中还没有 pool dequeue,那么会创建一个新的 pool dequeue,其初始缓冲区大小为 8。然后将对象放入这个全新的 pool dequeue 中。
  2. 如果链的头部缓冲区不是 nil,并且该缓冲区没有满,那么对象将直接添加到头部位置的缓冲区中。
  3. 如果链的头部缓冲区不是 nil,但该缓冲区已满,意味着头部索引已经绕回并与尾部索引重合,那么会创建一个新的 pool dequeue。这个新的池的缓冲区大小是当前头部缓冲区大小的两倍。然后,将对象放入这个新的 pool dequeue 中,并更新池链的头部指针,指向这个新的池。

这就是 Put() 流程的基本操作。它是一个相对简单的过程,因为它不涉及与其他处理器(Ps)的本地池交互;一切都在池链的当前头部中完成。

现在,让我们进入更复杂的部分——sync.Pool.Get()

image.png

sync.Pool.Get()

乍一看,Get() 函数似乎与 Put() 非常相似。

它首先通过 pinning 当前的 goroutine 到它的 P 上来防止抢占,然后检查并从其 P-local 池中获取私有对象,无需任何同步。如果私有对象不存在,它会检查共享池链并从链的头部弹出元素。

只有当前在 P-local 池上运行的 goroutine 才能访问链的头部,这就是为什么我们使用 popHead() 的原因:

func (p *Pool) Get() interface{} {
	// Pin the current P's P-local pool
	l, pid := p.pin()

	// Get the private object from the current P-local pool
	x := l.private
	l.private = nil

	// If the private object is not there, pop the head of the shared pool chain
	if x == nil {
		x, _ = l.shared.popHead()

		// Steal from other P's cache
		if x == nil {
			x = p.getSlow(pid)
		}
	}
	runtime_procUnpin()

	// If the object is still not there, create a new object from the factory function
	if x == nil && p.New != nil {
		x = p.New()
	}
	return x
}

Put() 中的 p.pin() 不同,在这里我们还获取了 pid,即当前 goroutine 运行所在 P 的 ID。我们需要这个 ID 来进行“窃取”过程,它会在快速路径失败时发挥作用。

快速路径是指当对象在当前 P 的缓存中可用时。但如果没有找到对象,意味着私有对象和共享链的头部都为空时,慢路径(getSlow)将接管。

在慢路径中,我们尝试从其他处理器(Ps)的缓存池中窃取对象。

窃取的思想是重用可能在其他处理器缓存中处于空闲状态的对象,而不是从头开始创建新对象。如果其他 P 的缓存池中有多余的对象,当前 P 可以抓取这些对象并加以利用。

image.png (上图:窃取流程) 窃取过程基本上会循环遍历所有 P(除了当前的 P,pid),并尝试从每个 P 的共享池链中抓取一个对象:

for i := 0; i <int(size); i++ {
	l := indexLocal(locals, (pid+i+1)%int(size))
	if x, _ := l.shared.popTail(); x != nil {
		return x
	}
}

正如我们之前所讨论的,在一个 poolChain 中,提供者(当前的 P)在链头推送和弹出元素,而多个消费者(其他 Ps)则从链尾弹出元素。

因此,popTail 会查看链表中的最后一个 poolDequeue,并尝试从该 poolDequeue 的末端获取数据。

  • 如果找到了数据,窃取成功,数据被返回。
  • 如果在该 poolDequeue 中没有找到数据,则尾部索引会增加,该 poolDequeue 被从链表中移除。

这个过程会持续进行,直到成功窃取数据或在所有池链中都找不到数据为止。

“那么,如果窃取过程失败,它会使用 New() 创建一个新对象吗?”

不完全是。

如果在所有窃取尝试后仍然找不到任何数据,函数会尝试从所谓的“受害者”处获取数据。这是与 sync.Pool 清理对象相关的新概念,我们将在下一节中详细讲解“受害者机制”。

简要回顾一下我们迄今为止讨论的内容:

image.png

我们尝试通过各种方式获取对象,如果什么都没找到,最终会通过 New() 创建一个新对象。但如果 New()nil,那么就直接返回 nil,就是这么简单。

在尝试使用受害者池(Victim)之后,它会被原子性地标记为空(尽管并发访问仍然可能从中获取数据)。随后,Get() 操作将跳过检查受害者缓存,直到它被重新填充。

那么,受害者池是什么呢?

Victim Pool

虽然 sync.Pool 是为更好地管理资源而设计的,但它并没有提供直接的工具供我们开发者去清理或管理对象生命周期。相反,sync.Pool 在幕后处理清理工作,以避免不受控的增长,这可能导致内存泄漏。

这种清理的主要方式是通过 Go 的垃圾回收器(GC)。

记得我们之前提到的 pin() 吗?实际上,pin() 还有一个副作用。每次 sync.Pool 第一次调用 pin()(或者在通过 GOMAXPROCS 更改 Ps 的数量后),它会被添加到一个名为 allPools 的全局切片中,该切片位于 sync 包内:

package sync

var (
	allPoolsMu Mutex

	// allPools is the set of pools that have non-empty primary
	// caches. Protected by either 1) allPoolsMu and pinning or 2)
	// STW.
	allPools []*Pool

	// oldPools is the set of pools that may have non-empty victim
	// caches. Protected by STW.
	oldPools []*Pool
)

allPools 是一个 []*Pool 切片,用于跟踪应用程序中所有活动的 sync.Pool 实例。

在每次垃圾回收(GC)周期开始之前,Go 的运行时会触发一个清理过程,清理掉 allPools 切片中的所有内容。其工作方式如下:

在 GC 开始之前,Go 运行时会调用 clearPool,将 sync.Pool 中的所有对象(包括私有对象和共享池链)转移到一个称为“victim area”(受害区域)的地方。这些对象并不会立即被丢弃,而是暂时保存在这个受害区域中。

与此同时,在上一个 GC 周期已经存放在受害区域中的对象,会在当前 GC 周期中被彻底清除。

如果你有兴趣查看源代码,可以参考以下:

func poolCleanup() {
	// Drop victim caches from all pools.
	for _, p := range oldPools {
		p.victim = nil
		p.victimSize = 0
	}

	// Move primary cache to victim cache.
	for _, p := range allPools {
		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
}

为什么需要这个 "victim" 机制?为什么要花费最多两个 GC 周期来清理池中的所有对象?

sync.Pool 中使用 victim 机制的原因是为了避免在 GC 周期结束后立即完全清空池中的对象。如果池被一次性清空,可能会导致性能问题,因为所有新的对象请求都需要从头开始创建。因此,Go 选择先将对象转移到 "victim area"(受害区域),确保在对象完全被丢弃之前,仍然有一个缓冲期,供它们继续被重用。

总结来说,sync.Pool 中的一个对象需要至少两个 GC 周期才能被完全移除。

这对使用低 GOGC 值的程序来说可能是个问题。GOGC 控制垃圾回收(GC)运行的频率来清理未使用的对象。如果 GOGC 设置得太低,清理过程可能会过快地移除未使用的对象,导致更多的缓存未命中。

最后需要提到的是,即便使用了 sync.Pool,在高并发和慢速 GC 的情况下,你仍然可能会遇到性能开销。在这种情况下,一个有效的解决方案可能是对 sync.Pool 的使用实施速率限制。

更多阅读

  1. www.cnblogs.com/qcrao-2018/… 这篇文章以源码角度对sync.Pool做出了详细的解读
  2. pkg.go.dev/sync#Pool 官方文档
  3. 如果有新手读者对文中栈和堆的描述感到困惑,这里推荐rust-book.cs.brown.edu/ch04-01-wha… ,虽然是rust教程,但是很好地解释了相关概念:)。
  4. 关于逃逸分析,可参考 geektutu.com/post/hpg-es…