go sync.pool

众所周知,Go实现了自动垃圾回收,这就意味着:当我们在申请内存时,不必关心如何以及何时释放内存,这些都是由Go语言内部实现的。注:我们关心的是堆内存,因为栈内存会随着函数调用的返回自动释放。

自动垃圾回收极大地降低了我们写程序时的心智负担,但是,这是否就意味着我们能够随心所欲的申请大量内存呢?理论上当然可以,但实际写代码时强烈不推荐这种做法,因为大量的临时堆内存会给GC线程的造成负担。

此时,小明同学就问:有没有办法能缓解海量临时对象的分配问题呢?

当然是有的,内存复用就是一个典型方案,而内存池就是该方案的一个实例,Go语言官方提供一种内存池的实现方案——sync.Pool。

首先我们来看sync.Pool的使用方式:

1
2
3
4
5
6
7
8
9
10
11
func main() {
pool := sync.Pool{
New: func() interface{} {
return "Hello"
},
}
old := pool.Get()
pool.Put(old.(string) + " World")
new := pool.Get()
fmt.Println(new) // Hello World
}

借助上面这段简单代码,我们验证了sync.Pool的内存复用。那么sync.Pool又是如何实现内存复用的呢?让我们来深入Go源码看一看。

sync.Pool的源码位于$GOROOT/src/sync/pool.go,其结构体定义如下:

1
2
3
4
5
6
7
8
9
10
11
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

// 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{}
}
  • noCopy字段:go vet静态扫描代码时提示对象拷贝,不影响编译和运行
  • local:对象池数组,实际上是[P]poolLocal,而poolLocal则为每个P的本地内存池,P的本地内存池有两个对象:
    • private interface{}:一个私有坑位
    • shared []interface{}:一组公有坑位
  • localSize:local数组的大小,一般等于P的数量(在调用GOMAXPROCS时会出现短暂不一致)
  • New:当对象池为空时,就调用New方法创建一个临时对象

这里需要注意的是:sync.Pool内存池并非P结构体的一个字段,而是sync.Pool自己维护了一个数组,取P的id作为数组下标来获取内存池对象。

了解了sync.Pool的数据结构之后,我们再来看其操作原理,sync.Pool的操作有两个:Get和Put,因为Put简单,我们先来看Put:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Put adds x to the pool.
func (p *Pool) Put(x interface{}) {
if x == nil {
return
}
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()
}
}

p.pin用于获取P的对象池,Put优先将内存对象存储到内存池私有坑位,如果私有坑位已经被占,则将其存储到公有坑位

注意:如果内存对象被存储至公有坑位,则需要加锁。

接着我们再来看Get操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (p *Pool) Get() interface{} {
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 x == nil && p.New != nil {
x = p.New()
}
return x
}
  • 如果本P的私有坑位有对象,则直接返回
  • 如果本P私有坑位没有对象,则从本P的公有坑位中获取一个对象返回
  • 如果本P的公有坑位也没有对象,则依次遍历其他P的公有坑位,取走一个对象返回
  • 如果所有P的公有坑位都没有对象,并且定义New函数,则调用New函数创建一个对象
  • 否则返回nil

注意:每当遍历一个P的公有坑位时,都需要加锁,因此最多加锁N次,最少0次,其中N为P的数目

了解了以上原理,我们就能够开开心心的使用sync.Pool了。此时,小明同学又问了,我明明已经使用了sync.Pool了,为什么GC压力还非常大?

这就涉及到sync.Pool本身的内存回收了:sync.Pool缓存临时对象并非是永久保存,它保活的时间作用域其实也非常短:我们发现sync/pool.go中还定义了poolCleanup函数用于内存池的清理,我们再看其调用时机:

1
2
3
func init() {
runtime_registerPoolCleanup(poolCleanup)
}

runtime_xxx函数都可以对应到$GOROOT/src/runtime包下的xxx函数,我们找到对应的函数定义:

1
2
3
4
5
6
7
8
9
10
11
//go:linkname sync_runtime_registerPoolCleanup sync.runtime_registerPoolCleanup
func sync_runtime_registerPoolCleanup(f func()) {
poolcleanup = f
}
func clearpools() {
// clear sync.Pools
if poolcleanup != nil {
poolcleanup()
}
......
}

因此,我们只需要定位clearpools的调用时机即可:

1
2
3
4
5
6
7
8
9
10
11
// gcStart transitions the GC from _GCoff to _GCmark (if
// !mode.stwMark) or _GCmarktermination (if mode.stwMark) by
// performing sweep termination and GC initialization.
//
// This may return without performing this transition in some cases,
// such as when called on a system stack or with locks held.
func gcStart(mode gcMode, trigger gcTrigger) {
......
clearpools()
......
}

我们发现每当GC开始时,都会清理sync.Pool内存对象池,这就意味着sync.Pool缓存的临时对象都活不过一个GC周期。如果我们的程序在疯狂分配临时对象,这就会加速GC的执行频率,而GC开始时又会释放sync.Pool内存池,这简直就是一个死循环。

所以小明啊,最佳的实践是什么呢?当然是优化代码逻辑咯,尽量减少内存分配次数。具体的代码优化可以借助pprof实现。

坚持原创技术分享,您的支持将鼓励我继续创作!