2
Go 并发系列是根据我对晁岳攀老师的《Go 并发编程实战课》的吸收和理解整理而成,如有偏差,欢迎指正~

为什么需要池化 Pool

Go 是一个支持自动垃圾回收的语言,对程序员而言,我们想创建对象就创建,不用关心资源的回收,大大提高了开发的效率。

但是方便的背后,却有也有不小的代价。Go 的垃圾回收机制还是有一个 STW (stop-the-world,程序暂停)的时间,大量创建的对象,都会影响垃圾回收标记的时间。

除此之外,像数据库的连接,tcp 连接,这些连接的创建本身就十分耗时,如果能将这些连接复用,也能减少业务耗时。

所以,高并发场景下,采用池化(Pool)手段,对某些对象集中管理,重复利用,减少创建和垃圾回收的成本,不仅可以大大提高业务的响应速度,也能提高程序的整体性能。

什么是池化 Pool

池化就是对某些对象进行集中管理,重复利用,减少对象创建和垃圾回收的成本。

Go 标准库 sync 提供了一个通用的 Pool,通过这个 Pool 可以创建池化对象,实现一般对象的管理。

下面我们主要看一下 sync.Pool 的实现。

sync.Pool 使用

sync.Pool 的使用很简单,它有1个对外的成员变量 New 和2个对外的成员方法 Get 和 Put。

下面是一个使用示例(见 fUsePool 方法):

type AFreeCoder struct {    officialAccount string    article         string    content         \[\]string    placeHolder     string}// 为了真实模拟,这里禁止编译器使用内联优化//go:noinlinefunc NewAFreeCoder() \*AFreeCoder {    return &AFreeCoder{        officialAccount: "码农的自由之路",        content:         make(\[\]string, 10000, 10000),        placeHolder:     "如果觉得有用,欢迎关注哦~",    }}func (a \*AFreeCoder) Write() {    a.article = "Go 并发之性能提升杀器 Pool"}func f(concurrentNum int) {    var w sync.WaitGroup    w.Add(concurrentNum)    for i := 0; i < concurrentNum; i++ {        go func() {            defer w.Done()            a := NewAFreeCoder()            a.Write()        }()    }    w.Wait()}func fUsePool(concurrentNum int) {    var w sync.WaitGroup    p := sync.Pool{        New: func() interface{} {            return NewAFreeCoder()        },    }    w.Add(concurrentNum)    for i := 0; i < concurrentNum; i++ {        go func() {            defer w.Done()            a := p.Get().(\*AFreeCoder)            defer p.Put(a)            a.Write()        }()    }    w.Wait()}

AFreeCoder 是自定义的结构体,用来模拟初始化比较耗时类型。

New 是函数类型变量,传入的函数需要实现 AFreeCoder 的初始化。

Get 方法返回的是 interface{} 类型,需要断言成 New 返回的类型。

Put 方法也比较好理解,变量用完了再放回去。

使用 sync.Pool 真的能提升性能吗?

上面的示例中,f 和 fUsePool 分别实现了不使用 Pool 和使用 Pool 情况下,并发执行 Write 函数的功能。

那么这两个函数的性能对比如何呢?我们可以用 go test 的 benchmark 测试一下(并发数 concurrentNum=100),测试结果如下:

goos: darwingoarch: amd64pkg: go\_practice/pool\_exampleBenchmark\_f-8                853           1355041 ns/op        16392237 B/op        203 allocs/opBenchmark\_fUsePool-8       12460             98046 ns/op          565066 B/op          9 allocs/opPASSok      go\_practice/pool\_example        4.663s

测试结果显示,使用了 Pool 之后,内存分配的次数相比不使用 Pool 的方式少很多,整体的耗时也会小很多。

如果把上面示例中初始化函数 NewAFreeCoder 中 content 的初始化操作去掉,再测试一次呢?测试结果如下:

goos: darwingoarch: amd64pkg: go\_practice/pool\_exampleBenchmark\_f-8                853           1355041 ns/op        16392237 B/op        203 allocs/opBenchmark\_fUsePool-8       12460             98046 ns/op          565066 B/op          9 allocs/opPASSok      go\_practice/pool\_example        4.663s

上面数据粘错了)使用了 Pool 之后,内存分配次数和每次操作消耗的内存仍然很少,但是整体的耗时相对不使用 Pool 的情况并没减少。

这是因为此时创建 AFreeCoder 对象的成本较低,而 Pool 相关操作也会有性能的消耗,所以才导致两者整体耗时差不多。

使用 sync.pool 的注意点

sync.Pool 本身是线程安全的,可以多个 goroutine 并发调用,使用起来很方便,但是有两个注意点:

  1. 禁止拷贝
  2. 不能存放需要保持长连接的对象

第1点,禁止拷贝很好理解,毕竟 New 很容易修改。

第2点,不能存放需要保持长连接的对象。这是因为 sync.Pool 注册了自己的 Pool 清理函数,Pool 中的变量可能会被垃圾回收掉。如果需求保存长连接,有很多其它的 Pool 实现了这种功能。

sync.pool 的实现

sync.pool 的定义

