golang 内存池sync.pool使用及如何降低IO密集应用的GC频率
在go语言的世界里,内存池有两种
- 一种是官方的 sync.pool 临时对象池
- 另一种是利用 channel实现的自定义内存池
下面将首先介绍这两种内存池的特点
标准库sync.pool
用法很简单,如下所示:
|
|
|
|
使用 Get/Put
方法取出和放回 Interface对象
如果Get时池中没有对象,则调用New,新建对象并返回
- 这个标准库的实现,内部也是使用
锁来保证线程(协程)安全
,但是使用了更细粒度的锁
,类似 java 的 ConcurrentHashMap,这样就能减少竞争 - sync.pool 中空闲的对象会在,下一次GC时被清空
以上两点,就是 sync.pool 最重要的两个特征:细粒度锁
、pool中空闲对象在GC时仍然会被清空
Go gc 发生的三种情况:
- 自动GC:分配大于32k的内存时如果探测到堆上存活对象>memstats.gc_trigger(激发阈值)。这个32K是怎么来的?
- 主动GC:调用runtime.GC()
- 定时GC:如果两分钟没有进行GC,则进行一次
细粒度锁是很好的,但是GC时仍然会被回收会导致没必要的浪费
优化方向,比如提供一个可以自定义的回收策略,比如定时5分钟这样,下一次GC还是太频繁了
从下面的代码来看,用户也没有办法自己去覆写 poolCleanup 函数:runtime_registerPoolCleanup
由 runtime
实现,并且是私有防范
|
|
在 src/sync/pool.go
的代码的开头,一大段注释中写道,查看方法 go doc sync pool
|
|
意思应该是,持有 sync.pool 的对象不能是短命的对象
博客 七分钟读懂 Go 的临时对象池pool及其应用场景有不一样的理解
我们的内存池是一个全局的资源池,全局
这个东西的生命周期就是一个进程的开始到消亡,应该是最长生命周期对象
go 1.13 sync.Pool 的优化
- 对STW暂停时间做了优化, 避免大的 sync.Pool 严重影响STW时间
- GC时对 sync.Pool 进行回收,不会一次将池化对象全部回收,这就避免了 sync.Pool 释放对象和重建对象导致的性能尖刺
- 对调用性能的优化
具体见如何优化的 查看文章 Go 1.13中 sync.Pool 是如何优化的?
反正 go 1.13 以后,基本可以放心使用 sync.Pool ,除非已经严重影响到业务了再想办法自己写内存池
使用channel机制实现的pool
好几个使用 channel 实现的pool,大体如下
|
|
这段代码定义的pool的特点有:
- pool的大小固定,put多余的buf将会被丢弃(leaky)
- 只接受固定宽度的buf
这段代码用的还是挺多的
shadowsocks-go 的 leakbuf.go 会漏水的池子 -> 多余的、宽度不对的buff都会被丢弃
其实也是这样,也有这两个特点
- 唯一的不同是,shadowsocks-go 的 Put 会检查
[]byte
的长度是否正确 - 不正确则panic(这应该是他的实现决定的)
使用 sync.pool 会在 GC 时回收 pool 空闲的buff(pool中buf数量可能为0),使用这个 leakypool 则会回收过多(丢弃)的buff(pool中数量基本不会为0)
前面是回收到剩余0空闲,后面是回收到空闲数量 <=channel 的容量
- sync.pool 可能会面临 pool 中无空闲可用的情况,需要重新 make 而 leakypool 则不会有这个问题
- sync.pool 不要求
[]byte
固定容量,更加自由,leakpool 则只能复用固定长度的[]byte
(当然,改下源码就不再有这个问题) - sync.pool 有更细粒度的锁
以上就是这两个方案的区别
slice内部实现
slice 建立在 array 的基础上,首先讲 go 的 array
|
|
和很多语言一样,array 包括类型和长度;包裹相同类型的对象,但长度不同的数组是不同的类型,比如[3]int
和[4]int
是不一样的类型,没错,go的数组的[]
放在前面。var a [4]int
会将数组的所有元素初始化为0
与c语言不同,array变量代表整个数组,而非是指向数组第一个元素的指针。所以,赋值或者作为参数传递数组,都会copy整个数组(形参、实参的区别);为了避免copy,可以传递数组的指针
在这里提一下:b := [2]byte{ 0x01, 0x02}
这时初始化了一个长度为2的字节数组,而非byte的slice。没错,[]byte
是slice,[n]byte
是数组! b := []byte{ 0x01, 0x02}
就是初始化了一个byte的slice
下面开始讲 slice
slice 的底层有一个 array ,所以可以从 array 转成 slice ;把一个 slice 赋值给另一个 slice ,两个 slice 共享同一个底层的 array ,修改 array 的值,对两个 slice 都有效。如下所示:
|
|
内存池优化思路
好了,看 slice 的实现,其实只是为了弄清一句话:
append 的结果是一个包含原 slice 所有元素加上新添加的元素的 slice
如果 s 的底层数组太小,而不能容纳所有值时,会分配一个更大的数组。 返回的 slice 会指向这个新分配的数组
目前的问题,socket编程处理数据省不了要 append 两个 slice ,旧 slice 和新 slice 是不是都要放回池中?
好好看看 slice 之后,有了这样的认识:
slice 其实只是指针,我们的pool其实要保留的就是 slice 所指向的数组
- 如果 slice 的底层数组太小,而不能容纳所有值时,会分配一个更大的数组
- 如果旧 slice 的底层数组不够大,那么 append 操作会让这个旧的底层数组失去引用,面临GC回收
所以需要为了避免旧的底层数组被回收,让旧的 slice 的 cap 大一点吗?
|
|
这样我们有三个slice,执行完毕会有两个或者三个底层数组参与(取决于a的底层数组够不够大)
为了尽可能地复用(将所有出现过的数组都放进pool)
那么就不要丢弃a的底层数组,最终只有两个底层数组参与
从试图将所有的数组放入pool的角度看,a的cap要大一点
但是将所有数组都放进pool真的好吗?
对于 leakypool ,不是太好,因为 leakypool 限定了 pool 中可以有的数量,多了的最后都被GC
举一个例子:
|
|
结论就是,还是得根据实际需要,去复用 底层数组 或者跟换 锁策略 来优化 内存池