golang sync.Pool 分析

在 echo 官网的手册上可以看到 echo 框架的路由性能主要依赖于 radix tree 和 sync.pool 对内存的复用。

Echo 的路由基于 radix tree ,它让路由的查询非常快。路由使用了 sync pool 来重复利用内存并且几乎达到了零内存占用。

对于高并发的应用来说,大量的 goroutines 的内存申请确实是个负担。想知道为什么需要用 sync.pool,了解代码中为什么使用了这个模块会提高性能,以及有哪些注意点,需要对 sync.pool 有一定的熟悉。先来看看它是如何实现的。

基于 Go 1.12 版本

主要结构

type Pool struct {
    noCopy noCopy // noCopy 是一个空结构,用来防止 pool 在第一次使用后被复制

    local     unsafe.Pointer // per-P pool, 实际类型为 [P]poolLocal
    localSize uintptr        // local 的 size

    // New 在 pool 中没有获取到,调用该方法生成一个变量
    New func() interface{}
}

// 具体存储结构
type poolLocalInternal struct {
    private interface{}   // 只能由自己的 P 使用
    shared  []interface{} // 可以被任何的 P 使用
    Mutex                 // 保护 shared 线程安全
}

type poolLocal struct {
    poolLocalInternal

    // 避免缓存 false sharing,使不同的线程操纵不同的缓存行,多核的情况下提升效率。
    pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

var (
    allPoolsMu Mutex
    allPools   []*Pool     // 池列表 
)

关于缓存 false sharing 的文章可以参考我的译文

主体流程

看完整个结构后,我们先了解一下整个流程。

Put 方法

Put 方法的整个流程比较简单,主要是将用完的对象放回池中,看一下注释就可以理解。

func (p *Pool) Put(x interface{}) {
    ...
    // 获取当前 P 的 pool
    l := p.pin()
    // 私有属性为空 放入
    if l.private == nil {
        l.private = x
        x = nil
    }
    runtime_procUnpin()
    // 私有属性放入失败 放入 shared 池
    if x != nil {
        l.Lock()
        l.shared = append(l.shared, x)
        l.Unlock()
    }
    ...
}

Get 方法

我们找到对应的代码如下,

func (p *Pool) Get() interface{} {
    ...
    // 获取当前 P 的 poolLocal
    l := p.pin()
    // 先从 private 读取
    x := l.private
    l.private = nil
    runtime_procUnpin()
    // private 没有
    if x == nil {
        l.Lock()
        // 从当前 P 的 shared 末尾取一个
        last := len(l.shared) - 1
        if last >= 0 {
            x = l.shared[last]
            l.shared = l.shared[:last]
        }
        l.Unlock()
        // 还没有取到 则去其他 P 的 shared 取
        if x == nil {
            x = p.getSlow()
        }
    }
    ...
    // 最后还没取到 调用 NEW 方法生成一个
    if x == nil && p.New != nil {
        x = p.New()
    }
    return x
}

上面有一个 p.getSlow() 操作是说从其他的 P 中偷取一个,比较有意思,在 Go 的GMP模型中也存在这个偷的概念,基本和这个类似。我们来看看

func (p *Pool) getSlow() (x interface{}) {
    ...
    // 尝试从其他 P 中窃取一个元素。
    pid := runtime_procPin()
    runtime_procUnpin()
    for i := 0; i < int(size); i++ {
        // 获取其他 P 的 poolLocal
        l := indexLocal(local, (pid+i+1)%int(size))
        l.Lock()
        last := len(l.shared) - 1
        if last >= 0 {
            x = l.shared[last]
            l.shared = l.shared[:last]
            l.Unlock()
            break
        }
        l.Unlock()
    }
    return x
}

存活周期以及内存回收

在倒入 pool 包时执行的 init 函数会向 GC 注册 poolCleanup 函数,也就是在 GC 之前会运行该函数。

func init() {
    runtime_registerPoolCleanup(poolCleanup)
}

我们来看看 poolCleanup,该函数主要是将所有池的变量解除引用,为下一步的 GC 作准备。

func poolCleanup() {
    // 在 GC 时会调用此函数。
    // 它不能分配,也不应该调用任何运行时函数。
    // 防御性地将所有东西归零,原因有两个:
    // 1. 防止整个池的错误保留。
    // 2. 如果GC发生时goroutine与Put / Get中的l.shared一起使用,它将保留整个Pool。因此下一周期内存消耗将增加一倍。
    for i, p := range allPools {
        // 将所有池对象接触引用 等待 GC 回收
        allPools[i] = nil
        for i := 0; i < int(p.localSize); i++ {
            l := indexLocal(p.local, i)
            l.private = nil
            for j := range l.shared {
                l.shared[j] = nil
            }
            l.shared = nil
        }
        p.local = nil
        p.localSize = 0
    }
    allPools = []*Pool{}
}

整个流程图


image.png

echo 中的用途

在 echo 中主要用来存储 context,因为大量的 foroutines 不断申请 context 的内存,会给 GC 带来大的压力影响性能。所以 echo 采用 sync.pool 来优化。

// New creates an instance of Echo.
func New() (e *Echo) {
    ...
    e.pool.New = func() interface{} {
        return e.NewContext(nil, nil)
    }
    e.router = NewRouter(e)
    return
}

// NewContext returns a Context instance.
func (e *Echo) NewContext(r *http.Request, w http.ResponseWriter) Context {
    return &context{
        request:  r,
        response: NewResponse(w, e),
        store:    make(Map),
        echo:     e,
        pvalues:  make([]string, *e.maxParam),
        handler:  NotFoundHandler,
    }
}

// AcquireContext returns an empty `Context` instance from the pool.
// You must return the context by calling `ReleaseContext()`.
func (e *Echo) AcquireContext() Context {
    return e.pool.Get().(Context)
}

// ReleaseContext returns the `Context` instance back to the pool.
// You must call it after `AcquireContext()`.
func (e *Echo) ReleaseContext(c Context) {
    e.pool.Put(c)
}

看完定义,我们再看看,echo 里的使用。也就是说我们通过 pool 这种形式避免了在并发大的情况下,造成的内存申请,和 GC 的压力。

// http 请求处理方法
func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // 从池里获取一个 context 对象
    c := e.pool.Get().(*context)
    // 重置对象
    c.Reset(r, w)
    ...
    // 用完后把 context 
    e.pool.Put(c)
}

