Go 并发编程 — 深度剖析 sync.Pool 源码级原理
[toc]
前情提要
上次我们从使用层面做了梳理分析,得到以下几点小知识:
更多干货,欢迎关注,公众号:奇伢云存储
sync.Pool 本质用途是增加临时对象的重用率,减少 GC 负担;
不能对 Pool.Get 出来的对象做预判,有可能是新的(新分配的),有可能是旧的(之前人用过,然后 Put 进去的);
不能对 Pool 池里的元素个数做假定,你不能够;
sync.Pool 本身的 Get, Put 调用是并发安全的,
sync.New
指向的初始化函数会并发调用,里面安不安全只有自己知道;当用完一个从 Pool 取出的实例时候,一定要记得调用 Put,否则 Pool 无法复用这个实例,通常这个用 defer 完成;
官方开头声明:
A Pool is a set of temporary objects that may be individually saved and retrieved.
原理剖析
下面我们从数据结构和实现逻辑来深入剖析下 sync.Pool
的原理。
数据结构
Pool 结构
sturct Pool
结构是给到用户的使用的结构,定义:
有几个注意点:
noCopy 为了防止 copy 加的打桩代码,但这个阻止不了编译,只能通过
go vet
检查出来;local
和localSize
这两个字段实现了一个数组,数组元素为poolLocal
结构,用来管理临时对象;victim
和victimSize
这个是在poolCleanup
流程里赋值了,赋值的内容就是local
和localSize
。victim 机制是把 Pool 池的清理由一轮 GC 改成 两轮 GC,进而提高对象的复用率,减少抖动;使用方只能赋值 New 字段,定义对象初始化构造行为;
poolLocal 结构
该结构是管理 Pool 池里 cache 元素的关键结构,Pool.local
指向的就是这么一个类型的数组,这个结构值得注意的一点是使用了内存填充,对齐 cache line,防止 false sharing 性能问题的技巧。
Pool 里面该结构数组是按照 P 的个数分配的,每个 P 都对应一个这个结构。
poolChain
我们可以稍微看下 poolChain
结构,这个纯粹是一个连接件,本身空间也就两指针,占用内存 16 Byte。
所以关键还是链表的元素,链表元素的结构是 poolChainElt
,这个结构体长这样:
poolChainElt
是双链表的元素点,里面其实是一段数组空间,类似于 ringbuffer,Pool 管理的 cache 对象就都存储在 poolDequeue 的 vals[] 数组里。
Get
Get 的语义就是从 Pool 池里取一个元素出来,这里的重点是:元素是层层 cache 的,由最快到最慢一层层尝试。最快的是本 P 对应的列表里通过 private
字段直接取出,最慢的就是调用 New 函数现场构造。
尝试路径:
当前 P 对应的
local.private
字段;当前 P 对应的
local
的双向链表;其他 P 对应的
local
列表;victim cache 里的元素;
New
现场构造;
runtime_procPin
runtime_procPin
是 procPin
的一层封装,procPin
实现如下:
procPin
函数的目的是为了当前 G 被抢占了执行权限(也就是说,当前 G 就在当前 M 上不走了),这里的核心实现是对 mp.locks++
操作,在 newstack
里会对此条件做判断,如果
Pool.pinSlow
这个函数必须提一下,这个函数做了非常重要的事情,一般是 Pool 第一次调用 Get 的时候才会走进来(注意,是每个 P 的第一次 Get 调用,但是只有一个 P 上的 G 才能干成事,因为有 allPoolsMu
锁互斥)。
pinSlow
主要做以下几个事情:
首次
Pool
需要把自己注册进allPools
数组;Pool.local
数组按照runtime.GOMAXPROCS(0)
的大小进行分配,如果是默认的,那么这个就是 P 的个数,也就是 CPU 的个数;
runtime_procUnpin
这个是对应 runtime_procPin
配套的函数,声明该 M 可以被抢占,字段 m.locks--
。
Put
Put 方法非常简单,因为是后置处理,该做的都在前面做好了,而清理动作又是在 runtime 的后台流程·,所以这里只是把元素放置到队列里就完成了。
但是也要注意一个小点,就是 Put 也会调用 p.pin()
,所以 Pool.local
也可能会在这里创建。
runtime
全局变量
每一个 Pool 结构都加到了全局队列里,在 src/sync/pool.go
文件里,定义了几个全局变量:
后台流程
init
初始化的时候注册清理函数。
在 Golang GC 开始的时候 gcStart
调用 clearpools()
函数就会调用到 poolCleanup
函数。也就是说,每一轮 GC 都是对所有的 Pool 做一次清理。
poolCleanup
这个是定期执行的,在 sync package init 的时候注册,由 runtime 后台执行,内容就是批量清理 allPools
里的元素。
victim 把回收动作由一次变为了两次,这样更抗造一点。每次清理都是只有上次 cache 的对象才会被真正清理掉,当前的 cache 对象只是移到回收站(victim)。
知识小结:
每轮 GC 开始都会调用
poolCleanup
函数;使用两轮清理过程来抵抗波动,也就是 local cache 和 victim cache 配合;
思考问题
原理上面已经剖析的非常清晰了,现在我们思考一些与众不同的问题:
1. 如果不是 Pool.Get 申请的对象,调用了 Put ,会怎么样?
不会有任何异常(是不是惊呆了),Pool 池里能接纳任意来源,任意类型的对象。就算不是 Pool.Get
出来的对象,也能正常调用 Pool.Put
,而一旦你做了这个事情之后,Pool 池里的就不是单一的对象元素了,而是一个杂货铺了。
原因解析:
首先,
Put(x interface{})
接口没有对 x 类型做判断和断言;其次,
Pool.Put
内部也没有对类型做断言和判断,无法追究元素是否是来自于 Get 的接口;
所以,在上一篇剖析 Pool 使用姿势文章的中,在调用 Pool.Get 出来元素之后,我有一行类型断言就是这个意思:
注意这个很重要,因为 sync.Pool 框架支持存放任何类型,本质上可以是一个杂货铺,所以 Get 出来和 Put 进去的对象类型要业务自己把控。
2. Pool.Get
出来的对象,为什么要 Pool.Put
放回 Pool 池,是为了不变成自己讨厌的垃圾吗?
首先,从使用姿势来说,Pool.Get
和 Pool.Put
一定要配套使用,通常使用 defer Pool.Put
这种形式保证释放元素进池子。
你想过建议 Get,Put
配套使用的原因吗?如果不配套是会变成不可回收的垃圾吗?
首先,这个说法是错误的,虽然 Pool.Get
,Pool.Put
通常是配套使用的,但是也绝对不是硬性要求,Get.Put
出来的元素使用完之后,就算不调用 Pool.Put
放进池子也不会成为垃圾,而是自然再没有人用到这个对象的时候,GC 会释放他。
举个极限的例子,如果我使用 Pool 的姿势上做下改动,每次都 Pool.Get
,一次都不调用 Pool.Put
,那么会有什么情况发生?
答案是:没啥情况发生,程序照常运行。只不过 Pool 每次 Get 的时候,都要执行 New 函数来构造对象而已,Pool 也失去了最本质的功能而已:复用临时对象。调用 Pool.Put
调用的本质目的就是为了对象复用。
3. Pool 本身允许复制之后使用吗?
不允许,但是你可以做的到。什么意思?
如果你在代码里 copy 了一个 Pool 池,你的代码 go build
是可以编译通过的,但是可能会导致内泄露的问题。在结构体 struct Pool
的实现中中已经明确说了,不允许 copy 。以下为官方原话:
// A Pool must not be copied after first use.
在 struct Pool
有一个字段 Pool.noCopy
明确限制你不要 copy,但是这个只有运行 go vet 才能检查出来(所以大家的代码编译之前一定要 go vet
做一次静态检查,可以避免非常多的问题)。
思考下,为什么要 Pool 禁止 copy ?
因为 Copy 之后,对于同一个 Pool 里面 cache 的对象,我们有了两个指向来源,原 Pool 清空之后,copy 的 Pool 没有清理掉,那么里面的对象就全都泄露了。并且 Pool 里面的无锁设计的基础是多个 Goroutine 不会操作到同一个数据结构,Pool 拷贝之后则不能保证这点。类似 sync.WaitGroup
, sync.Cond
首字段都用了 noCopy
结构,所以这两个结构体也是不能 copy 使用的。
所以,Pool 千万不要 copy 使用,编译之前一定要 go vet
检查代码。
总结
以上知识点做个总结:
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 之后再使用;
更多干货,欢迎关注,公众号:奇伢云存储
版权声明: 本文为 InfoQ 作者【奇伢云存储】的原创文章。
原文链接:【http://xie.infoq.cn/article/55f28d278cccf0d8195459263】。文章转载请联系作者。
评论 (1 条评论)