理解 Go 语言的池Pool

时间:2024-10-01 14:28:07

        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 方法返还即可。

下面的代码是一个池化 的例子。

  1. package main
  2. import (
  3. "fmt"
  4. "net/http"
  5. "sync"
  6. "time"
  7. )
  8. func main(){
  9. var p //创建一个对象池
  10. = func() interface{}{ //对象创建的方法
  11. return &{
  12. Timeout: 5 * ,
  13. }
  14. }
  15. var wg
  16. (10) // 使用10个goroutine测试从对象池中获取对象和放回对象
  17. go func(){
  18. for i :=0; i <10; i++ {
  19. go func(){
  20. defer ()
  21. c := ().(*) //获取一个对象,如果不存在,就新创建一个
  22. defer (c) // 使用完毕后放回池子,以便重用
  23. resp,err := ("")
  24. if err != nil {
  25. ("failed to get :",err)
  26. return
  27. }
  28. ()
  29. ("got ")
  30. }()
  31. }
  32. }()
  33. ()
  34. }

         在这个例子中,我们为 New 定义了创建 的方法,然后启动 10 个 goroutine 使用 来访问一个网址。在访问网址时,首先从池子中获取一个 对象,使用完毕后再放回池子。实际上,这个 Pool 可能创建了 10 个 ,也可能创建了8个,还可能创建了 3个,就看用户从它那里请求时是否有空闲的 ,以及其他 goroutine 能不能及时把 放回去。

        这里没有检查从池子中获取的 是否为空,原因是我们为 New 字段复制了创建 的方法,并且确保它能返回一个 。如果没有为 New 字段定义方法,那么就需要检查 Get 方法返回的结果是否为 nil 。

        你也许会问,为什么不设置 New 字段呢?因为可能会有这样的场景:要求最多使用 5 个 ,超过 5 个是不充许的,那么就需要预先初始化 5 个 ,不设置 New 字段,就能保证不会超过 5 个。

  1. func main(){
  2. var p
  3. for i :=0; i < 5; i++ {
  4. (&{//没有设置 New 字段,初始化时就放入了 5 个可重用对象
  5. Timeout: 5 * ,
  6. })
  7. }
  8. var wg
  9. (10)
  10. go func(){
  11. for i := 0; i <10; i++ {//使用10个goroutine测试
  12. go func(){
  13. defer ()
  14. c,ok := ().(*)
  15. if !ok{//可能从池子中获取不到对象
  16. ("got client is nil")
  17. return
  18. }
  19. defer (c)
  20. resp,err := ("")
  21. if err != nil {
  22. ("failed to get ",err)
  23. return
  24. }
  25. ()
  26. ("got ")
  27. }()
  28. }
  29. }()
  30. }

        在这个例子中没有设置 字段,只是一开始初始化了 5 个 。运行这个程序,大概率没有什么问题,可能 10 次 HTTP 请求都能成功。但是不设置 New 字段风险很大,因为池化的对象如果长时间没有被调用,可能就会被回收。这和垃圾回收的时机相关,所以无法预测什么时候池化的对象会被回收。比如上面的例子,在初始化池化的对象后,连续调用两次 ,强制进行两次垃圾回收,你就会发现后面的 10 个 goroutine 获得的都是值为 nil 的对象。

  1. var p
  2. for i :=0; i< 10; i++ {
  3. (&{
  4. Timeout: 5 * ,
  5. })
  6. }
  7. ()
  8. () //垃圾回收后对象全被释放了

        不设置 New 字段,可能总是会获取到值为 nil 的对象,所以这种方法很少使用。

        有趣的是,New 是可变字段。这就意味着,你可以在程序运时改变创建对象的方法。当然,很少有人会这么做。因为一般创建对象的逻辑都是一致的,要创建的也是同一类对象,所以在使用 Pool 时没必要玩一些 “花活” -- 在程序运时更改 New 的值。

        使用 可以池化任意的对象,我们经常使用它来池化 byte slice(字节切片),创建一个字节池,避免频繁地创建 byte slice。

        比如在 Vitess 中使用 构建了一个多级的 byte slice 池,之所以采用多级,就是为了节省内存空间。如果所有场景都使用很大的 byte slice,则着实是一种浪费。

  1. type Pool struct { // 一个分级的Pool
  2. minSize int
  3. maxSize int
  4. pools []*sizedPool
  5. }

        这个 Pool 可以返回如 32KB、64KB和 128KB 大小的 byte slice,按照调用者的需求选择相应的 sizePool:

  1. func (p *Pool) findPool(size int) *sizedPool {//选择特定大小的池子
  2. if size > {
  3. return nil
  4. }
  5. div,rem := bits.Div64(0,uint64(size),uint64())
  6. idx := bits.Len64(div)
  7. if rem == 0 && div != 0 && (div&(div-1))==0 {
  8. idx = idx -1
  9. }
  10. return [idx]
  11. }
  12. func (p *Pool) Get(size int) *[]byte {//获取对象
  13. sp := (size) // 先找到对应的池子
  14. if sp == nil {
  15. return makeSliecePointer(size)
  16. }
  17. buf := ().(*[]byte)
  18. *buf = (*buf)[:size]
  19. return buf
  20. }

        调用者在获取 byte slice 时,需要传入一个期望的大小,Pool 会根据这个大小找到一个合适的 sizePool,然后调用 方法。

 放回 byte slice 的时候也是先找到对应的 sizePool:

  1. func (p *Pool) Put(b *[]byte) {//将对象放回池子
  2. sp := (cap(*b)) // 先找到对应的池子
  3. if sp == nil {
  4. return
  5. }
  6. *b =(*b)[:cap(*b)]
  7. (b)
  8. }

sizedPool 的实现其实就是利用了 :

  1. type sizedPool struct {//sizedPool 包含对应的大小和
  2. size int
  3. pool
  4. }
  5. func newSizedPool(size int) *sizedPool {
  6. return &sizedPool {
  7. size:size,
  8. pool:{
  9. New:func()any {return makeSliecePointer(size)},
  10. },
  11. }
  12. }

         除为了节省内存空间而采用分级的 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 取走,那么它就会被移除,因为没有其他对象引用它,它就会被垃圾回收。