看一下 Pool 的定义:

type Pool struct {
    noCopy noCopy

    local     unsafe.Pointer // local fixed-size per-P pool, actual type is \[P\]poolLocal
    localSize uintptr        // size of the local array

    victim     unsafe.Pointer // local from previous cycle
    victimSize uintptr        // size of victims array

    // New optionally specifies a function to generate
    // a value when Get would otherwise return nil.
    // It may not be changed concurrently with calls to Get.
    New func() interface{}
}

noCopy 不用多解释,用于静态检查,防拷贝的。New 前面也说过,用来存对象初始化函数的。

重点是 local 和 victim 这两个字段。解释这两个字段前,先上一张《Go 并发实战课》原文的 sync.Pool 数据结构的示意图:

图片

sync.Pool 中,缓存的对象并不是存储在一个队列中,而是根据处理器 P 的核数 n 存了 n 份,这样能最大程度的保证并发的时候 n 个 goroutine 可以同时获取对象。

local 和 victim 结构都一样,都是 poolLocal 类型,有 private 和 shared 成员。private 存储单个对象,shared 是 poolChain 类型,类似队列,存了一堆对象。因为 local 和 victim 都是和处理器绑定的,当某个 goroutine 独占一个处理器时,直接通过 private 取值不需要加锁,速度就会很快。

为什么有了 local,还需要 victim 呢?这是为了降低池子中对象被回收的可能性

由于 sync.Pool 中存储对象的个数不定,大小不定,所以它需要在系统闲暇的时候将变量回收掉。其实现方式如下:

func poolCleanup() {
    for \_, p := range oldPools {
        p.victim = nil
        p.victimSize = 0
    }

    for \_, p := range allPools {
        p.victim = p.local
        p.victimSize = p.localSize
        p.local = nil
        p.localSize = 0
    }
    
    oldPools, allPools = allPools, nil
}

poolClean() 函数被注册到了 runtime 中,会在每一次 GC 调用之前被调用。这样 GC 第一次调用的时候,local 虽然被清空,但是还能通过 victim 拿到池子中的对象。

Put 方法

Put 方法实现的功能是将用完的对象重新放回池子里。因为 Put 比较简单,所以先介绍 Put 方法。

Put 方法实现如下:

func (p \*Pool) Put(x interface{}) {    if x == nil {        return    }    // 把当前goroutine固定在当前的P上    // l 就是 local    l, \_ := p.pin()     if l.private == nil {        l.private = x        x = nil    }    if x != nil {        l.shared.pushHead(x)    }    runtime\_procUnpin()}

先说一下 p.pin() 和 runtime\_procUnpin(), 这两个函数分别实现了某 goroutine 抢占当前 P(处理器)和解除抢占的功能。所以这里 private 的复制和之后 Get 方法中的读取都不需要加锁。

整个逻辑比较简单,优先存到本地 private,如果 private 已经有值了,就放到本地队列中。

Get 方法

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}

Get 方法整体概括就是从池子中取出一个对象,如果没有对象了,就 New 一个,再返回。

细节上,先从当前 P 对应的 local 的 private 获取,获取不到,就从当前 P 的 local 的队列 shared 中获取,还获取不到就从其它 P 的 shared 中获取 (getSlow 方法)。

如果最终仍然获取不到,才 New 一个对象。

sync.Pool 的坑

虽然 sync.Pool 也做了很多优化,性能有了很大的提升,但是使用的时候还是有两个坑:

内存泄露

如果池子中对象的类型是 slice,它的 cap 可能不断的变大,而 sync.Pool 的回收机制(第二次回收)可能导致这些过大的对象越来越多,且一直无法回收,最终造成内存泄露。

所以有一些特定 Pool 的使用中,会对池子中的变量的大小做一个限制,超过一个阈值直接丢弃。

内存浪费

除了内存泄露外,还有一种浪费的情况,就是池子中的变量变得很大,但是很多时候只需要一个很小的变量,就会造成内存浪费的情况。

长连接对象如何池化?

因为 sync.Pool 保存的对象可能会被无通知的释放掉,并不适合用来保存连接对象。连接对象的保存一般都通过其它方法完成。

比如 Go 中 http 连接的连接池的实现在 Transport 中,它用一个 idleConn 对象(map)来保存连接,由于没有类似 sync.Pool 的垃圾回收方法 PoolClean(),所以能保持长连接。Transport 对连接数量的控制通过 LRU 实现。

像第三方包 faith/pool,它是通过 channel + Mutex 的方式实现的 Pool,空闲的连接放到 channel 中。这也是 channel 的一个应用场景。

最后

Pool 是一个通用的概念,也是解决对象重用和预先分配的一个常用的优化手段。

但是项目一开始其实没必要考虑考虑这种优化,只有到了中后期阶段,出现性能瓶颈,需要优化的时候,可以考虑通过 Pool 的方式来优化。

图片

码农的自由之路

码农的自由之路

996的码农,也能自由~

47篇原创内容

公众号


都看到这里了,不如点个 赞/在看,加个关注呗~~


好文收藏
38 声望6 粉丝

好文收集