本文基于 Go 1.19
Pool
是一组可以安全在多个 goroutine
间共享的临时对象的集合。 存储在 Pool
中的任何项目都可能在任何时候被移除,因此 Pool
不适合保存那些有状态的对象,如数据库连接、TCP 连接等。 Pool
的目的是缓存已分配但未使用的项以供以后使用,从而减少垃圾收集器的压力。
也就是说,它可以轻松构建高效、线程安全的空闲列表,但是,它并不适用于所有空闲列表。
使用实例
下面以几个实际的例子来说明 Pool
的一些使用场景。
下面是两个非常典型的使用场景,但是在实际使用中,对于那些需要频繁创建和销毁的对象的场景,我们都可以考虑使用
Pool
。
gin 里面 Context 对象使用 Pool 保存
在 gin
的 Engine
结构体中的
ServeHTTP
方法中,可以看到 Context
对象是从
Pool
中获取的。 然后在处理完请求之后,将
Context
对象放回 Pool
中。
1 | func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) { |
我们可以去看看 gin
中 Context
对象的定义,我们会发现,它其中有很多字段。
设想一下,如果每一个请求都创建一个 Context
对象,那么每一个请求都要对 Context
进行内存分配,
分配了之后,如果请求结束了,这些申请的内存在后续就要被回收掉(当然,不是马上就回收)。
这样一来,如果待回收的 Context
对象很多,那么垃圾回收器就会被压力很大。
同样的做法在
echo
这个框架中也有出现。
fmt 里面的 pp 结构体
在我们使用 fmt
包来打印的时候(比如,调用
fmt.Fprintf
),其实底层是要使用一个名为 pp
的结构体来进行打印的。 如果我们的系统中需要大量地使用 fmt
库来做格式化字符串的操作,如果每次进行格式化操作的时候都
new
一个 pp
对象,
那么也会在某个时刻导致垃圾回收器的压力很大。
所以,fmt
包中使用 pp
的时候,都是从
Pool
中获取的:
1 | // newPrinter allocates a new pp struct or grabs a cached one. |
上面这个方法是获取 pp
对象的方法。我们在这里没有看到重置
pp
字段的代码,因为这些操作在 pp
的
free
方法中了:
1 | // free saves used pp structs in ppFree; avoids an allocation per invocation. |
我们不能对
sync.Pool
有任何假设,比如,不要想着 Put 进去的对象带了一个状态,然后 Get 出来的时候就能拿到这个状态。 因为这个对象可能在任何时候被清除掉。
从上面这个例子中,我们可以看到 p.buf = p.buf[:0]
这一行对 buf
进行了重置, 这给我我们的启示是,我们在使用
sync.Pool
的时候,如果我们存取的对象会带有一些不同的字段值,
那么我们可能需要对这些字段进行重置后再使用,这样就可以避免
Get
到的对象带有之前的一些状态,
不过重置这些字段的开销相比分配新的内存以及后续
GC,其实开销可以忽略不计。
Pool 整体模型
Pool
本质上是一个双端队列,这个队列支持以下操作:
pushHead
:将一个对象放到队列的头部,这也是唯一的入队操作。popHead
:从队列的头部取出一个对象。如果取不到则使用New
方法创建一个新的对象。popTail
:从队列的尾部取出一个对象。如果取不到则使用New
方法创建一个新的对象。
可以简单地用下图表示:
在阅读本文过程中,想不清楚的时候,回想一下这个模型,可能会有所帮助。
当然,在实际的实现中,比这个复杂多了,但是这个模型已经足够我们理解
Pool
的工作原理了。
Pool 的双端队列是使用数组还是链表
我们知道,队列的存储通常有两种方式,一种是数组,一种是链表,两者的优缺点如下:
优点 | 缺点 | |
---|---|---|
数组 | 存储空间连续,可以根据下标快速访问 | 大小固定,扩容成本高 |
链表 | 大小不固定,可以很容易增加新的元素 | 访问效率不如数组。需要额外的空间来存储前后元素的指针 |
那么 Pool
里面用的是数组还是链表呢?答案是:数组
+ 链表。
Pool 为什么要用数组 + 链表
为了快速访问队列中的元素,使用数组是最好的选择,但是数组的大小是固定的,如果队列中的元素很多,那么数组就会很快被填满。
如果我们还想继续往队列中添加元素,那么就需要对数组进行扩容,这个成本是很高的(因为本来就是需要频繁分配/销毁对象的场景才会使用
Pool
)。 同时,我们知道 Pool
设计的目的就是为了减少频繁内存分配带来的性能问题,如果在使用
Pool
的过程中频繁对其进行扩容,那么就违背了
Pool
设计的初衷了。
为了解决数组扩容的问题,我们可以考虑一下使用链表。在我们
Put
的时候往链表的头部添加一个元素,然后 Get
的时候从链表的尾部取出一个元素(还需要移除)。
但是这样我们就需要一个结构体来表示我们的节点了,那么问题来了,我们又需要频繁地分配/销毁这个结构体,这样就又回到了最开始的问题了(频繁创建/销毁对象)。
所以,只使用链表也不是一个好的选择。
所以,Pool
采用了 数组 + 链表
的方式来实现双端队列,它们的关系如下:
- 数组:存储队列中的元素,数组的大小是固定的。
- 链表:当一个数组存储满了之后,就会新建一个数组,然后通过链表将这两个数组串联起来。
最终,Pool
的双端队列的结构如下:
数组如何实现队列
我们知道,数组的存储空间是固定的一块连续的内存,所以我们可以通过下标来访问数组中的元素。
我们 push
的时候,将 head
下标
+1
,然后 pop
的时候,也要修改对应的下标,
但是这样会导致一个问题是,早晚 head
会超出数组的下标范围,但这个时候数组可能还有很多空间, 因为在我们
push
的时候,可能也同时在
pop
,所以数组中的空间可能还有很多。
所以,就可以在 head
超出数组下标范围的时候,将
head
对数组长度取模,这样就可以循环使用数组了:
不过对于这种情况,使用下面的图更加直观:
Pool
里面是使用poolDequeue
这个结构体来表示上图这个队列的,本身是一个数组,但是是当作环形队列使用的。
poolChain 的最终模型
poolChain
就是上面说的 数组 + 链表
的组合,它的最终模型如下:
说明:
poolChain
本身是一个双向链表,每个节点都是一个poolDequeue
,每个节点都有prev
和next
指针指向前后节点。上图的head
是头节点,tail
是尾节点。poolDequeue
是一个数组,它的大小是固定的,但是它是当作环形队列使用的。tail
初始化的时候长度为 8,具体可参考poolChain
的pushHead
方法,后面会说到。pushHead
的时候,如果head
中的环形队列已经满了,那么就会新建一个poolDequeue
,然后将它插入到head
的前面。(新的head
节点的大小为前一个head
节点的两倍)popTail
、popHead
的时候,如果tail
或者head
中的环形队列(poolDequeue
)已经空了,那么就会将它从链表中移除。
多个 P 的情况下的 poolChain
这里假设 P 跟我们机器的逻辑处理器的数量一致。(这里涉及到了 goroutine 的调度机制,不了解可以先了解一下再回来看。)
我们知道,在 go 中,每一个 goroutine 都会绑定一个
P,这样才可以充分利用多核的优势。 设想一下,如果我们有多个 goroutine
同时存储一个 Pool
,会出现什么情况呢?
会导致很激烈的数据竞争,虽然没有使用 Mutex
这种相对低效的互斥锁来解决竞争问题,使用的是原子操作,但是也会导致性能下降。
所以,在 Pool
的实现中,会为每一个 P 都创建一个
poolChain
,每次存取,先操作本地 P 绑定的
poolChain
,这样就可以减少多个 goroutine 同时操作一个
Pool
的竞争问题了。
所以,最终的 Pool
的模型会长成下面这个样子,每个 G
关联了一个 poolChain
:
注意:这里不是
Pool
实际的样子,只是为了说明Pool
的实现原理。
最终实现中的 Pool
在实际的实现中,其实会跟上一个图有一些差异,可以说复杂很多:
从上图可以看出,其实每个 P 关联的并不是 poolChain
,而是
poolLocal
,poolLocal
里面包含了一个
poolLocalInternal
和一个 pad
,pad
是为了避免伪共享而添加的。而 poolLocalInternal
就是实际上
Pool
中存储数据的一个结构体。poolLocalInternal
中包含了两个字段,private
和
shared
,shared
就是我们上面说的
poolChain
,而 private
是一个 any
类型的字段,在我们调用 Pool
的 Put
方法的时候,会先尝试将数据存储到 private
中,如果
private
中已经有数据了,那么就会将数据存储到
shared
中。同样的,在 Get
的时候,也会先从
private
中获取数据,如果 private
中没有数据,那么就会从 shared
中获取数据。
而相应的,Pool
存取数据会变成:
pushHead
:我们调用Pool
的Put
方法的时候,只会写入到本地 P 关联的那个poolLocal
中。popHead
:这个方法也只能从本地 P 关联的poolLocal
中取数据。popTail
:这个方法会从本地 P 关联的poolLocal
中取数据,如果取不到,那么就会从其他 P 关联的poolLocal
中取数据。
这样就可以减少多个 goroutine 同时操作一个
Pool
的竞争问题了(但是无法避免)。
Pool 模型总结
最后,我们将这一节的内容总结一下,可以得到下面这个图:
说明:
- go 进程内会有多个 P,每个 P 都会关联一个
poolLocal
。 poolLocal
中包含了一个poolLocalInternal
和一个pad
,poolLocalInternal
中包含了两个字段,private
和shared
。private
是一个any
类型的字段,用来存储我们调用Pool
的Put
方法的时候传入的数据,Get
的时候如果private
中有数据,那么就会直接返回private
中的数据。shared
是一个poolChain
,用来存储我们调用Pool
的Put
方法的时候传入的数据,Get
的时候如果当前 P 绑定的poolLocal
内是空的,那么可以从其他P
绑定的shared
的尾部获取。poolChain
是一个双向链表,每个节点都是一个poolDequeue
,每个节点都有prev
和next
指针指向前后节点。上图的head
是头节点,tail
是尾节点。poolDequeue
是一个数组,它的大小是固定的,但是它是当作环形队列使用的。pushHead
的时候,如果poolChain
的节点满了,那么会新建一个节点,其容量为前一个节点的两倍。poolChain
支持三种操作:pushHead
、popHead
、popTail
。pushHead
会将数据存储到当前 P 关联的poolChain
的头部。
Pool 中的结构体
在开始分析源码之前,我们先来看一下 Pool
的 UML 图:
上图中已经包含 Pool
实现的所有关键结构体了,下面我们来分析一下这些结构体的作用。
sync.Pool 结构体:
1 | type Pool struct { |
字段说明:
local
:就是我们上文说到的poolLocal
类型的切片,长度是runtime.GOMAXPROCS(0)
,也就是当前 P 的数量。之所以使用切片类型是因为我们可以在运行的过程中调整 P 的数量,所以它的长度并不是固定的,如果 P 的数量变了,poolLocal
也会跟着改变。localSize
:local
的长度。victim
:上一轮 GC 时的local
字段的值。victimSize
:victim
的长度。New
:新建对象的方法。
victim
的作用是在 GC 的时候,将 local
的值赋值给 victim
,然后将 local
置为
nil
,这样就可以避免在 GC 的时候,local
中的对象被回收掉。 当然,并不是完全不会回收,再经历一次 GC
之后,victim
中的对象就会被回收掉。这样做的好处是,可以避免 GC 的时候清除
Pool 中的所有对象, 这样在 GC 之后如果需要大量地从 Pool
中获取对象也不至于产生瞬时的性能抖动。
victim cache
是计算机科学中的一个术语,victim cache
是位于 cpu cache
和主存之间的又一级 cache,用于存放由于失效而被丢弃(替换)的那些块。
每当失效发生时,在访问主存之前,victim cache
都会被检查,如果命中,就不会访问主存。
Pool 获取对象的流程
最终,当我们调用 Pool
的 Get
方法的时候,会按下图的流程来获取对象:
说明:
- 首先会从当前 P 关联的
poolLocal
中的private
字段中获取对象,如果获取到了,那么直接返回。 - 如果
private
字段中没有对象,那么会从当前 P 关联的poolLocal
中的shared
字段中获取对象,如果获取到了,那么直接返回(这里使用的是popHead
方法)。 - 尝试从其他 P 关联的
poolLocal
中的shared
字段中获取对象,如果获取到了,那么直接返回(这里使用的是popTail
方法)。 - 如果其他 P 关联的
poolLocal
中的shared
字段中也没有对象,那么会从victim
中获取对象,如果获取到了,那么直接返回。 - 如果
victim
中也没有对象,那么会调用New
方法来创建一个新的对象(当然前提是我们创建Pool
对象的时候设置了New
字段)。 - 如果
New
字段也没有设置,那么会返回nil
。
poolLocal 和 poolLocalInternal 结构体:
在 Pool
中,使用了 poolLocal
和
poolLocalInternal
两个结构体来表示实际存储数据的结构体。
当然我们也可以只使用 poolLocalInternal
这个结构体,但是为了避免伪共享,在 Pool
的实现中, 将
poolLocalInternal
放在了 poolLocal
的第一个字段,然后在 poolLocal
中添加了一个
pad
字段,用来填充 poolLocalInternal
到 cache
line 的大小。
1 | // 实际存储 Pool 的数据的结构体 |
其实这里比较关键的地方是 pad
字段的作用,我们知道,CPU
会将内存分成多个 cache line,CPU 从内存中读取数据的时候,
并不是一个字节一个字节地读取,而是一次性读取一个 cache line
的数据。但是如果我们的数据结构中的字段不是按照 cache line
的大小来排列的, 比如跨了两个 cache
line,那么在读取的时候就会产生伪共享,这样就会降低性能。
伪共享的原因是,数据跨了两个 cache line,那么在读取的时候,就会将两个 cache line 都读取到 CPU 的 cache 中, 这样有可能会导致不同 CPU 在发生数据竞争的时候,会使一些不相关的数据也会失效,从而导致性能下降。 如果对齐到 cache line,那么从内存读取数据的时候,就不会将一些不相关的数据也读取到 CPU 的 cache 中,从而避免了伪共享。
poolChain、poolChainElt 和 poolDequeue 结构体
为什么要把这三个放一起讲呢?因为这三个结构体就是 Pool
中做实际存取数据的结构体,三者作用如下:
poolChain
:poolChain
是一个链表,每个节点都是poolChainElt
。poolChainElt
:每个poolChainElt
中包含了一个poolDequeue
,同时包含了指向前一个节点和后一个节点的指针。poolDequeue
:poolDequeue
是一个双端队列(环形队列,使用数组存储),用来存储Pool
中的数据。
1 | // poolChain 是 poolDequeue 的动态大小版本。 |
poolChain
的结构大概如下:
poolDequeue
的结构大概如下:
在 poolDequeue
中,headTail
是一个
uint64
类型,它的高 32 位是 head
,低 32 位是
tail
。 这样一来,这样就可以使用原子操作来保证
head
和 tail
的协程安全了。
在 poolDequeue
中,vals
是一个切片类型,元素类型是 eface
,eface
是一个空接口类型,内存布局跟 interface{}
一样,
因此可以看作是一个 interface{}
类型。如果这里看不明白可以看看《go
interface 设计与实现》。
那为什么不直接使用 interface{}
类型呢?因为如果用了
interface{}
类型, 那么 poolDequeue
就无法区分保存的 val
是 nil
还是一个空的槽。
那有什么解决办法呢?在 push
和 pop
的时候使用互斥锁可以解决这个问题(因为目前的实现是使用原子操作的,所以这才需要判断保存的
val
是 nil
还是空槽),
但是这样就会导致性能下降。
Pool 源码剖析
在源码剖析的开始部分,不会深入去讲底层的存取细节,我们将其当作一个抽象的队列来看待即可,这样可能会更加便于理解。不过在讲完
Pool
的实现之后,最后还是会展开讲述这个复杂的 "队列" 的那些实现细节。
接下来,我们来看看 Pool
的源码实现。 Pool
提供的接口非常简单,只有 Put
、Get
两个方法,还有一个 New
字段,用来指定 Pool
中的元素是如何创建的:
New
属性:New
是一个函数,用来创建Pool
中的元素。Put
方法:Put
方法用来向Pool
中放入一个元素。Get
方法:Get
方法用来从Pool
中获取一个元素。
Get
Get
方法的实现如下:
Get
从 Pool
中选择一个任意项,将其从
Pool
中移除,并将其返回给调用者。 Get
可能会选择忽略池并将其视为空的。 调用方不应假定传递给 Put
的值与 Get
返回的值之间存在任何关系。 (Put
和
Get
之间可能会发生 GC
,然后 Pool
里面的元素可能会被 GC
回收掉)
如果 Get
否则返回 nil
并且
p.New
不为 nil
,则 Get
返回调用
p.New
的结果。
1 | // Get 从 Pool 中获取一个对象 |
Pool
Get
的流程可以总结如下:
- 将当前的
goroutine
和P
绑定,禁止被抢占,返回当前P
的本地缓存(poolLocal
)和P
的ID
。 - 从本地
private
取,如果取不到,就从本地shared
的头部取,如果取不到,就从其他P
的shared
的尾部取。获取到则返回 - 如果从其他的
P
的shared
的尾部也获取不到,则从victim
获取。获取到则返回 - 将当前的
goroutine
和P
解绑,允许被抢占。 - 如果
p.New
不为nil
,则返回p.New
的结果。
再贴一下上面那个图(当然,下图包含了下面的 getSlow
的流程,并不只是 Get
):
在 Pool
中一个很关键的操作是
pin
,它的作用是将当前的 goroutine
和
P
绑定,禁止被抢占。 这样就可以保证在 Get
和
Put
的时候,都可以获取到当前 P
的本地缓存(poolLocal
), 否则,有可能在 Get
和 Put
的时候,P
会被抢占,导致获取到的
poolLocal
不一致,这样 poolLocal
就会失去意义, 不得不再次陷入跟其他 goroutine
竞争的状态,又不得不考虑在如何在不同 goroutine
之间进行同步了。
而绑定了 P
后,在 Get
和 Put
的时候,就可以使用原子操作来代替其他更大粒度的锁了,
但是我们也不必太担心,因为绑定 P
的时间窗口其实很小。
getSlow 源码剖析
在 Get
中,如果从 private
和
shared
中都取不到,就会调用 getSlow
方法。它的作用是:
- 尝试从其他
P
的shared
的尾部取。 - 尝试从
victim
获取。
getSlow
的实现如下:
1 | // 从其他 P 的 shared 的尾部取。 |
pin 源码剖析
pin
方法的实现如下:
pin
将当前 goroutine
固定到 P
上,禁用抢占并返回 poolLocal
池中对应的
poolLocal
。 调用方必须在完成取值后调用
runtime_procUnpin()
来取消抢占。
1 | // 将当前的 goroutine 和 P 绑定,禁止被抢。 |
在 pinSlow
中比较关键的操作是,它会初始化当前
P
关联的 poolLocal
,并将其添加到
allPools
中。 关于 allPools
的作用,可以看下一小节。
pinSlow
的流程: 1. 解除当前 P
的绑定。 2.
加全局 Pool
的锁。 3. 重新绑定当前 P
。 4.
如果当前 P
的 id
小于
localSize
,那么就返回当前 P
的
poolLocal
。(典型的 double-checking
) 5. 如果
local
还没初始化,那么将当前 P
的
poolLocal
添加到 allPools
中。 6. 初始化
local
。最后返回当前 P
的
poolLocal
。
对于 local
的初始化,我们可以参考一下下图(我们需要知道的是,切片的底层结构体的第一个字段是一个数组):
&local[0]
是 []poolLocal
的首地址,unsafe.Pointer(&local[0])
就是
poolLocal
数组的首地址。
indexLocal 源码剖析
我们在上面的代码中还可以看到一个 indexLocal
函数,它的作用是返回 poolLocal
数组中的第 i
个元素。
1 | // l 指向了 poolLocal 数组的首地址,i 是数组的索引。 |
我们之前在看 Pool
结构体的时候,看到过
local
字段,它的类型是
unsafe.Pointer
,也就是一个指针。 然后我们再结合一下
indexLocal
的实现,就可以知道 local
字段指向的是一个 poolLocal
数组了。 其中 l
是
poolLocal
数组的首地址,i
是数组的索引,unsafe.Sizeof(poolLocal{})
是
poolLocal
的大小。
这一小节没看懂可以参考一下我写的另外一篇文章 《深入理解 go unsafe》。
allPools 的作用
allPools
的目的是在 GC
的时候,遍历所有的
Pool
对象,将其中的 victim
替换为
local
,然后将 local
设置为 nil
。
这样后续的 Get
操作在 local
获取不到的时候,可以从 victim
中获取。一定程度上减少了
GC
后的性能抖动。
在 Pool
中,还定义了下面几个全局变量:
1 | var ( |
如果我们第一次看这些变量,可能会有点懵,不知道它们的作用是什么。
我们可以再结合一下 poolCleanup
函数的实现,就可以知道它们的作用了。
1 | func poolCleanup() { |
poolCleanup
这个函数会在 GC
开始的时候被调用,它的作用是将 local
移动到
victim
中。 同时,将 victim
置为
nil
,这样 GC
就可以回收 victim
中的对象了,也就是说要经历两轮 GC
local
才会真正地被回收。 也就意味着,GC
的时候,local
其实并没有被回收,而是被移动到了
victim
中。
poolCleanup
可以用下图表示,实际上就是使用
local
和 localSize
覆盖 victim
和
victimSize
:
Put
Put
的实现比较简单,就是将对象放到 local
中,不需要 Get
那种操作其他 P
绑定的
poolLocal
的情况。
1 | // Put 将 x 添加到 Pool 中。 |
Pool
Put
的流程: 1. 如果 Put
的值是 nil
,则直接返回。 2. 将当前的 goroutine
和 P
绑定,禁止被抢占,返回当前 P
的本地缓存(poolLocal
)和 P
的
ID
。 3. 如果本地 private
为空,则将
x
放入本地 private
。 4. 如果本地
private
不为空,则将 x
放入本地
shared
的头部。 5. 将当前的 goroutine
和
P
解绑,允许被抢占。
这里面的 pin
和
runtime_procUnpin
,我们在前文已经介绍过了,这里就不再赘述了。
New
这里说的 New
是 sync.Pool
中的
New
字段,在我们尝试了所有方法都获取不到对象的时候, 会判断
Pool
的 New
属性是否为
nil
,如果不为 nil
,则会调用 New
方法,创建一个新的对象。
poolChain 和 poolDequeue 源码剖析
poolChain
是一个双向链表,它的每一个节点的元素是
poolDequeue
。
在上文中,对于 Get
和 Put
的细节,还没有具体展开,因为不了解这些细节也不影响我们理解
Pool
的整体流程。 现在是时候来看看 poolChain
和 poolDequeue
这两个结构体的实现了,会结合起来一起看。
poolChain
和 poolDequeue
里面都提供了以下三个方法:
pushHead
:将对象放到队列的头部。popHead
:从队列的头部取出一个对象。popTail
:从队列的尾部取出一个对象。
不一样的是,poolChain
里面的方法会处理链表节点的创建和销毁,而 poolDequeue
里面的方法才是实际从队列存取对象的方法。
pushHead
poolChain
的 pushHead
方法的实现如下:
1 | // 添加一个元素到队列头部 |
poolChain
的 pushHead
方法的流程:
- 如果链表为空,那么就初始化链表。
- 如果链表不为空,那么就尝试
pushHead
。 - 如果
pushHead
失败,那么就分配一个两倍大小的新dequeue
。 - 然后把新
dequeue
放到链表头部。(push
的时候已经锁定了goroutine
在P
上,所以这一步是没有并发问题的)
在 poolChain
的 pushHead
方法中,唯一需要特别注意的是
storePoolChainElt(&c.tail, d)
这一行代码。 这里使用了
storePoolChainElt
方法,而不是直接使用
c.tail = d
。 这是因为 c.tail
是会和其他
goroutine
存在竞争的(其他 goroutine
获取对象的时候可能会修改
tail
),因此不能直接赋值,而是使用了原子操作。
poolChain
的 pushHead
方法的流程图如下:
队列头来说,
prev
实际上指向的是head
的下一个元素,但是又不能叫next
,因为next
被用来表示tail
的下一个元素,所以就叫了prev
。我们需要知道prev
、next
本质上都是指向了下一个元素,就看你是从队列头还是队列尾来查找。
在 poolChain
中,其实实际存储对象的时候并不是在
poolChain
,而是在 poolChain
的每一个节点中的
poolDequeue
中。 所以我们再来看看 poolDequeue
中的 pushHead
实现:
1 | // pushHead 在队列的头部添加 val。 |
poolDequeue
的 pushHead
方法的流程:
pushHead
的处理流程: 1.
判断队列是否已满,如果已满则返回 false
。 2. 获取下一个
push
的位置,先判断这个位置是否可用,如果不可用则返回
false
。(可能和 popTail
冲突,如果
popTail
没来得及将其中的值取出来,那么这个槽就还不能使用)
3. 如果可用,则将值放入这个位置,然后将 head
指针加
1
。
在 poolDequeue
中,我们看到有一行代码比较奇怪:atomic.LoadUint64(&d.headTail)
,这是为了可以原子操作存取
head
和 tail
两个值。这样就可以避免锁的使用了。
popHead
poolChain
的 popHead
方法的实现如下:
1 | // popHead 会从链表头部 pop 出一个元素。 |
prev 虽然从命名上看是前一个,但是实际上是下一个节点。从 head 开始遍历的话,prev 就是下一个节点,从 tail 开始遍历的话,next 就是下一个节点。
poolChain
的 popHead
的处理流程:
- 如果链表为空,那么就返回
false
。 - 如果链表不为空,那么就尝试
popHead
。 - 如果
popHead
失败,那么就尝试从链表下一个 dequeuepopHead
。(循环直到最后一个poolChainElt
)
poolChain
的 popHead
方法的流程图如下:
在 poolChain
中,实际上调用的是 poolDequeue
的 popHead
方法:
1 | // popHead 删除并返回队列头部的元素。 |
poolDequeue
的 popHead
的处理流程:
- 判断队列是否为空,如果为空则返回
false
。 - 尝试将
head
指针减1
,如果失败则进行下一轮尝试(自旋,for + 原子操作是无锁编程中很常见的写法)。 - 将
head
指针对应的槽位的值取出来,然后将槽位置为nil
。
popTail
poolChain
的 popTail
方法的实现如下:
1 | // 从队列尾取出一个元素 |
poolChain
中 popTail
的处理流程:
- 如果链表为空,那么就返回
false
。 - 如果链表不为空,那么就尝试
popTail
。 - 如果
popTail
失败,那么就尝试从链表上一个 dequeuepopTail
。(循环直到第一个poolChainElt
)
poolChain
的 popTail
方法的流程图如下:
在 popTail
的时候,如果发现 poolChainElt
已经为空了,那么就会从链表中移除它:
poolDequeue
的 popTail
方法的实现如下:
1 | // popHead 和 popTail 都有取值,然后将槽置空的过程,但是它们的实现是不一样的。 |
poolDequeue
的 popTail
的处理流程:
- 判断队列是否为空,如果为空则返回
false
。 - 尝试将
tail
指针加1
,如果失败则进行下一轮尝试(自旋)。 - 将
tail
指针对应的槽位的值取出来,然后将槽位置为nil
。
sync.Pool 设计要点
在 sync.Pool
中,我们可以看到它有许许多多的编程技巧,为了实现一个高性能的
Pool
要做的东西是非常复杂的,
但是对于我们而言,我们只会用到它暴露出来的两个非常简单的接口
Put
、Get
,这其实也算是 Go
语言的一种设计哲学吧,
把复杂留给自己,把简单留给用户。但是,我们还是要知道它的实现原理,这样才能更好的使用它。
接下来,我们就再来总结一下 sync.Pool
高性能的一些设计要点:
noCopy
字段,防止sync.Pool
被复制。sync.Pool
是不能被复制的,否则会导致一些隐晦的错误。local
字段,用于存储与P
关联的一个poolLocal
对象,在多个goroutine
同时操作的时候,可以减少不同goroutine
之间的竞争,只有在本地的poolLocal
中没有找到对象的时候,才会去其他goroutine
关联的队列中去取。poolDequeue
中pushHead
跟popTail
之间会存在head
、tail
指针上的一些竞争,这些竞争问题是通过原子操作来解决的(相比互斥锁效率更高)。使用了原子操作可能就会有失败的时候,这个时候,再次重试就可以了。poolDequeue
中的head
/tail
指针使用一个字段来保存,然后通过原子操作保证head
/tail
的一致性。poolChain
使用链表的方式解决容量问题,并且新增一个元素到链表的时候,容量为上一个元素(poolChain
链表头)的两倍(非常常见的扩容策略)。双向链表,可以接受别的P
的popTail
操作,减少竞争的同时可以充分利用多核。pin
保证P
不会被抢占。如果一个goroutine
在执行Put
或者Get
期间被挂起,有可能下次恢复时,绑定的就不是上次的P
了。那整个过程就会完全乱掉,因为获取到的poolLocal
不是之前那个了。使用pin
可以解决这种并发问题。- 自旋操作,因为原子操作失败的时候可能存在竞争,这个时候再尝试一下就可能成功了。(
for
+ 原子操作是无锁编程中很常见的一种编程模式,在sync.Map
中也有很多类似操作) pad
内存对齐,可以避免伪共享。poolDequeue
中存储数据的结构是一个环形队列,是连续的内存,可以充分利用 CPU 的cache
。在访问poolDequeue
某一项时,其附近的数据项都有可能加载到统一cache line
中,有利于提升性能。同时它是预先分配内存的,因此其中的数据项可不断复用。
总结
最后,总结一下本文内容:
sync.Pool
是一个非常有用的工具,它可以帮助我们减少内存的分配和回收(通过复用对象),提升程序的性能。但是,我们要注意它的使用场景,它适合那些没有状态的对象,同时,我们不能对那些从Pool
中Get
出来的对象做任何假设。- 我们在
Put
或者Get
之前,可能需要对我们操作的对象重置一下,防止对后续的操作造成影响。 Pool
中的对象存储是使用队列的方式,这个队列的实现是一个链表(poolChain
),链表的每一个节点都是一个环形队列(poolDequeue
)。这个队列支持以下三种操作:pushHead
:将一个对象放到队列的头部。popHead
:将队列的头部的对象取出来。popTail
:将队列的尾部的对象取出来。
sync.Pool
的实现中,使用了很多编程技巧,比如noCopy
、pin
、pad
、原子操作等等,这些技巧都是为了实现一个高性能的Pool
而做的一些优化,我们可以学习一下,具体参考上一节。sync.Pool
中,Put
和Get
操作的时候会先将goroutine
与P
绑定,然后再去操作P
关联的poolLocal
,这样可以减少竞争,提升性能。因为每一个P
都有一个关联的poolLocal
,所以多个goroutine
操作的时候,可以充分利用多核。在操作完成后,再解除绑定。- 考虑到
GC
直接清除Pool
中的对象会在GC
后可能会产生性能抖动,所以在GC
的时候,其实并不会马上清除Pool
中的对象,而是将这些对象放到victim
字段中,在Get
的过程中,如果所有的poolLocal
中获取不到对象,则会从victim
中去找。但是再进行GC
的时候,旧的victim
会被清除。也就是Pool
中对象的淘汰会经历两次GC
。