Go 并发之性能提高杀器 Pool

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。tcp

下面是一个使用示例(见 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篇原创内容

公众号


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

相关文章
相关标签/搜索