基本介绍与用法
在高并发场景下,我们会遇到很多问题,垃圾回收(GC
)就是其中之一。
Go
语言中的垃圾回收是自动执行的,这有利有弊,好处是避免了程序员手动对垃圾进行回收,简化了代码编写和维护,坏处是垃圾回收的时机无处不在,这在无形之中增加了系统运行时开销。在对系统性能要求较高的高并发场景下,这是我们应该主动去避免的,因此这需要对对象进行重复利用,以避免产生太多垃圾,而这也就引入了我们今天要讨论的主题 —— sync
包提供的 Pool
类型。
sync.Pool
数据类型用来保存一组可独立访问的临时对象。请注意这里加粗的“临时”这两个字,它说明了 sync.Pool
这个数据类型的特点,也就是说,它池化的对象会在未来的某个时候被毫无预兆地移除掉。而且,如果没有别的对象引用这个被移除的对象的话,这个被移除的对象就会被垃圾回收掉。
来看看sync.Pool
使用的一般场景:
- 临时对象池:当你的程序需要频繁创建和销毁临时对象时,可以使用
sync.Pool
来管理这些对象。这可以帮助减少内存分配和垃圾回收的负担,提高程序性能。 - 减少内存分配:通过在
sync.Pool
中缓存对象,可以避免频繁地进行内存分配。这对于内存敏感的应用程序特别有用,如高性能服务器或实时应用。 - 短生命周期对象:
sync.Pool
特别适用于那些对象生命周期非常短暂的情况,因为它们可以被有效地重用而不必等待垃圾回收。 - 连接池:
sync.Pool
可以用于管理连接池,比如数据库连接、网络连接等。这可以减少每次请求时创建和销毁连接的开销,提高连接的重用性。 - 临时数据缓存:如果你需要在某个计算中保存临时数据,但不希望它们一直占用内存,可以将这些临时数据存放在
sync.Pool
中,当计算完成后释放这些数据。
因为 Pool
可以有效地减少新对象的申请,从而提高程序性能,所以 Go
内部库也用到了 sync.Pool
,比如 fmt
包,它会使用一个动态大小的 buffer
池做输出缓存,当大量的 goroutine
并发输出的时候,就会创建比较多的 buffer
,并且在不需要的时候回收掉。
sync.Pool
还有以下特点:
- 线程安全:
sync.Pool
内部使用了同步机制,因此可以安全地在多个goroutine
中使用。 - 临时对象的缓存:
sync.Pool
通常用于缓存临时对象,这些对象在某个阶段被频繁创建和销毁,但其生命周期很短。 - 自动回收:
sync.Pool
并不保证缓存的对象一定会一直保留,它有可能随时清理缓存中的对象,因此不能将其用于长期持有对象。 - 零值:
sync.Pool
的零值是一个可用的空池,因此在不初始化的情况下可以安全地使用。 - 性能提升:通过缓存和复用对象,
sync.Pool
可以有效地减少内存分配和垃圾回收的开销,从而提高程序的性能。
sync.Pool
主要对外暴露三个接口:
func (p *Pool) Put(x interface{}) {...}
func (p *Pool) Get() interface{} {...}
New func() interface{}
Get
方法会返回Pool
已经存在的对象;Put
将一个元素返还给Pool
,Pool
会把这个元素保存到池中,并且可以复用。但如果Put
一个nil
值,Pool
就会忽略这个值;New
创建一个Pool
实例,关键一点是配置New
方法, 当Pool
池子没有可Get
对象,则会返回New
方法的返回值。
下面是一个实例基本代码:
package main
import (
"fmt"
"sync"
)
type MyObject struct {
value int
}
func main() {
// 创建一个对象池
objectPool := sync.Pool{
New: func() interface{} {
fmt.Println("Creating a new object")
return &MyObject{}
},
}
// 从池中获取对象
obj1 := objectPool.Get().(*MyObject)
obj1.value = 42
fmt.Println("Object 1:", obj1)
// 归还对象到池中
objectPool.Put(obj1)
// 再次从池中获取对象,应该是同一个对象
obj2 := objectPool.Get().(*MyObject)
fmt.Println("Object 2:", obj2)
}
在上面的示例中,我们创建了一个 sync.Pool
,并使用 New
函数指定了如何创建新的对象。通过调用 Get
方法可以从池中获取对象,而通过 Put
方法可以将对象放回池中。
需要注意的是,池中的对象并不保证一直可用,可以被随时清理,因此不能依赖于对象的持久性。此外,sync.Pool
并不适用于所有场景,主要用于管理具有短生命周期的对象。
几个问题思考
-
问题一: 为什么用
Pool
,而不是在运行的时候直接实例化对象呢?示例:
import ( "fmt" "sync" "sync/atomic" ) //用来存放createBuffer创建次数 var numCalcsCreated int32 //创建一个大小为1024的buffer对象 func createBuffer() interface{} { atomic.AddInt32(&numCalcsCreated, 1) //计数+1 buffer := make([]byte, 1024) return buffer } func main() { //创建 buffPool 对象 buffPool := &sync.Pool{ New: createBuffer, } //goroutine 数量 numWorks := 1024 * 1024 var wg sync.WaitGroup wg.Add(numWorks) //多goroutine并发测试 for i := 0; i < numWorks; i++ { go func() { defer wg.Done() //申请一个buffer实例 buffer := buffPool.Get() _ = buffer.([]byte) //返还一个buffer实例 defer buffPool.Put(buffer) }() } wg.Wait() fmt.Printf("%d buffer objects were created.\n", numCalcsCreated) }
输出结果:
root# go run main.go 4 buffer objects were created. root#go run main.go 3 buffer objects were created.
程序
go run
运行了两次,一次结果是3
,一次是4
。这个是什么原因呢?首先,这个是正常的情况,不知道你有没有注意到,创建
Pool
实例的时候,只要求填充了New
函数,而根本没有声明或者限制这个Pool
的大小。所以,记住一点,程序员作为使用方不能对 Pool 里面的元素个数做假定。我们再来更改以下代码:
buffer := buffPool.Get() => buffer := createBuffer()
这个时候,我们再执行程序 , 结果:
root# go run .\main.go 1048576 buffer objects were created. root#go run .\main.go 1048576 buffer objects were created.
注意到,和之前有两个不同点:
- 同样也是运行两次,两次结果相同。
- 对象创建的数量和并发
Worker
数量相同,数量等于1048576
(这个就是1024*1024
);
原因很简单,因为每次都是直接调用
createBuffer
函数申请buffer
,有1048576
个并发Worker
调用,所以跑多少次结果都会是1048576
。实际上还有一个不同点,就是程序跑的过程中,该进程分配消耗的内存很大。因为
Go
申请内存是程序员触发的,回收却是Go
内部runtime GC
回收器来执行的,这是一个异步的操作。这种业务不负责任的内存使用会对GC
带来非常大的负担,进而影响整体程序的性能。类比现实的例子,一个程序猿喝奶茶,需要一个吸管(吸管类比就是我们代码里的
buffer
对象喽),奶茶喝完吸管就扔了,那就是塑料垃圾了(Garbage
)。清洁工老李(GC
回收器 )需要紧跟在后面打扫卫生,现在1048576
个程序猿同时喝奶茶,每个人都现场要一根新吸管,喝完就扔,马上地上有1048576
个塑料吸管垃圾。清洁工老李估计要累个半死。那如果,现在在某个隐秘的角落放一个回收箱 ( 类比成
sync.Pool
) ,程序员喝完奶茶之后,吸管就丢到回收箱里,下一个程序员要用吸管的话,伸手进箱子摸一下,看下有管子吗?有的话,就拿来用了。没有的话,就再找人要一根新吸管。这样新吸管的使用数量就大大减少了呀,地上也没垃圾了,老李也轻松了,多好呀。并且,极限情况下,如果大家喝奶茶足够快,保证箱子里每时每刻都至少有一根用过的吸管,那
1048576
个程序员估计用一根吸管都够了。。。。(有点想吐。。。)回归正题,这就也解释了,为什么使用
sync.Pool
之后数量只有3
,4
个。但是进一步思考:为什么sync.Pool
的两次使用结果输出不不一样呢?因为复用的速度不一样。我们不能对 Pool 池里的 cache 的元素个数做任何假设。不过还是那句话,如果速度足够快,其实里面可以只有一个元素就可以服务
1048576
个并发的Goroutine
。 -
问题二:
sync.Pool
是并发安全的吗?sync.Pool
当然是并发安全的。官方文档里明确说了:A Pool is safe for use by multiple goroutines simultaneously.
sync.Pool
只是本身的Pool
数据结构是并发安全的,并不是说Pool.New
函数一定是线程安全的。Pool.New
函数可能会被并发调用 ,如果New
函数里面的实现是非并发安全的,那就会有问题。上面的代码例子里,关于
createBuffer
函数的实现里,对于numCalcsCreated
的计数加是用原子操作的:atomic.AddInt32(&numCalcsCreated, 1)
。因为numCalcsCreated
是个全局变量,Pool.New
( 也就是createBuffer
) 并发调用的时候,会导致data race
,所以只有用原子操作才能保证数据的正确性。我们可以尝试下,把
atomic.AddInt32(&numCalcsCreated, 1)
这样代码改成numCalcsCreated++
,然后用go run -race main.go
命令检查一下,肯定会报告告警的,类似如下:root # go run -race main.go ================== WARNING: DATA RACE Read at 0x000000d61e20 by goroutine 8: main.createBuffer() ............................
本质原因:
Pool.New
函数可能会被并发调用。 -
问题三: 为什么
sync.Pool
不适合用于像socket
长连接或数据库连接池?因为,我们不能对
sync.Pool
中保存的元素做任何假设,以下事情是都可以发生的:Pool
池里的元素随时可能释放掉,释放策略完全由runtime
内部管理;Get
获取到的元素对象可能是刚创建的,也可能是之前创建好cache
住的。使用者无法区分;Pool
池里面的元素个数你无法知道;
所以,只有的你的场景满足以上的假定,才能正确的使用
Pool
。sync.Pool
本质用途是增加临时对象的重用率,减少GC
负担。划重点:临时对象。所以说,像socket
这种带状态的,长期有效的资源是不适合Pool
的。
结构体
sync.Pool
是一个临时对象池。一句话来概括,sync.Pool
管理了一组临时对象,当需要时从池中获取,使用完毕后从再放回池中,以供他人使用。
sync.Pool
结构体定义如下:
//go 1.20.3 path:src/sync/pool.go
type Pool struct {
noCopy noCopy
local unsafe.Pointer
localSize uintptr
victim unsafe.Pointer
victimSize uintptr
New func() any //当缓存池无对应对象时调用
}
-
noCopy 用于检测
Pool
池是否被copy
,Pool
不希望被copy
,这个阻止不了编译,只能通过go vet
检查出来; -
local 一个指向
poolLocal
数组的指针; -
localSize
local
指针指向的poolLocal
数组长度,其数值等于等于P
的个数,即GOMAXPROCES
设置数量poolLocal
数组的长度; -
victim
victim
会在一轮GC
到来的时候做两件事: 1. 一个是释放自己占用的内存 , 2. 另外一个是接管local
;victim
的目的是为了减少GC
后冷启动导致的性能抖动,让分配对象更平滑;victim
机制是把Pool
池的清理由一轮GC
改成两轮GC
,进而提高对象的复用率,减少抖动; -
victimSize
GC
时候会接管localSize
,与victim
配合使用; -
**New ** 使用方只能赋值
New
字段,定义对象初始化构造行为。
再来看看 sync.Pool.local
所指向的poolLocal
结构:
//go 1.20.3 path:src/sync/pool.go
type poolLocal struct {
poolLocalInternal //内嵌poolLocalInternal
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}
-
pad
pad
数组就是用来防止false sharing
的, 将poolLocal
补齐至两个缓存行的倍数, 一般CPU
读取缓存的单位为:cache line
,一个cache line
大小为64 byte
, 所以补足2
个缓存行需要128 byte
,也是为了提升频繁存取的性能。关于
false sharing
相关内容可以参考:http://ifeve.com/falsesharing/,此处不做详细说明。 -
poolLocalInternal 是
poolLocal
的内部表示。主要是为了避免在池中的所有对象上分配一个指针。其结构体代码为:
//go 1.20.3 path:src/sync/pool.go type poolLocalInternal struct { private any shared poolChain }
-
private 一个是私有对象,只能被局部调度器
P
使用; -
shared 共享列表对象, 可以被任何
P
访问 , 双链表结构,用于挂接cache
元素。shared
字段定义为poolChain
类型,该结构体定义如下://go 1.20.3 path:src/sync/poolqueue.go type poolChain struct { head *poolChainElt tail *poolChainElt } type poolChainElt struct { poolDequeue next, prev *poolChainElt } type poolDequeue struct { headTail uint64 vals []eface } type eface struct { typ, val unsafe.Pointer }
-
poolChain 一个双向链表,
tail
指向最后一个poolChainElt
元素,head
指向第一个poolChainElt
元素; -
poolChainElt 包含一个
poolDequeue
环形队列,用于存储对象,而next
,prev
用于链接poolChainElt
元素。 -
poolDequeue 算是个逻辑上的环形数组,管理成
ring buffer
的模式。-
vals 存储着实际的值
-
headTail
headTail
字段将首尾索引融合在一起,高32
位用来记录环head
的位置,低32
位用来记录环tail
的位置, 而且head
代表的是当前要写入的位置,所以实际的存储区间是[tail, head)
,当head
和tail
指向同一位置则表示环形数组为空。headTail
示意图如下:
再用一张图来看看环形队列,即
ring buffer
:poolDequeue
使用ring buffer
+headTail
结合的方法有什么好处呢?其实都是为了实现
lock free
,解决对于一个poolDequeue
来说,可能会被多个P
同时访问就会出现并发问题。例如:当
ring buffer
空间仅剩一个的时候,即head - tail = 1
。 如果多个P
同时访问ring buffer
,在没有任何并发措施的情况下,两个P
都可能会拿到对象, 这样就容易引起问题。为了不引入
Mutex
锁,sync.Poo
l是使用atomic
包中的CAS
操作完成的。atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2)
在更新
head
和tail
的时候,也是通过原子变量+
位运算进行操作的。例如,当实现head++
的时候,需要通过以下代码实现:const dequeueBits = 32 atomic.AddUint64(&d.headTail, 1<<dequeueBits)
-
-
-
了解完sync.Pool
所涉及到的几个结构体,我们用一张关系图来理清他们之间相互相关,如下图:
![img](https://i-blog.csdnimg.cn/blog_migrate/12322849044866943db7a7668c63439a.png)
源码分析
Pool.Put
Pool.Put
的过程就是将临时对象放进 Pool
里面。
源码如下:
//go 1.20.3 path:src/sync/pool.go
func (p *Pool) Put(x any) {
// 如果x为nil,直接返回
if x == nil {
return
}
......
// 获取当前P的本地pool,并且锁定当前P
l, _ := p.pin()
/**
如果当前P的本地pool的private字段为nil,将x赋值给private字段
否则将x插入到当前P的本地pool的shared字段的头部
*/
if l.private == nil {
l.private = x
} else {
l.shared.pushHead(x)
}
// 解锁当前P
runtime_procUnpin()
......
}
Pool.Put
的策略相对简单:
- 如果放入对象为空 直接返回;
- 调用
p.pin
将当前goroutine
与P
绑定,获取poolLocal
,即获取当前goroutine
所运行的P
持有的localPool
; - 优先将缓存对象放入
private
(私有空间)中; - 如果
private
已经有值,则插入shared
(共享空间)头部;
接下来看一下goroutine
是怎么跟P
绑定的,实现该功能是调用函数 Pool.pin
:
//go 1.20.3 path:src/sync/pool.go
// pin函数会将当前 goroutine绑定的P, 禁止抢占(preemption) 并从 poolLocal 池中返回 P 对应的 poolLocal
func (p *Pool) pin() (*poolLocal, int) {
//锁定当前P,绑定P和G, 禁止抢占
pid := runtime_procPin()
// 获取p.localSize的值
s := runtime_LoadAcquintptr(&p.localSize)
// 获取p.local的值
l := p.local
// 如果pid小于p.localSize的值,则返回p.local[pid]和pid
if uintptr(pid) < s {
return indexLocal(l, pid), pid
}
// 如果pid越界时,会涉及全局加锁,重新分配poolLocal,添加到全局列表
return p.pinSlow()
}
Pool.pin
的大致逻辑是:
-
调用
runtime_procPin
方法: 会先获取当前G
,然后绑定到对应的M
上,然后返回M
目前绑定的P
的id
;runtime_procPin
主要实现如下代码://go 1.20.3 path:src/runtime/proc.go func sync_runtime_procPin() int { return procPin() } func procPin() int { _g_ := getg() mp := _g_.m mp.locks++ return int(mp.p.ptr().id) }
procPin
函数的目的是为了当前G
被抢占了执行权限(也就是说,当前G
就在当前M
上不走了),这里的核心实现是对mp.locks++
操作,在newstack
里会对此条件做判断,如下:if preempt { // 已经打了抢占标识了,但是还需要判断条件满足才能让出执行权; if thisg.m.locks != 0 || thisg.m.mallocing != 0 || thisg.m.preemptoff != "" || thisg.m.p.ptr().status != _Prunning { gp.stackguard0 = gp.stack.lo + _StackGuard gogo(&gp.sched) // never return } }
-
调用
runtime_LoadAcquintptr
,原子操作取出localSize
; -
如果
pid
小于localSize
,调用indexLocal
函数取出pid
对应的poolLocal
返回;在这里需要先解释下
poolLocal
的索引与P
(这个P
是指Processor
,是golang
中的调度器,后续会详细单独来讲)的关系:
在sync.Pool
中,Pool.localSize
的大小等于P
的数量,如果当前Processor.ID
(后面简称pid
)大于localSize
,那么就表示Pool
还没创建对应的poolLocal
,否则每个P
都有自己的localPool
,pid
将会是poolLocal
的索引,以pid
作为数组偏移,就可以从local
(localPool
数组)中定位到localPool
了。接下来看下
indexLocal
函数://根据偏移获取poolLocal func indexLocal(l unsafe.Pointer, i int) *poolLocal { lp := unsafe.Pointer(uintptr(l) + uintptr(i)*unsafe.Sizeof(poolLocal{})) return (*poolLocal)(lp) }
该函数主要功能就是取出对应索引的
poolLocal
数据。 -
如果
pid
大于或者等于localSize
,就表示Pool
还没创建对应的poolLocal
,调用pinSlow
进行创建。为什么要判断是否
pid
越界呢?这里简单解释一下。大部分时间里P
的数量是不会被动态调整的,而runtime.GOMAXPROCS(n int)
能够在运行时动态调整P
的数量。如果P
数量增加,多出来的部分pid
并没有分配localPool
,所以访问就会越界了。下面看看
pinSlow
源码://go 1.20.3 path:src/sync/pool.go var ( // 全局锁 allPoolsMu Mutex // 存储所有的pool池 allPools []*Pool // 需要被GC回收的pool池 oldPools []*Pool ) func (p *Pool) pinSlow() (*poolLocal, int) { //解锁当前P runtime_procUnpin() // 加锁全局变量allPools allPoolsMu.Lock() defer allPoolsMu.Unlock() //锁定当前P.绑定P和G,禁止抢占 pid := runtime_procPin() // 获取p.localSize的值 s := p.localSize // 获取p.local的值 l := p.local //如果pid小于p.localSize的值,则返回p.local[pid]和pid if uintptr(pid) < s { return indexLocal(l, pid), pid } // 如果p.local为nil,则将p添加到全局列表 if p.local == nil { allPools = append(allPools, p) } // 获取当前P的数量 size := runtime.GOMAXPROCS(0) // 重新分配poolLocal,创建一个大小为size的poolLocal数组 local := make([]poolLocal, size) // 将local数组的地址赋值给p.local atomic.StorePointer(&p.local, unsafe.Pointer(&local[0])) // store-release // 将size赋值给p.localSize runtime_StoreReluintptr(&p.localSize, uintptr(size)) // store-release // 返回local[pid]和pid return &local[pid], pid }
pinSlow()
的主要运行步骤为:-
释放
P
禁用抢占,然后尝试获取全局排他锁allPoolsMu Mutex
; -
拿到
allPoolsMu Mutex
之后又开启了禁止抢占P
获取pid
, 再获取Pool.local
地址以及Pool.localSize
大小; -
如果
pid
小于p.localSize
的值,则直接返回p.local[pid]
和pid
, -
否则根据
runtime.GOMAXPROCS(0)
获取CPU
数量,创建跟CPU
数据一致的poolLocal
类型数组进行重新分配poolLocal
;
该函数除了相关流程外,还有一个全部变量需要注意:
allPools
,在清理Pool
环节会重点使用到,在pinSlow
函数中,有句代码为allPools
起了赋值作用:if p.local == nil { allPools = append(allPools, p) }
判断
p.local
是否为空,如果为空,说明这将是一个新的pool
,我们需要把它加入allPools
里头。至于为什么要加进去呢,当然是为了GC
,GC
时可以通过allPools
获取所有pool
实例,进行垃圾清理。 -
最后一张图来理清Pool.Put
的整体流程:
Pool.Get
Pool.Get
作用就是在从池中获取对象,其源码如下:
//go 1.20.3 path:src/sync/pool.go
func (p *Pool) Get() any {
......
// 获取当前P的本地缓存poolLocal
l, pid := p.pin()
//获取poolLocal 私有缓存
x := l.private
//清空私有缓存
l.private = nil
if x == nil {
//如果私有缓存为空,则从共享缓存中获取
x, _ = l.shared.popHead() // 从本地共享缓存从头部获取
//如果共享缓存为空,则从其他P的共享缓存中获取
if x == nil {
x = p.getSlow(pid)
}
}
//解锁p
runtime_procUnpin()
......
//如果获取的x为空,且p.New不为空,则调用p.New创建一个新的poolLocal对象
if x == nil && p.New != nil {
x = p.New()
}
return x
}
Pool.Get
基本步骤梳理下如下:
- 获取当前
P
的本地缓存poolLocal
; - 优先从
poolLocal
的私有对象private
中选择对象;若private
中取不到数据,则尝试从poolLocal
的shared
队列的头部弹出一个元素; - 如果
poolLocal
的shared
中也没有元素了,则从其他P
对应的shared
中偷取一个,我们称为steal
;如果其他P
的shared
中也没有元素,再检查victim
中是否有元素获取;这步流程是在函数p.getSlow
函数中完成的,具体查看对该函数的解析; - 如果此时还是没有取到数据,则如果
Pool
对象设置了New
方法,将调用New
方法产生一个;如果没有设置New
方法,将返回nil
。
来看看 getSlow
源码来分析,分析下如何从其他P
中steal
元素以及如何从victim
中查找元素,其代码如下:
//go 1.20.3 path:src/sync/pool.go
func (p *Pool) getSlow(pid int) any {
// 获取本地缓存poolLocal的大小
size := runtime_LoadAcquintptr(&p.localSize)
// 获取本地缓存poolLocal
locals := p.local
/**
循环遍历本地缓存poolLocal,从其他P的共享缓存中获取
尝试P的顺序是从当前pid+1个索引位置开始对应的,在shared中的查询方式是从它的尾部弹出一个元素
如果shared队列中没有,继续尝试下一个位置,直到循环检查一圈都没有
*/
for i := 0; i < int(size); i++ {
// 获取其他P的poolLocal
l := indexLocal(locals, (pid+i+1)%int(size))
// 从l的共享缓存中队列尾部获取一个对象,如果获取对象不为空,则返回该对象
if x, _ := l.shared.popTail(); x != nil {
return x
}
}
/**
如果从其他P的共享缓存中获取失败,则从victim缓存中获取看看
查找的位置是从pid索引位置开始的poolLocal产生从它的shared尾部弹出一个元素
如果有就返回,如果没有就尝试下一个位置的poolLocal
*/
// 获取victim缓存的大小
size = atomic.LoadUintptr(&p.victimSize)
// 如果当前P的pid大于等于victim缓存的大小,则返回nil
if uintptr(pid) >= size {
return nil
}
// 获取victim缓存
locals = p.victim
// 获取当前P的poolLocal
l := indexLocal(locals, pid)
// 检查victim.private是否有元素,有就返回
if x := l.private; x != nil {
l.private = nil
return x
}
// 循环遍历victim对应的poolLocal,从其他P的local缓存shared中尾部获取一个对象,如果获取对象不为空,则返回该对象
for i := 0; i < int(size); i++ {
l := indexLocal(locals, (pid+i)%int(size))
if x, _ := l.shared.popTail(); x != nil {
return x
}
}
//如果从victim缓存中获取失败,则设置victim缓存的大小为0; 这里将p.victimSize设置为0,是为了减少后序调用getSlow,不用再一次遍历
atomic.StoreUintptr(&p.victimSize, 0)
//尝试了所有的local.shared和victim.private, victim.shared都没有找到,返回nil
return nil
}
Pool.getSlow
函数的代码逻辑如下:
- 从当前
pid+1
个索引位置开始,遍历其他P
的poolLocal
的shared
共享缓存,从它的尾部弹出一个元素,如果获取到了元素则返回该元素; - 如果没有获取到相关元素,则获取
victim
缓存,尝试从victim
中取数据:- 首先从
victim
的private
中查找元素,如果有则返回该元素; - 如果没找到元素,则从当前
pid
开始,遍历其他P
的shared
尾部弹出一个元素,如果有元素则返回,如果没有就尝试下一个位置的poolLocal
,直接循环结束;
- 首先从
- 如果还是获取不到相关元素,则将
victim
缓存的大小设置为0
,以便减少后序调用getSlow
,最后返回nil
。
最后一张图来概括Pool.Get
的整体流程:
poolCleanUp
poolCleanup()
函数是负责清理Pool
的,会在STW
阶段被调用,在 sync package init
的时候注册,由 runtime
后台执行,该函数调用链如下:
gcTrigger->gcStart()->clearpools()->poolCleanup()
poolCleanup()
函数注册相关源码:
//在程序启动时,会注册一个清理函数poolCleanup(),每次GC开始前,都会被调用
func init() {
runtime_registerPoolCleanup(poolCleanup)
}
func sync_runtime_registerPoolCleanup(f func()) {
poolcleanup = f
}
具体来看下 poolCleanup
源码:
//go 1.20.3 path:src/sync/pool.go
func poolCleanup() {
/**
遍历oldPools,将其中的victim置为nil,victimSize置为0
*/
for _, p := range oldPools {
p.victim = nil
p.victimSize = 0
}
/**
遍历allPools,将其中的local置为victim,localSize置为victimSize,将local置为nil,localSize置为0
*/
for _, p := range allPools {
p.victim = p.local
p.victimSize = p.localSize
p.local = nil
p.localSize = 0
}
// 将allPools置为nil,将oldPools置为allPools
oldPools, allPools = allPools, nil
}
代码逻辑很简单:
- 清空
oldPools
中victim
的对象; - 将
allPools
对象池中,local
对象迁移到victim
上; - 将
allPools
迁移到oldPools
,并清空allPools
。
在runtime
中实现,每次gc
的时候都会进行清理和复制,其实也就是清理local
的信息,间接证明sync.Pool
减小GC
压力是避免频繁的申请内存和释放内存。
从代码中也可以看出,需要经过两轮GC
才能完全清理Pool
,第一轮GC
让victim cache
接管local cache
;第二轮GC
就是全部清除victim cache
,为什么这么设计?
因为假如第一轮就全部清除,那么之后再获取对象的时候性能就会下降,因为很有可能在大量创建对象了,所以它GC
第一轮先放到victim cache
中,等到第二轮在处理。
victim
的设计也间接提升了GC
的性能,因为相对来说Sync.Pool
池化的对象是long-live
的,而GC
的主要对象是short-live
的,所以会减少GC
的执行。
poolDequeue
poolDequeue
数据结构:
// 环形队列
type poolDequeue struct {
// 64位的整型,高32位用来记录环head的位置,低32位用来记录环tail的位置,而且head代表的是当前要写入的位置,所以实际的存储区间是[tail, head)
headTail uint64
// 环,这里用eface这个结构体也是有妙用的,后面具体会说
vals []eface
}
type eface struct {
typ, val unsafe.Pointer
}
poolDequeue
被实现为单生产者多消费者的固定大小的无锁Ring
式队列。生产者可以从head
插入删除,而消费者仅可从tail
删除。headTail
指向了队列的头和尾,通过位运算将head
和tail
位置存入poolDequeue.headTail
变量中。
const dequeueBits = 32
func (d *poolDequeue) unpack(ptrs uint64) (head, tail uint32) {
const mask = 1<<dequeueBits - 1
head = uint32((ptrs >> dequeueBits) & mask)
tail = uint32(ptrs & mask)
return
}
func (d *poolDequeue) pack(head, tail uint32) uint64 {
const mask = 1<<dequeueBits - 1
return (uint64(head) << dequeueBits) |
uint64(tail&mask)
}
headTail
存储的是64
位的整型,高32
位用来记录环head
的位置,低32
位用来记录环tail
的位置,通过 pack
函数来结合head
和 tail
位置得出 headTail
,而通过对 headTail
解析( 使用unpack
函数 )得出 head
和 tail
值。
poolDequeue
提供了三个方法,源码如下:
//入队
func (d *poolDequeue) pushHead(val interface{}) bool {
//加载headTail值
ptrs := atomic.LoadUint64(&d.headTail)
//解析出 head 和 tail
head, tail := d.unpack(ptrs)
//判断poolDequeue环形队列是否满,如果满了则返回false
if (tail+uint32(len(d.vals)))&(1<<dequeueBits-1) == head {
// Queue is full.
return false
}
//获取当前head
slot := &d.vals[head&uint32(len(d.vals)-1)]
// 检测当前head slot是否被popTail清理,如果另外一个goroutine正在清理尾部,则该队列实际上已经为满
typ := atomic.LoadPointer(&slot.typ)
if typ != nil {
return false
}
// The head slot is free, so we own it.
if val == nil {
val = dequeueNil(nil)
}
//设置当前head slot值
*(*interface{})(unsafe.Pointer(slot)) = val
//当前head值+1
atomic.AddUint64(&d.headTail, 1<<dequeueBits)
return true
}
//从头部pop出值
func (d *poolDequeue) popHead() (interface{}, bool) {
var slot *eface
for {
//加载headTail值
ptrs := atomic.LoadUint64(&d.headTail)
//解析出 head 和 tail值
head, tail := d.unpack(ptrs)
//如果tail == head 代表该队列为空,直接返回
if tail == head {
return nil, false
}
//head值-1
head--
//利用新的head值和tail组合成新的headTail值
ptrs2 := d.pack(head, tail)
//利用CSA原子操作,更新headTail值
if atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) {
// 成功更新值后,取出当前head slot信息
slot = &d.vals[head&uint32(len(d.vals)-1)]
break
}
}
//将slot设置为空值,并设置当前head slot为空结构体
val := *(*interface{})(unsafe.Pointer(slot))
if val == dequeueNil(nil) {
val = nil
}
*slot = eface{}
//返回信息
return val, true
}
//出队
func (d *poolDequeue) popTail() (interface{}, bool) {
var slot *eface
for {
//加载headTail值
ptrs := atomic.LoadUint64(&d.headTail)
//解析出 head 和 tail值
head, tail := d.unpack(ptrs)
//如果tail == head 代表该队列为空,直接返回
if tail == head {
return nil, false
}
//将tail+1和head值重新组合成headTail值
ptrs2 := d.pack(head, tail+1)
//利用cas更新 headTail值
if atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) {
// 成功更新值后,取出当前tail slot信息
slot = &d.vals[tail&uint32(len(d.vals)-1)]
break
}
}
// 将tail slot值信息设置为空,以及重置slot为空结构
val := *(*interface{})(unsafe.Pointer(slot))
if val == dequeueNil(nil) {
val = nil
}
slot.val = nil
atomic.StorePointer(&slot.typ, nil)
return val, true
}
-
poolDequeue
作为一个ring buffer
,需要记录下其head
和tail
的值。但在poolDequeue
的定义中,head
和tail
并不是独立的两个变量,只有一个uint64
的headTail
变量。这是因为headTail
变量将head
和tail
打包在了一起:其中高32
位是head
变量,低32
位是tail
变量 -
对于一个
poolDequeue
来说,可能会被多个P
同时访问,如果多个P
同时访问ring buffer
,在没有任何并发措施的情况下,两个P
都可能会拿到对象,这肯定是不符合预期的。在不引入Mutex
锁的前提下,sync.Pool
利用了atomic
包中的CAS
操作。两个P
都可能会拿到对象,但在最终设置headTail
的时候,只会有一个P
调用CAS
成功,另外一个CAS
失败。sync/poolqueue.go:popTail
ptrs2 := d.pack(head, tail+1) if atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) { slot = &d.vals[tail&uint32(len(d.vals)-1)] break }
sync/poolqueue.go:popHead
head-- ptrs2 := d.pack(head, tail) if atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) { slot = &d.vals[head&uint32(len(d.vals)-1)] break }
-
使用
ring buffer
是因为它有以下优点:- 预先分配好内存,且分配的内存项可不断复
- 由于
ring buffer
本质上是个数组,是连续内存结构,非常利于CPU Cache
。在访问poolDequeue
某一项时,其附近的数据项都有可能加载到统一Cache Line
中,访问速度更快
poolChain
poolChain
是一个双端队列,里面的head
和tail
分别指向队列头尾;poolDequeue
里面存放真正的数据,是一个单生产者、多消费者的固定大小的无锁的环状队列,headTail
是环状队列的首位位置的指针,可以通过位运算解析出首尾的位置,生产者可以从 head
插入、head
删除,而消费者仅可从 tail
删除。
poolChain
数据结构:
type poolChain struct {
head *poolChainElt
tail *poolChainElt
}
type poolChainElt struct {
poolDequeue
next, prev *poolChainElt
}
这个双端队列的模型大概是这个样子:
![img](https://i-blog.csdnimg.cn/blog_migrate/d8f086da8ae968e43818162a858c07af.png)
poolChain
也实现了pushHead
,popHead
和popTail
方法:
//入队操作
func (c *poolChain) pushHead(val interface{}) {
//获取head指针
d := c.head
/**
如果 head指针为空,则说明队列未初始化,则进行初始化操作,poolDequeue初始长度为 8
*/
if d == nil {
const initSize = 8
d = new(poolChainElt)
d.vals = make([]eface, initSize)
c.head = d
storePoolChainElt(&c.tail, d)
}
//调用poolDequeue.pushHead操作将val压入poolDequeue队列
if d.pushHead(val) {
return
}
//如果d.pushHead失败,则说明poolDequeue队列满了,这时候将长度*2进行扩容
newSize := len(d.vals) * 2
//扩容后的长度不允许超过 dequeueLimit值
if newSize >= dequeueLimit {
newSize = dequeueLimit
}
/**
新建一个poolChainElt结构体,将d 和 d2 链接,并将c.head指向d2
*/
d2 := &poolChainElt{prev: d}
//按着newSize容量创建vals数组
d2.vals = make([]eface, newSize)
c.head = d2
storePoolChainElt(&d.next, d2)
//再次调用poolDequeue.pushHead操作
d2.pushHead(val)
}
//从头部弹出值
func (c *poolChain) popHead() (interface{}, bool) {
//获取head指针
d := c.head
/**
循环遍历poolChain中的各个poolChainElt,直到找到存在值的head信息
*/
for d != nil {
if val, ok := d.popHead(); ok {
return val, ok
}
d = loadPoolChainElt(&d.prev)
}
return nil, false
}
func (c *poolChain) popTail() (interface{}, bool) {
d := loadPoolChainElt(&c.tail)
// 队尾为空,返回
if d == nil {
return nil, false
}
for {
// 先取出下一个节点,因为当前d节点获取的时候为空,执行的时候,另外一个p又插入的数据
d2 := loadPoolChainElt(&d.next)
// 获取vals切片数据
if val, ok := d.popTail(); ok {
return val, ok
}
// 校验d2是否为空
if d2 == nil {
return nil, false
}
// 原子性cas对比d的值中途是否有改变
if atomic.CompareAndSwapPointer((*unsafe.Pointer)(unsafe.Pointer(&c.tail)), unsafe.Pointer(d), unsafe.Pointer(d2)) {
// popTail为空,并且d2非空。 则清空上一个数据
storePoolChainElt(&d2.prev, nil)
}
d = d2
}
}
popHead
以队首向队尾为方向遍历链表,对每个poolDequeue
执行popHead
尝试取出存放的对象。popTail
以队尾向队队首为方向遍历链表,对每个poolDequeue
执行popTail
尝试从尾部取出存放的对象。poolDequeue
是在poolChain
的pushHead
中创建的,且每次创建的长度都是前一个poolDequeue
长度的2倍,初始长度为8
。
总结
Pool
本质是为了提高临时对象的复用率;Pool
使用两层回收策略(local
+victim
)避免性能波动;Pool
本质是一个杂货铺属性,啥都可以放。把什么东西放进去,预期从里面拿出什么类型的东西都需要业务使用方把控,Pool
池本身不做限制;Pool
池里面cache
对象也是分层的,一层层的cache
,取用方式从最热的数据到最冷的数据递进;Pool
是并发安全的,但是内部是无锁结构,原理是对每个P
都分配cache
数组(poolLocalInternal
数组),这样cache
结构就不会导致并发;- 永远不要
copy
一个Pool
,明确禁止,不然会导致内存泄露和程序并发逻辑错误; - 代码编译之前用
go vet
做静态检查,能减少非常多的问题; - 每轮
GC
开始都会清理一把Pool
里面cache
的对象,注意流程是分两步,当前Pool
池local
数组里的元素交给victim
数组句柄,victim
里面cache
的元素全部清理。换句话说,引入victim
机制之后,对象的缓存时间变成两个GC
周期; - 不要对
Pool
里面的对象做任何假定,有两种方案:要么就归还的时候memset
对象之后,再调用Pool.Put
,要么就Pool.Get
取出来的时候memset
之后再使用;
参考资料: