Golang sync.Pool 和 伪共享false share

参考
go语言的官方包sync.Pool的实现原理和适用场景
深入Golang之sync.Pool详解
伪共享(false sharing),并发编程无声的性能杀手

一、简述

众所周知,go是自动垃圾回收的(garbage collector),这大大减少了程序编程负担。但gc是一把双刃剑,带来了编程的方便但同时也增加了运行时开销,使用不当甚至会严重影响程序的性能。因此性能要求高的场景不能任意产生太多的垃圾(有gc但又不能完全依赖它挺恶心的),如何解决呢?那就是要重用对象了,我们可以简单的使用一个chan把这些可重用的对象缓存起来,但如果很多goroutine竞争一个chan性能肯定是问题.....由于golang团队认识到这个问题普遍存在,为了避免大家重造车轮,因此官方统一出了一个包Pool。但为什么放到sync包里面也是有的迷惑的,先不讨论这个问题。

先来看看如何使用一个pool:

package main
 
import(
    "fmt"
    "sync"
)
 
func main() {
    p := &sync.Pool{
        New: func() interface{} {
            return 0
        },
    }
 
    a := p.Get().(int)
    p.Put(1)
    b := p.Get().(int)
    fmt.Println(a, b)
}

上面创建了一个缓存int对象的一个pool,先从池获取一个对象然后放进去一个对象再取出一个对象,程序的输出是0 1。创建的时候可以指定一个New函数,获取对象的时候如何在池里面找不到缓存的对象将会使用指定的new函数创建一个返回,如果没有new函数则返回nil。用法是不是很简单,我们这里就不多说,下面来说说我们关心的问题:

1、缓存对象的数量和期限

上面我们可以看到pool创建的时候是不能指定大小的,所有sync.Pool的缓存对象数量是没有限制的(只受限于内存),因此使用sync.pool是没办法做到控制缓存对象数量的个数的。另外sync.pool缓存对象的期限是很诡异的,先看一下src/pkg/sync/pool.go里面的一段实现代码:

func init() {
    runtime_registerPoolCleanup(poolCleanup)
}

可以看到pool包在init的时候注册了一个poolCleanup函数,它会清除所有的pool里面的所有缓存的对象,该函数注册进去之后会在每次gc之前都会调用,因此sync.Pool缓存的期限只是两次gc之间这段时间。例如我们把上面的例子改成下面这样之后,输出的结果将是0 0。正因gc的时候会清掉缓存对象,也不用担心pool会无限增大的问题。

    a := p.Get().(int)
    p.Put(1)
    runtime.GC()
    b := p.Get().(int)
    fmt.Println(a, b)

这是很多人错误理解的地方,正因为这样,我们是不可以使用sync.Pool去实现一个socket连接池的。

2、缓存对象的开销

如何在多个goroutine之间使用同一个pool做到高效呢?官方的做法就是尽量减少竞争,因为sync.pool为每个P(对应cpu,不了解的童鞋可以去看看golang的调度模型介绍)都分配了一个子池,如下图:


Golang sync.Pool 和 伪共享false share_第1张图片
image.png

当执行一个pool的get或者put操作的时候都会先把当前的goroutine固定到某个P的子池上面,然后再对该子池进行操作。每个子池里面有一个私有对象和共享列表对象,私有对象是只有对应的P能够访问,因为一个P同一时间只能执行一个goroutine,因此对私有对象存取操作是不需要加锁的。共享列表是和其他P分享的,因此操作共享列表是需要加锁的。

获取对象过程是:

1)固定到某个P,尝试从私有对象获取,如果私有对象非空则返回该对象,并把私有对象置空;

2)如果私有对象是空的时候,就去当前子池的共享列表获取(需要加锁);

3)如果当前子池的共享列表也是空的,那么就尝试去其他P的子池的共享列表偷取一个(需要加锁);

4)如果其他子池都是空的,最后就用用户指定的New函数产生一个新的对象返回。

可以看到一次get操作最少0次加锁,最大N(N等于MAXPROCS)次加锁。

归还对象的过程:

1)固定到某个P,如果私有对象为空则放到私有对象;

2)否则加入到该P子池的共享列表中(需要加锁)。

可以看到一次put操作最少0次加锁,最多1次加锁。

由于goroutine具体会分配到那个P执行是golang的协程调度系统决定的,因此在MAXPROCS>1的情况下,多goroutine用同一个sync.Pool的话,各个P的子池之间缓存的对象是否平衡以及开销如何是没办法准确衡量的。但如果goroutine数目和缓存的对象数目远远大于MAXPROCS的话,概率上说应该是相对平衡的。

