请问sync.Pool有什么缺点?
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可能过来偷对象。
|
|
问题3 就是由于shared区是一个带锁的后进先出队列造成的。每次Pool.Get方法在调用时,执行顺序是:
- 先看当前P的private区是否为空。
- 加锁,看当前P的shared区是否为空。
- 加锁,循环遍历看其他P的shared区是否为空。
- 只要上面三步任意一步就不为空,就可以把缓存对象返回了。但若都为空,最后就得调用New方法返回对象。
|
|
这一顿的加锁操作和Mutex锁自带的阻塞唤醒开销,Get方法在极端情况下就会有性能问题。
Mutex锁分析参考《一份详细注释的go Mutex源码》
问题2和3 都是由于每次GC时,遍历清空所有缓存对象造成的。
sync.Pool在init()中向runtime注册了一个cleanup方法,它在STW1阶段被调用的。如果它执行过久,就会硬生生延长STW1阶段耗时。
|
|
这个cleanup方法干的事情是遍历所有的sync.Pool对象,再遍历每个sync.Pool对象中的每个P的shared区,把shared区每个缓存对象设置为nil。代码中就是三层for循环,简单粗暴时间复杂度高。
|
|
好消息是1.13beta1已经解决了这三个问题。注意是beta版本,而不是stable版本。
接下来主要看1.13通过什么思路解决这些问题的。
不排除未来的stable版本或1.13的小版本会对这块实现做小改动。
取消每次GC默认对全部对象进行回收
解决问题2和3的思路就是不能每次全部回收。但该回收多少呢?
@aclements 提出了一种思路,这轮在sync.Pool中的对象。最快也在下轮GC才被回收。
https://github.com/golang/go/issues/22950#issuecomment-352935997
还记得上面说过每个P都有private区和shared区吗?现在每个P里两个区合在一起构成数组,给个名字叫local(其实也是源码中的实现)。1.13版本的实现中再引入一个victim,它结构与local一致。
|
|
有了victim,Get和Put方法的步骤就有所变化:
- Get时,先从local里尝试取出缓存对象(包括所有的P)。如果失败,就尝试从victim里取。
- victim里也取对象失败,就调用New方法。
- Put时,只放local里。
新的数据结构下,cleanup方法策略也有所变化,改为每次只把victim里的对象回收掉。然后victim再指向当前的local。
|
|
显然这样好处就是这轮的缓存对象在GC时不会立马回收,而是存放起来,滞后一轮。这样下一轮能得到复用机会,提高了缓存对象的命中率。并且回收对象时,由对shared区O(n)的遍历操作,变成O(1)。
从benchmark感受这个优化带来的性能提升:
|
|
1.9.7版本的STW1阶段耗时TP96线是285485ns,而1.13beta1是7720ns。
Benchmark代码参考1.13beat1源码src/sync/pool_test.go.BenchmarkPoolSTW方法
使用无锁队列替换shared区
问题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版本吧。