基准测试

既然说 pool 的优势这么大,我们可以用基准测试来看一下 使用池和不实用池的区别。这里我们声明了一个非常简单的结构 S

package main

import (
    "sync"
    "testing"
)

type S struct {
    num int
}

func BenchmarkWithPool(b *testing.B) {
    var s *S
    var pool = sync.Pool{
        New: func() interface{} { return new(S) },
    }
    for i := 0; i < b.N; i++ {
        for j := 0; j < 10000; j++ {
            s = pool.Get().(*S)
            s.num = 1
            s.num++
            pool.Put(s)
        }
    }
}

func BenchmarkWithNoPool(b *testing.B) {
    var s *S
    for i := 0; i < b.N; i++ {
        for j := 0; j < 10000; j++ {
            s = &S{num: 1}
            s.num++
        }
    }
}

运行基准测试,

$ go test -bench=. -benchmem
goos: darwin
goarch: amd64
                                     
BenchmarkWithPool-4                10000            253269 ns/op               0 B/op          0 allocs/op
BenchmarkWithNoPool-4              10000            175742 ns/op           80000 B/op      10000 allocs/op

可以看到每次分配的内存 0 B vs 80000 B,每次内存分配次数 0 vs 10000。因为每次测试,我们执行了10000次迭代,所以看到没使用池的内存单次分配是 8B(即 结构 S 占的内存),单次分配次数为 1次。但是在每次执行的时间上使用池比不使用池是要多的,比较使用池涉及到池的维护,也算是正常的。这样看来,在高并发的场景下,context 的复用率非常高,所带来的 GC 压力也更小,所以效率当然就高了。

参考文章

https://juejin.im/post/5d006254e51d45776031afe3

推荐阅读更多精彩内容