Go 是一种有自动垃圾回收机制的编程语言,采用三色并发标记算法标记对象并回收。和其他没有自动垃圾回收机制的编程语言不同,使用 Go 语言创建对象时,我们没有回收/释放的心理负担,想创建对象就创建,想用对象就用。
但是,如果想使用 Go 语言开发一个高性能的应用程序,就必须考虑垃圾回收给性能带来的影响,毕竟 Go 的自动垃圾回收机制有一个STW(stop-the-world,程序暂停)的时间,而且在堆上大量地创建对象,也会影响垃圾回收标记的时间。所以,我们在做性能优化时,通常会采用对象池的方式,把不用的对象回收起来,避免被垃圾回收,这样使用时就不必在堆上重新创建对象了。
不仅如此,像数据库连接、TCP 的长连接等,这些连接的创建也是一个非常耗时的操作。如果每次使用时都创建一个新的连接,则很可能整个业务的很多时间都花在了创建连接上。所以,如果能把这些连接保存下来,避免每次使用时都重新创建,则不仅可以大大减少业务的耗时,还能提高应用程序的整体性能。
这种模式被称为对象池设计模式(object pool pattern) 。一个对象池包含一组已经初始化过且可以重复使用的对象,池的用户可以从池子中获得对象,对其进行操作处理,并在不需要的时候归还给池子,而非直接销毁它。
若初始化对象的代价很高,且经常需要实例化对象,但实例化的对象数量较少,那么使用对象池可以显著提升性能。从池子中获得对象的时间是可预测的且时间花费较少,而新建一个实例所需的时间是不确定的,可能时间花费较多。
Go标准库中提供了一个通用的 Pool 数据结构,也就是 ,我们使用它可以创建池化的对象。
1. 的使用方法
首先,我们来介绍 Go 标准库中提供的 数据类型。
数据类型用来保存一组可独立访问的 “临时” 对象。请注意这里加引号的 “临时” 两个字,它说明了 这个数据类型的特点--其池化的对象会在未来某个时候被毫无征兆地移除。而且,如果没有别的对象引用这个要被移除的对象,该对象就会被垃圾回收。
因为 Pool 可以有效地减少对新对象的申请,从而提高程序性能,所以 Go 内部库中也用到了 。比如 fmt 包,它会使用一个动态大小的 buffer 池做输出缓存,当大量的 goroutine 并发输出的时候,就会创建比较多的 buffer ,并且在不需要的时候被回收。
有两个知识点需要记住:
- 本身就是线程安全的,多个 goroutine 可以并发地调用它的方法存取对象。
- 不可在使用之后再复制使用。
这个数据类型提供了三个对外的方法:New、Get 和 Put。
1. New:创建对象
这里的 New 不是创建 Pool 类型的对象的方法,而是 Pool 对象创建其池化对象的方法。因为只有定义了创建池化对象的方法,它才能在需要的时候创建对像。
Pool struct 包含一个 New 字段,这个字段的类型是函数 func() interface{ }。当调用 Pool 的 Get 方法从池中获取对象时,如果没有更多空闲的对象可用,就会调用 New 方法创建新的对象。如果没有设置 New 字段,当没有更多空闲的对象可返回时, Get 方法将返回 nil,表明当前没有可用的对象。
Pool 不需要初始化,你可以使用它的零值。
2. Get:获取对象
如果调用 Get 方法,就会从池子中取走一个对象。这就意味着这个对象会被从池子中移除,返回给调用者。不过,除了返回值是正常实例化的对象,Get 方法的返回值还可能是 nil ( 字段没有设置,又没有空闲的对象可返回),所以在使用的时候可能需要判断。
3. Put: 返还对象
Put 方法用于将一个对象返还给 Pool, Pool 会把这个对象保存到池子中,并且可以复用。但如果返还的是nil, Pool 就会忽略这个值。
如果你想弃用一个对象,不再重用它,很简单,不要再调用 Put 方法返还即可。
下面的代码是一个池化 的例子。
-
package main
-
-
import (
-
"fmt"
-
"net/http"
-
"sync"
-
"time"
-
)
-
-
func main(){
-
var p //创建一个对象池
-
= func() interface{}{ //对象创建的方法
-
return &{
-
Timeout: 5 * ,
-
}
-
}
-
-
var wg
-
(10) // 使用10个goroutine测试从对象池中获取对象和放回对象
-
go func(){
-
for i :=0; i <10; i++ {
-
go func(){
-
defer ()
-
c := ().(*) //获取一个对象,如果不存在,就新创建一个
-
defer (c) // 使用完毕后放回池子,以便重用
-
-
resp,err := ("")
-
if err != nil {
-
("failed to get :",err)
-
return
-
}
-
-
()
-
("got ")
-
}()
-
}
-
}()
-
()
-
}
在这个例子中,我们为 New 定义了创建 的方法,然后启动 10 个 goroutine 使用 来访问一个网址。在访问网址时,首先从池子中获取一个 对象,使用完毕后再放回池子。实际上,这个 Pool 可能创建了 10 个 ,也可能创建了8个,还可能创建了 3个,就看用户从它那里请求时是否有空闲的 ,以及其他 goroutine 能不能及时把 放回去。
这里没有检查从池子中获取的 是否为空,原因是我们为 New 字段复制了创建 的方法,并且确保它能返回一个 。如果没有为 New 字段定义方法,那么就需要检查 Get 方法返回的结果是否为 nil 。
你也许会问,为什么不设置 New 字段呢?因为可能会有这样的场景:要求最多使用 5 个 ,超过 5 个是不充许的,那么就需要预先初始化 5 个 ,不设置 New 字段,就能保证不会超过 5 个。
-
func main(){
-
var p
-
for i :=0; i < 5; i++ {
-
(&{//没有设置 New 字段,初始化时就放入了 5 个可重用对象
-
Timeout: 5 * ,
-
})
-
}
-
-
var wg
-
(10)
-
go func(){
-
for i := 0; i <10; i++ {//使用10个goroutine测试
-
go func(){
-
defer ()
-
c,ok := ().(*)
-
if !ok{//可能从池子中获取不到对象
-
("got client is nil")
-
return
-
}
-
defer (c)
-
-
resp,err := ("")
-
if err != nil {
-
("failed to get ",err)
-
return
-
}
-
()
-
("got ")
-
}()
-
}
-
}()
-
-
}
在这个例子中没有设置 字段,只是一开始初始化了 5 个 。运行这个程序,大概率没有什么问题,可能 10 次 HTTP 请求都能成功。但是不设置 New 字段风险很大,因为池化的对象如果长时间没有被调用,可能就会被回收。这和垃圾回收的时机相关,所以无法预测什么时候池化的对象会被回收。比如上面的例子,在初始化池化的对象后,连续调用两次 ,强制进行两次垃圾回收,你就会发现后面的 10 个 goroutine 获得的都是值为 nil 的对象。
-
var p
-
for i :=0; i< 10; i++ {
-
(&{
-
Timeout: 5 * ,
-
})
-
}
-
-
()
-
() //垃圾回收后对象全被释放了
不设置 New 字段,可能总是会获取到值为 nil 的对象,所以这种方法很少使用。
有趣的是,New 是可变字段。这就意味着,你可以在程序运时改变创建对象的方法。当然,很少有人会这么做。因为一般创建对象的逻辑都是一致的,要创建的也是同一类对象,所以在使用 Pool 时没必要玩一些 “花活” -- 在程序运时更改 New 的值。
使用 可以池化任意的对象,我们经常使用它来池化 byte slice(字节切片),创建一个字节池,避免频繁地创建 byte slice。
比如在 Vitess 中使用 构建了一个多级的 byte slice 池,之所以采用多级,就是为了节省内存空间。如果所有场景都使用很大的 byte slice,则着实是一种浪费。
-
type Pool struct { // 一个分级的Pool
-
minSize int
-
maxSize int
-
pools []*sizedPool
-
}
这个 Pool 可以返回如 32KB、64KB和 128KB 大小的 byte slice,按照调用者的需求选择相应的 sizePool:
-
func (p *Pool) findPool(size int) *sizedPool {//选择特定大小的池子
-
if size > {
-
return nil
-
}
-
div,rem := bits.Div64(0,uint64(size),uint64())
-
idx := bits.Len64(div)
-
if rem == 0 && div != 0 && (div&(div-1))==0 {
-
idx = idx -1
-
}
-
return [idx]
-
}
-
-
func (p *Pool) Get(size int) *[]byte {//获取对象
-
sp := (size) // 先找到对应的池子
-
if sp == nil {
-
return makeSliecePointer(size)
-
}
-
buf := ().(*[]byte)
-
*buf = (*buf)[:size]
-
return buf
-
}
调用者在获取 byte slice 时,需要传入一个期望的大小,Pool 会根据这个大小找到一个合适的 sizePool,然后调用 方法。
放回 byte slice 的时候也是先找到对应的 sizePool:
-
func (p *Pool) Put(b *[]byte) {//将对象放回池子
-
sp := (cap(*b)) // 先找到对应的池子
-
if sp == nil {
-
return
-
}
-
*b =(*b)[:cap(*b)]
-
(b)
-
}
sizedPool 的实现其实就是利用了 :
-
type sizedPool struct {//sizedPool 包含对应的大小和
-
size int
-
pool
-
}
-
-
func newSizedPool(size int) *sizedPool {
-
return &sizedPool {
-
size:size,
-
pool:{
-
New:func()any {return makeSliecePointer(size)},
-
},
-
}
-
}
除为了节省内存空间而采用分级的 buffer 设计外,其他的一些第三方库也会提供 buffer 池的功能。下面就介绍几个常用的第三方库。
(1)bytebufferpool
bytebufferpool 是 fasthttp 的作者 valyala 提供的一个 buffer 池,其基本功能和 相同。它的底层也是使用 实现的,它会检测放入的 buffer 的大小,如果 buffer 过大,那么此 buffer 就会被丢弃。
valyala 一向擅长挖掘系统的性能,这个库也不例外。它提供了校准(calibrate,用来动态调整创建对象的权重)的机制,可以“智能” 地调整 Pool 的 defaultSize 和 maxSize。一般来说,我们使用 buffer 的场景比较固定,所用 buffer 的大小会位于某个范围。有了校准的特性,bytebufferpool 就能够偏重于创建这个范围大小的 buffer,从而节省内存空间。
(2)oxtoacart/bpool
oxtoacart/bpool 也是比较常用的 buffer 池,它提供了以下几种类型的 buffer。
- :提供一个对象数量固定的 buffer 池,对象类型是 。如果超过这个数量,返还的时候就丢弃。如果池子中的对象都被取走了,则会新建一个 buffer 返还。返还的时候不会检测 buffer 的大小。
- :提供一个对象数量固定的 byte slice 池,对象类型是 byte slice。返还的时候不会检测 byte slice 的大小。
- :提供一个对象固定的 buffer 池,如果超过这个数量,返还的时候就丢弃。如果池子中的对象都被取走了,则会新建一个 buffer 返还。返还的时候会检测 buffer 的大小。如果超过指定的大小,则会创建一个新的满足条件的 buffer 放回去。
bpool 最大的特色就是能够保持池子中对象的数量,一旦返还时数量大于它的阈值,就会自动丢弃;而 是一个没有限制的池子,只要返还就会收进去。
bpool 是基于 channel 实现的,不像 为提高性能而做了很多优化,所以,它在性能上比不过 。不过,它提供了限制 Pool 容量的功能,如果你想控制 Pool 的容量,则可以考虑使用这个库。
2. 的实现
在 Go 1.13 之前, 的实现有如下两大问题:
(1)每次垃圾回收时都会回收创建的对象
如果缓存的对象数量太多,就会导致 STW 的时间变长;缓存的对象都被回收后,则会导致 Get 拿命中率下降,Get 方法不得不新创建很多对象。
(2)底层实现使用了 Mutex,对这个锁并发请求竞争激烈的时候,会导致性能的下降。
的数据结构如图所示:
Pool 最重要的两上字段是 local 和 victim,它们主要用来存储空闲的对象。 只要清楚了这两个字段的处理逻辑,你就能完全掌握 的实现。下面我们来看看这两个字段的关系。
每次垃圾回收的时候,Pool 都会把 victim 中的对象移除,然后把 local 的数据给 victim。这样一来, local 就会被清空,而 victim 就像一个垃圾分拣站,以后它里面的东西可能会被当做垃圾丢弃,但是里面有用的东西也可能会被捡回来重新使用。
victim 中对象的命运有两种:一种是如果对象被 Get 取走,那么这个对象就很幸运,因为它又 “活” 过来了;另一种是如果 Get 的并发量不是很大,对象没有被 Get 取走,那么它就会被移除,因为没有其他对象引用它,它就会被垃圾回收。