1.12及之前版本的sync.Pool有三个问题:
每次GC都回收所有对象,如果缓存对象数量太大,会导致STW1阶段的耗时增加。
每次GC都回收所有对象,导致缓存对象命中率下降,New方法的执行造成额外的内存分配消耗。
Pool.Get方法底层有锁,极端情况下,要尝试最多P次抢锁,也获取不到缓存对象,最后得执行New方法返回对象。
这些问题就对sync.Pool的室使用提出了要求,不满足时,性能并不会有大幅提升:
最好是高并发场景。(对应问题3)
最好两次GC之间的间隔足够长。(对应问题1,2)
先简单介绍下原理,看哪块源码造成这三个问题。
如果对sync.Pool的基本原理一点都不了解,可以移步先阅读《 golang标准库sync.Pool原理及源码简析》或点击阅读原文
sync.Pool对象内部为每个P都分配了一个private区和shared区。
private区只能存放一个可复用对象,因为每个P在任意时刻只运行一个G,所以在private区上写入和取出对象是不用加锁的。
shared区可以放多个可复用对象,它本身是slice。进shared区就append,出shared区就slice[:last-1]。但shared区上写入和取出对象要加锁,因为别的G可能过来偷对象。
type poolLocalInternal struct {
// 私有对象,每个P都有,用于不同G执行get和put可以无锁操作
private interface{}
// 共享对象数组,每个P都有一个,同一个P上不同G可以多次执行put方法,需要有地方能存储。并且别的P上的G可能过来偷,所以要加锁
shared []interface{}
// 对shared进行加锁,private不用加锁
Mutex
}
问题3 就是由于shared区是一个带锁的后进先出队列造成的。每次Pool.Get方法在调用时,执行顺序是:
先看当前P的private区是否为空。
加锁,看当前P的shared区是否为空。
加锁,循环遍历看其他P的shared区是否为空。
只要上面三步任意一步就不为空,就可以把缓存对象返回了。但若都为空,最后就得调用New方法返回对象。
// 遍历一次其他P的共享区,偷一个,每次尝试偷都得上锁
for i := 0; i < int(size); i++ {
// 定位到某个P上的shared区
l := indexLocal(local, (pid+i+1)%int(size))
l.Lock()
last := len(l.shared) - 1
if last >= 0 {
// 如果有缓存对象,就返回,并解锁
x = l.shared[last]
l.shared = l.shared[:last]
l.Unlock()
break
}
// 没有缓存对象,解锁,继续遍历下一个P
l.Unlock()
}
这一顿的加锁操作和Mutex锁自带的阻塞唤醒开销,Get方法在极端情况下就会有性能问题。
Mutex锁分析参考《一份详细注释的go Mutex源码》
问题2和3 都是由于每次GC时,遍历清空所有缓存对象造成的。
sync.Pool在init()中向runtime注册了一个cleanup方法,它在STW1阶段被调用的。如果它执行过久,就会硬生生延长STW1阶段耗时。
func init() {
runtime_registerPoolCleanup(poolCleanup)
}
这个cleanup方法干的事情是遍历所有的sync.Pool对象,再遍历每个sync.Pool对象中的每个P的shared区,把shared区每个缓存对象设置为nil。代码中就是三层for循环,简单粗暴时间复杂度高。
func poolCleanup() {
// ...
for i, p := range allPools {
// 有多少个Sync.Pool对象,遍历多少次
allPools[i] = nil
for i := 0; i < int(p.localSize); i++ {
// 有多少个P,遍历多少次
l := indexLocal(p.local, i)
l.private = nil
for j := range l.shared {
// 清空shared区中每个缓存对象
l.shared[j] = nil
}
l.shared = nil
}
// ...
}
// ...
}
好消息是1.13beta1已经解决了这三个问题。注意是beta版本,而不是stable版本。
接下来主要看1.13通过什么思路解决这些问题的。
不排除未来的stable版本或1.13的小版本会对这块实现做小改动。
解决问题2和3的思路就是不能每次全部回收。但该回收多少呢?
@aclements 提出了一种思路。这轮在sync.Pool中的对象,最快也在下轮GC才被回收。
https://github.com/golang/go/issues/22950#issuecomment-352935997
还记得上面说过每个P都有private区和shared区吗?现在每个P里两个区合在一起构成数组,给个名字叫local(其实也是源码中的实现)。1.13版本的实现中再引入一个victim,它结构与local一致。
// 1.13版本源码
type Pool struct {
local unsafe.Pointer // 实际指向[P]poolLocal
localSize uintptr // P的个数
victim unsafe.Pointer // 指向上轮的local
victimSize uintptr // 指向上轮的localSize
New func() interface{}
}
type poolLocal struct {
poolLocalInternal
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}
type poolLocalInternal struct {
private interface{}
shared poolChain
}
有了victim,Get和Put方法的步骤就有所变化:
Get时,先从local里尝试取出缓存对象(包括所有的P)。如果失败,就尝试从victim里取。
victim里也取对象失败,就调用New方法。
Put时,只放local里。
新的数据结构下,cleanup方法策略也有所变化,改为每次只把victim里的对象回收掉。然后victim再指向当前的local。
var (
// 所有sync.Pool对象
allPools []*Pool
// 待回收的所有sync.Pool对象
oldPools []*Pool
)
func poolCleanup() {
for _, p := range oldPools {
// 每次只回收victim
p.victim = nil
p.victimSize = 0
}
for _, p := range allPools {
// victim指向当前的local
p.victim = p.local
p.victimSize = p.localSize
p.local = nil
p.localSize = 0
}
oldPools, allPools = allPools, nil
}
显然这样好处就是这轮的缓存对象在GC时不会立马回收,而是存放起来,滞后一轮。这样下一轮能得到复用机会,提高了缓存对象的命中率。并且回收对象时,由对shared区O(n)的遍历操作,变成O(1)。
从benchmark感受这个优化带来的性能提升:
# 1.9.7
BenchmarkPoolSTW-8 p96-ns/STW 285485 p50-ns/STW 190467
# 1.13beta1
BenchmarkPoolSTW-8 p96-ns/STW 7720 p50-ns/STW 4979
1.9.7版本的STW1阶段耗时TP96线是285485ns,而1.13beta1是7720ns。
Benchmark代码参考1.13beat1源码src/sync/pool_test.go.BenchmarkPoolSTW方法
问题3 是因为在shared的访问加了一把Mutex锁造成的。如果不消除这把锁,引入victim区也是徒劳。因为此时victim的访问也得加锁。
旧实现中shared区是单纯的带锁后进先出队列,1.13beta版本改成了单生产者,多消费者的双端无锁环形队列。
单生产者是指,每个P上运行的G,执行Put方法时,就往队列里存放缓存对象(别的P上运行的G不能往里放),并且只能放在队列头部。由于每个P任意时刻只有一个G被运行,所以存放缓存对象不需要加锁。
多消费者分两种角色,一是在P上运行的G,执行Get方法时,从队列头部取出缓存对象。同上,取对象不用加锁;二是在其他P上运行的G,执行Get方法时,本地没有缓存对象,就到别的P上偷。此时盗窃者G只能从队列尾部取出对象,因为盗窃者可能有多个,所以尾部取数据用CAS来实现无锁。
注意,每个P都持有自己无锁队列,下图只画出了P0的。
并且队列也可能有多个,下图只画出单队列的情况。
如何正确地实现无锁队列超出本文意图,不展开介绍。感兴趣可以自行找资料学习或看源码。
每个P持有的循环队列初始化多大呢?增长和收缩策略呢?
下图用一张图做宏观介绍。
陈述要点:
shared区改用双向链表,每个链表节点指向一个无锁环形队列。
链表节点必须在头部插入。
当前P上的G取缓存对象时,只从头部链表节点指向的无锁队列里取。取不到,沿着prev指针到下一个无锁队列上重复操作,也没有的话。就到别的P上偷。
盗窃者G在偷缓存对象时,只从尾部链表节点指向的无锁队列里取。取不到,沿着next指针到下一个无锁队列上重复操作,也没有的话。就到别的P上继续偷,直到都偷不着,就调用New方法。
链表首次插入节点时,指向无锁队列初始化大小为8,增长策略为在头部插入新节点,指向的无锁队列大小为旧头部节点指向无锁队列大小的两倍,始终保持2的n次方大小。
假如在链表长度为3的情况下。尾部节点指向的无锁队列里缓存对象被偷光了。那么尾部节点会沿着next指针前移,把旧的无锁队列内存释放掉。此时链表长度变为2,这就是链表的收缩策略。最小时剩下一个节点,不会收缩成空链表。
无锁队列的自身最大的大小是2**30。达到上限时,再执行Put操作就放不进去,也不报错。
总体就是这样,让我们期待1.13的stable版本吧。