总的来说,sync.Pool的定位不是做类似连接池的东西,它的用途仅仅是增加对象重用的几率,减少gc的负担,而开销方面也不是很便宜的。

二、源码

sync.Pool首先声明了两个结构体

// Local per-P Pool appendix.
type poolLocalInternal struct {
    private interface{}   // Can be used only by the respective P.
    shared  []interface{} // Can be used by any P.
    Mutex                 // Protects shared.
}

type poolLocal struct {
    poolLocalInternal

    // Prevents false sharing on widespread platforms with
    // 128 mod (cache line size) = 0 .
    pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

为了使得在多个goroutine中高效的使用goroutine,sync.Pool为每个P(对应CPU)都分配一个本地池,当执行Get或者Put操作的时候,会先将goroutine和某个P的子池关联,再对该子池进行操作。 每个P的子池分为私有对象和共享列表对象,私有对象只能被特定的P访问,共享列表对象可以被任何P访问。因为同一时刻一个P只能执行一个goroutine,所以无需加锁,但是对共享列表对象进行操作时,因为可能有多个goroutine同时操作,所以需要加锁。

值得注意的是poolLocal结构体中有个pad成员,目的是为了防止false sharing。cache使用中常见的一个问题是false sharing。当不同的线程同时读写同一cache line上不同数据时就可能发生false sharing。false sharing会导致多核处理器上严重的系统性能下降。具体的可以参考伪共享(false sharing),并发编程无声的性能杀手

类型sync.Pool有两个公开的方法,一个是Get,一个是Put, 我们先来看一下Put的源码。

// Put adds x to the pool.
func (p *Pool) Put(x interface{}) {
    if x == nil {
        return
    }
    if race.Enabled {
        if fastrand()%4 == 0 {
            // Randomly drop x on floor.
            return
        }
        race.ReleaseMerge(poolRaceAddr(x))
        race.Disable()
    }
    l := p.pin()
    if l.private == nil {
        l.private = x
        x = nil
    }
    runtime_procUnpin()
    if x != nil {
        l.Lock()
        l.shared = append(l.shared, x)
        l.Unlock()
    }
    if race.Enabled {
        race.Enable()
    }
}

1.如果放入的值为空,直接return.
2.检查当前goroutine的是否设置对象池私有值,如果没有则将x赋值给其私有成员,并将x设置为nil。
3.如果当前goroutine私有值已经被设置,那么将该值追加到共享列表。

func (p *Pool) Get() interface{} {
    if race.Enabled {
        race.Disable()
    }
    l := p.pin()
    x := l.private
    l.private = nil
    runtime_procUnpin()
    if x == nil {
        l.Lock()
        last := len(l.shared) - 1
        if last >= 0 {
            x = l.shared[last]
            l.shared = l.shared[:last]
        }
        l.Unlock()
        if x == nil {
            x = p.getSlow()
        }
    }
    if race.Enabled {
        race.Enable()
        if x != nil {
            race.Acquire(poolRaceAddr(x))
        }
    }
    if x == nil && p.New != nil {
        x = p.New()
    }
    return x
}

1.尝试从本地P对应的那个本地池中获取一个对象值, 并从本地池冲删除该值。
2.如果获取失败,那么从共享池中获取, 并从共享队列中删除该值。
3.如果获取失败,那么从其他P的共享池中偷一个过来,并删除共享池中的该值(p.getSlow())。
4.如果仍然失败,那么直接通过New()分配一个返回值,注意这个分配的值不会被放入池中。New()返回用户注册的New函数的值,如果用户未注册New,那么返回nil。

三、总结

通过以上的解读,我们可以看到,Get方法并不会对获取到的对象值做任何的保证,因为放入本地池中的值有可能会在任何时候被删除,但是不通知调用者。放入共享池中的值有可能被其他的goroutine偷走。 所以对象池比较适合用来存储一些临时切状态无关的数据,但是不适合用来存储数据库连接的实例,因为存入对象池重的值有可能会在垃圾回收时被删除掉,这违反了数据库连接池建立的初衷。

根据上面的说法,Golang的对象池严格意义上来说是一个临时的对象池,适用于储存一些会在goroutine间分享的临时对象。主要作用是减少GC,提高性能。在Golang中最常见的使用场景是fmt包中的输出缓冲区。

在Golang中如果要实现连接池的效果,可以用container/list来实现,开源界也有一些现成的实现,比如go-commons-pool,具体的读者可以去自行了解。

你可能感兴趣的:(Golang sync.Pool 和 伪共享false share)