众所周知,Go实现了自动垃圾回收,这就意味着:当我们在申请内存时,不必关心如何以及何时释放内存,这些都是由Go语言内部实现的。注:我们关心的是堆内存,因为栈内存会随着函数调用的返回自动释放。
自动垃圾回收极大地降低了我们写程序时的心智负担,但是,这是否就意味着我们能够随心所欲的申请大量内存呢?理论上当然可以,但实际写代码时强烈不推荐这种做法,因为大量的临时堆内存会给GC线程的造成负担。
此时,小明同学就问:有没有办法能缓解海量临时对象的分配问题呢?
当然是有的,内存复用就是一个典型方案,而内存池就是该方案的一个实例,Go语言官方提供一种内存池的实现方案——sync.Pool。
首先我们来看sync.Pool的使用方式:
1 | func main() { |
借助上面这段简单代码,我们验证了sync.Pool的内存复用。那么sync.Pool又是如何实现内存复用的呢?让我们来深入Go源码看一看。
sync.Pool的源码位于$GOROOT/src/sync/pool.go,其结构体定义如下:
1 | type Pool struct { |
- 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 | // Put adds x to the pool. |
p.pin用于获取P的对象池,Put优先将内存对象存储到内存池私有坑位,如果私有坑位已经被占,则将其存储到公有坑位
注意:如果内存对象被存储至公有坑位,则需要加锁。
接着我们再来看Get操作:
1 | func (p *Pool) Get() interface{} { |
- 如果本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 | func init() { |
runtime_xxx函数都可以对应到$GOROOT/src/runtime包下的xxx函数,我们找到对应的函数定义:
1 | //go:linkname sync_runtime_registerPoolCleanup sync.runtime_registerPoolCleanup |
因此,我们只需要定位clearpools的调用时机即可:
1 | // gcStart transitions the GC from _GCoff to _GCmark (if |
我们发现每当GC开始时,都会清理sync.Pool内存对象池,这就意味着sync.Pool缓存的临时对象都活不过一个GC周期。如果我们的程序在疯狂分配临时对象,这就会加速GC的执行频率,而GC开始时又会释放sync.Pool内存池,这简直就是一个死循环。
所以小明啊,最佳的实践是什么呢?当然是优化代码逻辑咯,尽量减少内存分配次数。具体的代码优化可以借助pprof实现。