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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容