golang sync.pool对象复用 并发原理 缓存池
在go http每一次go serve(l)都会构建Request数据结构。在大量数据请求或高并发的场景中,频繁创建销毁对象,会导致GC压力。解决办法之一就是使用对象复用技术。在http协议层之下,使用对象复用技术创建Request数据结构。在http协议层之上,可以使用对象复用技术创建(w,*r,ctx)数据结构。这样即可以回快TCP层读包之后的解析速度,也可也加快请求处理的速度。
先上一个测试:
//测试平台 i5 3.8GHz 4核
bPool := sync.Pool{
New: func() interface{} {
b := make([]byte,1024)
return &b
},
}
t1 := time.Now().Unix()
count := 1000000000
for i:=0;i<count;i++{
buf := make([]byte,1024)
_ = buf
}
t2 := time.Now().Unix()
for i:=0;i<count;i++{
buf := bPool.Get().(*[]byte)
_ = buf
//clear buf
bPool.Put(buf)
}
t3 := time.Now().Unix()
fmt.Println("new:%d s",t2-t1)
fmt.Println("pool:%d s",t3-t1)
结论是这样的:
new:%d s 21
pool:%d s 396
貌似使用池化,性能弱爆了???这似乎与net/http使用sync.pool池化Request来优化性能的选择相违背。这同时也说明了一个问题,好的东西,如果滥用反而造成了性能成倍的下降。在看过pool原理之后,结合实例,将给出正确的使用方法,并给出预期的效果。
基本用法
sync.Pool是一个协程安全的临时对象池。数据结构如下:
type Pool struct {
noCopy noCopy // type noCopy struct{}
local unsafe.Pointer
localSize uintptr
New func() interface{}
}
type poolLocal struct {
private interface{} // Can be used only by the respective P.
shared []interface{} // Can be used by any P.
Mutex // Protects shared.
pad [128]byte // Prevents false sharing.
}
local 成员的真实类型是一个 poolLocal 数组,localSize 是数组长度。这涉及到Pool实现,pool为每个P分配了一个对象,P数量设置为runtime.GOMAXPROCS(0)。在并发读写时,goroutine绑定的P有对象,先用自己的,没有去偷其它P的。go语言将数据分散在了各个真正运行的P中,降低了锁竞争,提高了并发能力。
不要习惯性地误认为New是一个关键字,这里的New是Pool的一个字段,也是一个闭包名称。其API:
var pool = &sync.Pool{New:func()interface{}{return NewObject()}}
//池对象最好初始化为全局唯一
pool.Put(x interface{})
pool.Get() interface{}
如果不指定New字段,对象池为空时会返回nil,而不是一个新构建的对象。Get()到的对象是随机的。
pool := sync.Pool{New: func() interface{} {
return "empty string"
}}
s := "Hello World"
pool.Put(s)
fmt.Println(pool.Get())
fmt.Println(pool.Get())
一个缓存池的例子
type BufPool struct {
pool sync.Pool
spliter string
}
func NewBufPool() *BufPool {
return &BufPool{
pool:sync.Pool{
New: func() interface{} {
return &bytes.Buffer{}
},
},
spliter:" ",
}
}
func (this *BufPool)JoinString(strs ...string) (res string,err error) {
if len(strs) == 0 {
return
}
buf := this.pool.Get().(*bytes.Buffer)
if _,err := buf.WriteString(strs[0]);err!=nil{
return "",err
}
for _,str := range strs[1:] {
if _,err := buf.WriteString(this.spliter);err!= nil {
return "",err
}
if _,err := buf.WriteString(str);err!= nil {
return "",err
}
}
res = buf.String()
buf.Reset()
this.pool.Put(buf)
return
}
原生sync.Pool的问题是,Pool中的对象会被GC清理掉,这使得sync.Pool只适合做简单地对象池,不适合作连接池。
为何不适合作连接池
对象的数量和期限
pool创建时不能指定大小,没有数量限制。pool中对象会被GC清掉,只存在于两次GC之间。实现是pool的init方法注册了一个poolCleanup()函数,这个方法在GC之前执行,清空pool中的所有缓存对象。
池对象Get/Put开销
为使多协程使用同一个POOL。最基本的想法就是每个协程,加锁去操作共享的POOL,这显然是低效的。而进一步改进,类似于ConcurrentHashMap(JDK7)的分Segment,提高其并发性可以一定程度性缓解。
注意到pool中的对象是无差异性的,加锁或者分段加锁都不是较好的做法。go的做法是为每一个绑定协程的P都分配一个子池。每个子池又分为私有池和共享列表。共享列表是分别存放在各个P之上的共享区域,而不是各个P共享的一块内存。协程拿自己P里的子池对象不需要加锁,拿共享列表中的就需要加锁了。
Get对象过程:
- goroutine固定到某一个P后,先从当前子池私区拿。并置私有对象为空。
- 拿不到再从当前子池共享列表拿,需要加锁。
- 仍拿不到从其它子池共享列表拿,需要加锁。
- 仍拿不到,sync.pool.New闭包非空,则New一个对象。
- 所以最坏的情况下遍历其它P才拿到对象,最大值为MACPROCS。
Put过程:
- 固定P中私有对象为空,则放到私有对象。
- 否则放入当前子池的共享列表,加锁实现。
- 开销为最多一次加锁。
如何解决Get最坏情况遍历所有P才获取得对象呢:
- 能够设置加锁期间遍历其它P的最大次数,遍历不到就直接创建,减少加锁占用pool的时间。
- 使各子池共享列表中的对象数量尽量平均化,从而避免最坏的情况发生。
方法1止前sync.pool并没有这样的设置。方法2由于goroutine被分配到哪个P由调度器调度不可控,无法确保其平衡。
由于不可控的GC导致生命周期过短,且池大小不可控,因而不适合作连接池。仅适用于增加对象重用机率,减少GC负担。2
用实验回答篇头的问题
实验1
var bytePool = sync.Pool{
New: func() interface{} {
b := make([]byte, 1024)
return &b
},
}
func main() {
runtime.GOMAXPROCS(16)
a := time.Now().Unix()
count := 100000000
// 不使用对象池
for i := 0; i < 1; i++ {
for j:=0;j<count;j++{
obj := make([]byte, 1024)
_ = obj
}
}
b := time.Now().Unix()
c := time.Now().Unix()
// 使用对象池
for i := 0; i < 1; i++ {
go func() {
for j := 0; j < count; j++ {
obj := bytePool.Get().(*[]byte)
_ = obj
bytePool.Put(obj)
}
}()
}
d := time.Now().Unix()
fmt.Println("without pool ", b-a, "s")
fmt.Println("with pool ", d-c, "s")
}
执行结果:
without pool 2 s
with pool 0 s
单线程情况下,遍历其它无元素的P,长时间加锁性能低下。启用协程改善。
实验2
var bytePool = sync.Pool{
New: func() interface{} {
b := make([]byte, 1024)
return &b
},
}
func main() {
runtime.GOMAXPROCS(16)
a := time.Now().Unix()
count := 100000000
// 不使用对象池
for i := 0; i < 1000; i++ {
for j:=0;j<count;j++{
obj := make([]byte, 1024)
_ = obj
}
}
b := time.Now().Unix()
c := time.Now().Unix()
// 使用对象池
for i := 0; i < 1000; i++ {
go func() {
for j := 0; j < count; j++ {
obj := bytePool.Get().(*[]byte)
_ = obj
bytePool.Put(obj)
}
}()
}
d := time.Now().Unix()
fmt.Println("without pool ", b-a, "s")
fmt.Println("with pool ", d-c, "s")
}
结果:
without pool 2000 s
with pool 2 s
测试场景在goroutines远大于GOMAXPROCS情况下,与非池化性能差异巨大。
实验3
var bytePool1 = sync.Pool{
New: func() interface{} {
b := make([]byte, 1024)
return &b
},
}
var bytePool2 = sync.Pool{
New: func() interface{} {
b := make([]byte, 1024)
return &b
},
}
func main() {
runtime.GOMAXPROCS(4)
a := time.Now().Unix()
count := 1000000000
goCount := 1000
for i := 0; i < goCount; i++ {
go func() {
for j := 0; j < count; j++ {
obj := bytePool1.Get().(*[]byte)
_ = obj
bytePool1.Put(obj)
}
}()
}
b := time.Now().Unix()
for i := 0; i < 1000; i++ {
go func() {
for j := 0; j < count; j++ {
bNew := make([]byte, 1024)
bytePool2.Put(&bNew)
}
}()
}
c := time.Now().Unix()
for i := 0; i < goCount; i++ {
go func() {
for j := 0; j < count; j++ {
obj := bytePool2.Get().(*[]byte)
_ = obj
bytePool2.Put(obj)
}
}()
}
d := time.Now().Unix()
fmt.Println("without pool ", b-a, "s")
fmt.Println("with pool ", d-c, "s")
}
测试结果
without pool 6 s
with pool 0 s
可以看到同样使用*sync.pool,较大池大小的命中率较高,性能远高于空池。
结论:pool在一定的使用条件下提高并发性能,条件1是协程数远大于GOMAXPROCS,条件2是池中对象远大于GOMAXPROCS。归结成一个原因就是使对象在各个P中均匀分布。
关于何时回收Pool
池pool和缓存cache的区别。池的意思是,池内对象是可以互换的,不关心具体值,甚至不需要区分是新建的还是从池中拿出的。缓存指的是KV映射,缓存里的值互不相同,清除机制更为复杂。缓存清除算法如LRU、LIRS缓存算法。
池空间回收的几种方式。一些是GC前回收,一些是基于时钟或弱引用回收。最终确定在GC时回收Pool内对象,即不回避GC。用java的GC解释弱引用。GC的四种引用:强引用、弱引用、软引用、虚引用。虚引用即没有引用,弱引用GC但有空间则保留,软引用GC即清除。ThreadLocal的值为弱引用的例子。
Pool其它场景
regexp
fmt包做字串拼接,从sync.pool拿[]byte对象。避免频繁构建再GC效率高很多。
var ppFree = sync.Pool{
New: func() interface{} { return new(pp) },
}