阅读 142

深入浅出 Go - sync.Pool 源码分析

在开发过程中我们可能需要用到对象。一般的做法是在函数中进行实例化对象,使用完后交给 GC 处理,但是这种方式在高并发场景下会导致 GC 时间过长,进而影响系统性能。这也就牵扯到我们今天要讲的 sync.Pool

快速入门

sync.Pool 用法很简单,如下

func main() {
    pool := sync.Pool{
        New: func() interface{} {
            return 0
        },
    }

    // 放入数据
    pool.Put(1)

    // 取出数据,用完对象记得要 Put 回去
    fmt.Println(pool.Get().(int)) // 1
    // 当 pool 中没对象时,会通过 New 来生成并获取
    fmt.Println(pool.Get().(int)) // 0
}
复制代码

sync.Pool 起到了临时对象池的作用,避免频繁实例化临时对象,减少 GC 压力。为什么说是临时呢?因为每当 GC 时,sync.Pool 中的对象也会释放内存,这也就不适合用来做连接池了,真的就只能起到临时对象池的作用。例子如下

func main() {
    pool := sync.Pool{
        New: func() interface{} {
            return 0
        },
    }

    pool.Put(1)
    pool.Put(1)

    runtime.GC()
    runtime.GC()

    fmt.Println(pool.Get().(int)) // 0
}
复制代码

为什么 GC 了两次

首先我们来了解下,为什么上面的例子需要 GC 两次,这就需要我们来看 sync.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{}
}
复制代码

我们可以看到 localvictim,可以先暂时理解为它们存储的是临时对象

func init() {
    runtime_registerPoolCleanup(poolCleanup)
}

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
}
复制代码

poolCleanup 函数会在每次 GC 时被调用,可以看到函数内部会将 victim 清空,local 拷贝给 victimlocal 的值实际上并没有被清空 (这种方式称为 Victim Cache,是 CPU 硬件处理缓存的一种技术),这也是为什么需要 GC 两次才会彻底释放掉之前创建的临时对象。至于官方这么实现也是为了尽可能避免 GC 后冷启动导致性能抖动

noCopy

我们看到 Pool 结构体中有 noCopy,它是直接嵌套在 struct 中的,如果使用了值拷贝,当使用 go vet 做静态语法分析时会报错 (Go 并没有直接避免值拷贝的方法,所以官方通过静态检查的方式来避免)

func main() {
    pool := sync.Pool{
        New: func() interface{} {
            return 0
        },
    }
  
    ValueCopy(pool) // copies lock value
    ReferenceCopy(&pool) 
}

func ValueCopy(pool sync.Pool) {

}

func ReferenceCopy(pool *sync.Pool) {

}
复制代码

那么为什么需要 noCopy 呢?单纯的就是为了限制 struct 类型变量进行值拷贝,原因是假如 struct 类型变量中的成员变量有 unsafe.Pointer ,锁等,开发者没注意到这个,可能会导致后续编程出现误操作

poolLocal

接下来就是 locallocal 实际上是一个 [P]poolLocal 数组,数组的长度等于调度器中 P 的数量,我们通过一个图来了解下整个结构

  • localvictim 是指向缓冲池的指针
  • private 是私有数据,只有当前的 P 能操作
  • shared 是共享数据,是一个双向链表,只有当前的 P 能 pushHead/popHead,任意 P 能 popTail

我们来看下这个双向链表 poolChain,代码如下

type poolChain struct {
    head *poolChainElt
    tail *poolChainElt
}

type poolChainElt struct {
    poolDequeue
    next, prev *poolChainElt
}

type poolDequeue struct {
    headTail uint64
    vals []eface
}
复制代码

poolChainElt 是双向链表的节点,而 poolDequeue 则是 lock free 的 Ring Buffer,是一种无锁数据结构,这个后面出一篇文章来讲

为什么官方不直接使用 poolDequeue,而是采用双向链表和 poolDequeue,主要原因是 poolDequeue 是固定大小的,所以使用双向链表来辅助扩容

Put

func (p *Pool) Put(x interface{}) {
    if x == nil {
        return
    }
    l, _ := p.pin()
    if l.private == nil {
        l.private = x
        x = nil
    }
    if x != nil {
        l.shared.pushHead(x)
    }
    runtime_procUnpin()
}
复制代码

Put 的代码相对简单,首先是通过 p.pin() 将当前 goroutine 和 P 绑定,禁止被抢占,返回当前 P 绑定的 poolLocalInternal 和 P 的 id。如果 private 为 nil 则插入,否则插入到 shared

Get

func (p *Pool) Get() interface{} {
    l, pid := p.pin()
    x := l.private
    l.private = nil
    if x == nil {
        x, _ = l.shared.popHead()
        if x == nil {
            x = p.getSlow(pid)
        }
    }
    runtime_procUnpin()
    if x == nil && p.New != nil {
        x = p.New()
    }
    return x
}
复制代码

Get 首先会从与当前 P 绑定的 poolLocalInternalprivate 读取,如果为 nil,则去 shared 读取,如果为 nil,则通过 p.getSlow(pid) 去其它 poolLocalInternalshared 读取,如果为 nil,则通过 New 函数返回一个新的对象

pad

最后我们来简单讲下 pad,我们看到 poolLocal 有一个 pad 字段,它主要用于防止 false sharing (伪共享),这个到时候会出一篇文章来着重讲解。现在只需要知道,CPU 读取 1Bytes 数据也会把临近的 63Bytes 读取到 Cache Line 中,而 pad 起到占位作用,用于 Cache Line 对齐 (缓存行对齐),避免同一个 Cache Line 加载到多个 poolLocalInternal

在现代 CPU 中,CPU Cache 会划分为多个 Cache Line,可以简单的理解为是 CPU Cache 的最小缓存单元,比如 x86_64 体系下 Cache Line 为 64Bytes

文章分类
后端
文章标签