Go 是一个自动垃圾回收的编程语言,采用三色并发标记算法标记对象并回收。和其它没 有自动垃圾回收的编程语言不同,使用 Go 语言创建对象的时候,我们没有回收 / 释放的 心理负担,想用就用,想创建就创建。
但是,如果你想使用 Go 开发一个高性能的应用程序的话,就必须考虑垃圾回收给性能带 来的影响,毕竟,Go 的自动垃圾回收机制还是有一个 STW(stop-the-world,程序暂 停)的时间,而且,大量地创建在堆上的对象,也会影响垃圾回收标记的时间。
所以,一般我们做性能优化的时候,会采用对象池的方式,把不用的对象回收起来,避免 被垃圾回收掉,这样使用的时候就不必在堆上重新创建了。
不止如此,像数据库连接、TCP的长连接,这些连接在创建的时候是一个非常耗时的操作。如果每次都创建一个新的连接对象,耗时较长,很可能整个业务的大部分耗时都花在了创建连接上。
所以,如果我们能把这些连接保存下来,避免每次使用的时候都重新创建,不仅可以大大 减少业务的耗时,还能提高应用程序的整体性能。
Go 标准库中提供了一个通用的 Pool 数据结构,也就是 sync.Pool,我们使用它可以创建 池化的对象。这节课我会详细给你介绍一下 sync.Pool 的使用方法、实现原理以及常见的 坑,帮助你全方位地掌握标准库的 Pool。
不过,这个类型也有一些使用起来不太方便的地方,就是它池化的对象可能会被垃圾回收 掉,这对于数据库长连接等场景是不合适的。所以在这一讲中,我会专门介绍其它的一些 Pool,包括 TCP 连接池、数据库连接池等等。
除此之外,我还会专门介绍一个池的应用场景: Worker Pool,或者叫做 goroutine pool,这也是常用的一种并发模式,可以使用有限的 goroutine 资源去处理大量的业务数 据。
sync.Pool
首先,我们来学习下标准库提供的 sync.Pool 数据类型。 sync.Pool 数据类型用来保存一组可独立访问的临时对象。请注意这里的“临时”这两 个字,它说明了 sync.Pool 这个数据类型的特点,也就是说,它池化的对象会在未来的某 个时候被毫无预兆地移除掉。而且,如果没有别的对象引用这个被移除的对象的话,这个 被移除的对象就会被垃圾回收掉。
因为 Pool 可以有效地减少新对象的申请,从而提高程序性能,所以 Go 内部库也用到了 sync.Pool,比如 fmt 包,它会使用一个动态大小的 buffer 池做输出缓存,当大量的 goroutine 并发输出的时候,就会创建比较多的 buffer,并且在不需要的时候回收掉。 有两个知识点你需要记住:
- sync.Pool 本身就是线程安全的,多个 goroutine 可以并发地调用它的方法存取对象;
- sync.Pool不可在使用之后再复制使用。
使用方法
知道了 sync.Pool 这个数据类型的特点,接下来,我们来学习下它的使用方法。其实,这 个数据类型不难,它只提供了三个对外的方法:New、Get 和 Put。
1.New
Pool struct 包含一个 New 字段,这个字段的类型是函数 func() interface{}。当调用 Pool 的 Get 方法从池中获取元素,没有更多的空闲元素可返回时,就会调用这个 New 方法来 创建新的元素。如果你没有设置 New 字段,没有更多的空闲元素可返回时,Get 方法将返 回 nil,表明当前没有可用的元素。
有趣的是,New 是可变的字段。这就意味着,你可以在程序运行的时候改变创建元素的方 法。当然,很少有人会这么做,因为一般我们创建元素的逻辑都是一致的,要创建的也是 同一类的元素,所以你在使用 Pool 的时候也没必要玩一些“花活”,在程序运行时更改 New 的值。
2.Get
如果调用这个方法,就会从 Pool取走一个元素,这也就意味着,这个元素会从 Pool 中移 除,返回给调用者。不过,除了返回值是正常实例化的元素,Get 方法的返回值还可能会 是一个 nil(Pool.New 字段没有设置,又没有空闲元素可以返回),所以你在使用的时 候,可能需要判断。
3.Put
这个方法用于将一个元素返还给 Pool,Pool 会把这个元素保存到池中,并且可以复用。但 如果 Put 一个 nil 值,Pool 就会忽略这个值
一个常用场景buffer池。因为 byte slice是经常被创建销毁的一类对象,使用buffer池可以缓存已经创建的byteslice,比如,著名的静态网站生成工具 Hugo中,就包含这样的实现e bufpool,你可以看一下下面这段代码:
var buffers = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func GetBuffer() *bytes.Buffer {
return buffers.Get().(*bytes.Buffer)
}
func PutBuffer(buf *bytes.Buffer) {
buf.Reset()
buffers.Put(buf)
}
实现原理
Pool 最重要的两个字段是 local 和 victim,因为它们两个主要用来存储空闲的元素。弄清 楚这两个字段的处理逻辑,你就能完全掌握 sync.Pool 的实现了。下面我们来看看这两个 字段的关系。
每次垃圾回收的时候,Pool 会把 victim 中的对象移除,然后把 local 的数据给 victim, 这样的话,local 就会被清空,而 victim 就像一个垃圾分拣站,里面的东西可能会被当做 垃圾丢弃了,但是里面有用的东西也可能被捡回来重新使用。
victim 中的元素如果被 Get 取走,那么这个元素就很幸运,因为它又“活”过来了。但 是,如果这个时候 Get 的并发不是很大,元素没有被 Get 取走,那么就会被移除掉,因为 没有别人引用它的话,就会被垃圾回收掉。
下面的代码是垃圾回收时 sync.Pool 的处理逻辑:
func poolClearup{
for _, p := range oldPools {
p.victim = nil
p.victimSize = 0
}
// 将local复制给victim, 并将原local置为nil
for _, p := range allPools {
p.victim = p.local
p.victimSize = p.localSize
p.local = nil
p.localSize = 0
}
oldPools, allPools = allPools, nil
}
在这段代码中,你需要关注一下 local 字段,因为所有当前主要的空闲可用的元素都存放在 local 字段中,请求元素时也是优先从 local 字段中查找可用的元素。local 字段包含一个 poolLocalInternal 字段,并提供 CPU 缓存对齐,从而避免 false sharing。
而 poolLocalInternal 也包含两个字段:private 和 shared。
-
private,代表一个缓存的元素,而且只能由相应的一个 P 存取。因为一个 P 同时只能 执行一个 goroutine,所以不会有并发的问题。
-
shared,可以由任意的 P 访问,但是只有本地的 P 才能 pushHead/popHead,其它 P 可以 popTail,相当于只有一个本地的 P 作为生产者(Producer),多个 P 作为消费者 (Consumer),它是使用一个 local-free 的 queue 列表实现的。
Get方法
func (p *Pool) Get() interface{} {
// 把当前goroutine固定在当前的P上
l, pid := p.pin()
x := l.private // 优先从local的private字段取,快速
l.private = nil
if x == nil {
// 从当前的local.shared弹出一个,注意是从head读取并移除
x, _ = l.shared.popHead()
if x == nil { // 如果没有,则去偷一个
x = p.getSlow(pid)
}
}
runtime_procUnpin()
// 如果没有获取到,尝试使用New函数生成一个新的
if x == nil && p.New != nil {
x = p.New()
}
return x
}
我来给你解释下这段代码。首先,从本地的 private 字段中获取可用元素,因为没有锁, 获取元素的过程会非常快,如果没有获取到,就尝试从本地的 shared 获取一个,如果还没 有,会使用 getSlow 方法去其它的 shared 中“偷”一个。最后,如果没有获取到,就尝 试使用 New 函数创建一个新的。
这里的重点是 getSlow 方法,我们来分析下。看名字也就知道了,它的耗时可能比较长。 它首先要遍历所有的 local,尝试从它们的 shared 弹出一个元素。如果还没找到一个,那 么,就开始对 victim 下手了。
在 vintim 中查询可用元素的逻辑还是一样的,先从对应的 victim 的 private 查找,如果 查不到,就再从其它 victim 的 shared 中查找。
下面的代码是 getSlow 方法的主要逻辑:
func (p *Pool) getSlow(pid int) interface{} {
size := atomic.LoadUintptr(&p.localSize)
locals := p.local
// 从其它proc中尝试偷取一个元素
for i := 0; i < int(size); i++ {
l := indexLocal(locals, (pid+i+1)%int(size))
if x, _ := l.shared.popTail(); x != nil {
return x
}
}
// 如果其它proc也没有可用元素,那么尝试从vintim中获取
size = atomic.LoadUintptr(&p.victimSize)
if uintptr(pid) >= size {
return nil
}
locals = p.victim
l := indexLocal(locals, pid)
if x := l.private; x != nil { // 同样的逻辑,先从vintim中的local private获取
l.private = nil
return x
}
for i := 0; i < int(size); i++ { // 从vintim其它proc尝试偷取
l := indexLocal(locals, (pid+i)%int(size))
if x, _ := l.shared.popTail(); x != nil {
return x
}
}
// 如果victim中都没有,则把这个victim标记为空,以后的查找可以快速跳过了
atomic.StoreUintptr(&p.victimSize, 0)
return nil
}
这里我没列出 pin 代码的实现,你只需要知道,pin 方法会将此 goroutine 固定在当前的 P 上,避免查找元素期间被其它的 P 执行。固定的好处就是查找元素期间直接得到跟这个 P 相关的 local。有一点需要注意的是,pin 方法在执行的时候,如果跟这个 P 相关的 local 还没有创建,或者运行时 P 的数量被修改了的话,就会新创建 local。
Put方法
func (p *Pool) Put(x interface{}) {
if x == nil { // nil值直接丢弃
return
}
l, _ := p.pin()
if l.private == nil { // 如果本地private没有值,直接设置这个值即可
l.private = x
x = nil
}
if x != nil { // 否则加入到本地队列中
l.shared.pushHead(x)
}
runtime_procUnpin()
}
Put的逻辑相对简单,优先设置本地private,如果private 字段已经有值了,那么就把此元素push 到本地队列中。