再过10分钟你就能了解Go中的sync.Pool啦

233 阅读3分钟
  • 元素池化是常用的性能优化的手段(性能优化的几把斧头:并发,预处理,缓存)

  • 是通用型的 Pool 池模式,针对所有的对象类型都可以用。

  • sync.Pool 本质用途是增加临时对象的重用率,减少 GC 负担,常用于一些对象实例创建昂贵的场景;

  • 不能对 Pool.Get 出来的对象做预判,有可能是新的(新分配的),有可能是旧的(之前人用过,然后 Put 进去的);

  • 不能对 Pool 池里的元素个数做假定;

  • Pool 是 Goroutine 并发安全的, sync.Pool 本身的 Get, Put 调用是并发安全的,sync.New 指向的初始化函数会并发调用,里面安不安全看函数内容;

  • 当用完一个从 Pool 取出的实例时候,一定要记得调用 Put,否则 Pool 无法复用这个实例,通常这个用 defer 完成;

一、初始化

创建一个 Pool 实例。 关键是配置 New 方法,声明 Pool 元素创建的方法。

bufferpool := &sync.Pool {
    New: func() interface {} {
        println("Create new instance")
        return struct{}{}
    }
}

二、申请对象

buffer := bufferPool.Get()

Get 方法返回 Pool 已存在对象,如果没有就调用初始化的时候定义的 New 方法来初始化一个对象。

三、释放对象

bufferPool.Put(buffer)

使用对象之后,调用 Put 方法声明把对象放回池子。 这个调用之后仅仅是把这个对象放回池子,池子里面的对象啥时候真正释放外界是不清楚的,是不受外部控制的

四、为什么用 Pool,而不是运行时直接实例化对象?

本质原因:Go 的内存释放是由 runtime 来自动处理的,有 GC 过程。

举个例子

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

// 用来统计实例真正创建的次数
var numCalcsCreated int32

// 创建实例的函数
func createBuffer() interface{} {
    // 这里要注意下,非常重要的一点。这里必须使用原子加,不然有并发问题;
    atomic.AddInt32(&numCalcsCreated, 1)
    buffer := make([]byte, 1024)
    return &buffer
}

func main() {
    // 创建实例
    bufferPool := &sync.Pool{
        New: createBuffer,
    }

    // 多 goroutine 并发测试
    numWorkers := 1024 * 1024
    var wg sync.WaitGroup
    wg.Add(numWorkers)

    for i := 0; i < numWorkers; i++ {
        go func() {
            defer wg.Done()
            // 申请一个 buffer 实例
            buffer := bufferPool.Get()
            _ = buffer.(*[]byte)
            // 释放一个 buffer 实例
            defer bufferPool.Put(buffer)
        }()
    }
    wg.Wait()
    fmt.Printf("%d buffer objects were created.\n", numCalcsCreated)
}

上面的例子可以直接复制运行起来看下,控制台输出:

➜  pool# go run test_pool.go        
17 buffer objects were created.
➜  pool# go run test_pool.go
34 buffer objects were created.

程序 go run 运行了两次,一次结果是 17 ,一次是 34 。这个是什么原因呢?

首先,这个是正常的情况,不知道你有没有注意到,创建 Pool 实例的时候,只要求填充了 New 函数,而根本没有声明或者限制这个 Pool 的大小。所以,记住一点,程序员作为使用方不能对 Pool 里面的元素个数做假定

再来,如果我不用 Pool 来申请实例,而是直接申请,也就是上面的代码只改一行:

将以下代码:

// 申请一个 buffer 实例
buffer := bufferPool.Get()

修改成:

// 申请一个 buffer 实例
buffer := createBuffer()

这个时候,我们再执行程序 go run test_pool.go,会发现什么?

➜  pool go run test_pool_1.go
1048576 buffer objects were created.
➜  pool go run test_pool_1.go
1048576 buffer objects were created.

注意到,和之前有两个不同点

  1. 同样也是运行两次,两次结果相同。
  2. 对象创建的数量和并发 Worker 数量相同,数量等于 1048576 (这个就是 1024*1024);

原因很简单,因为每次都是直接调用 createBuffer 函数申请 buffer,有 1048576 个并发 Worker 调用,所以跑多少次结果都会是 1048576。

实际上还有一个不同点,就是程序跑的过程中,该进程分配消耗的内存很大。因为 Go 申请内存是程序员触发的,回收却是 Go 内部 runtime GC 回收器来执行的,这是一个异步的操作。这种业务不负责任的内存使用会对 GC 带来非常大的负担,进而影响整体程序的性能。

五、为什么 sync.Pool 的两次使用结果输出不一样

因为复用的速度不一样我们不能对 Pool 池里的 cache 的元素个数做任何假设。不过还是那句话,如果速度足够快,其实里面可以只有一个元素就可以服务 1048576 个并发的 Goroutine 。

六、sync.Pool 是并发安全的吗?

sync.Pool 是并发安全的。官方文档里明确说了:

A Pool is safe for use by multiple goroutines simultaneously.

但是,因为 sync.Pool 只是本身的 Pool 数据结构是并发安全的,并不是说 Pool.New 函数一定是线程安全的。Pool.New 函数可能会被并发调用 ,如果 New 函数里面的实现是非并发安全的,那就会有问题。

细心的小伙伴会注意到我在上面的代码例子里,关于 createBuffer 函数的实现里,对于 numCalcsCreated 的计数加是用原子操作的:atomic.AddInt32(&numCalcsCreated, 1)

func createBuffer() interface{} {
    // 这里要注意下,非常重要的一点。这里必须使用原子加,不然有并发问题;
    atomic.AddInt32(&numCalcsCreated, 1)
    buffer := make([]byte, 1024)
    return &buffer
}

因为 numCalcsCreated 是个全局变量,Pool.New( 也就是 createBuffer ) 并发调用的时候,会导致 data race ,所以只有用原子操作才能保证数据的正确性。

小伙伴们可以尝试下,把 atomic.AddInt32(&numCalcsCreated, 1) 这样代码改成 numCalcsCreated++ ,然后用 go run -race test_pool.go 命令检查一下,肯定会报警!

本质原因:Pool.New 函数可能会被并发调用。

七、 为什么 sync.Pool 不适用于 socket 长连接或数据库连接池?

因为,我们不能对 sync.Pool 中保存的元素做任何假设,以下事情是都可以发生的:

  1. Pool 池里的元素随时可能释放掉,释放策略完全由 runtime 内部管理;
  2. Get 获取到的元素对象可能是刚创建的,也可能是之前创建好 cache 住的。使用者无法区分;
  3. Pool 池里面的元素个数你无法知道;

所以,只有的你的场景满足以上的假定,才能正确的使用 Pool 。

sync.Pool 本质用途是增加临时对象的重用率,减少 GC 负担。

划重点:临时对象

所以说,像 socket 这种带状态的,长期有效的资源是不适合 Pool 的。