golang 是一门自带 GC(垃圾回收) 的语言,GC 帮助程序员从手动管理内存的繁琐工作中解放出来,不仅提升了程序员的生产效率,还保护了程序员的发际线。
天下没有免费的午餐,GC 有时候却可能成为你系统性能下降的罪魁祸首,在系统内存负载较大的情况下,对象数量很多的情况下,尤其容易发生。
本文介绍一种降低 GC 开销的的优化措施,借鉴自 bigcache,一个优秀的开源 golang 内存缓存库。
问题
我们先来看一下使用不同的方式在内存中存储 3*10^7 个元素后,一次 GC 的耗时比较情况:
func main() {
const N = 30e6
if len(os.Args) != 2 {
fmt.Printf("usage: %s [1 2 3 4]\n(number selects the test)\n", os.Args[0])
return
}
switch os.Args[1] {
case "1":
// Big map with a pointer in the value
m := make(map[int32]*int32)
for i := 0; i < N; i++ {
n := int32(i)
m[n] = &n
}
runtime.GC()
fmt.Printf("With %T, GC took %s\n", m, timeGC())
_ = m[0] // Preserve m until here, hopefully
case "2":
// Big map, no pointer in the value
m := make(map[int32]int32)
for i := 0; i < N; i++ {
n := int32(i)
m[n] = n
}
runtime.GC()
fmt.Printf("With %T, GC took %s\n", m, timeGC())
_ = m[0]
case "3":
// A slice, just for comparison to show that
// merely holding onto millions of int32s is fine
// if they're in a slice.
type t struct {
p, q int32
}
var s []t
for i := 0; i < N; i++ {
n := int32(i)
s = append(s, t{n, n})
}
runtime.GC()
fmt.Printf("With a plain slice (%T), GC took %s\n", s, timeGC())
_ = s[0]
case "4":
// A slice of pointer
type t struct {
p, q int32
}
var s []*t
for i := 0; i < N; i++ {
n := int32(i)
s = append(s, &t{n, n})
}
runtime.GC()
fmt.Printf("With a pointer slice (%T), GC took %s\n", s, timeGC())
_ = s[0]
}
}
func timeGC() time.Duration {
start := time.Now()
runtime.GC()
return time.Since(start)
}
分别在命令行传入 1-4 调用上述程序,在笔者电脑上的输出结果是:
With map[int32]*int32, GC took 1.000267379s
With map[int32]int32, GC took 9.477637ms
With a plain slice ([]main.t), GC took 214.495µs
With a pointer slice ([]*main.t), GC took 216.134041ms
大家可以看到,在 map 或者 slice 中存储指针还是非指针,其 GC 性能完全是两个级别。还要注意到,我们上面的程序其实 timeGC 不会真的实质性的回收什么数据,因为这时候还没有已经没有引用的数据。 GC 的消耗主要在扫描内存上了。
这里想必有人会说,GC 消耗这么大,我以后要在 map 中全部存对象,不存指针。但是我想说:朋友,too young 啊。
golang 是一门按照值传递的编程语言,什么意思呢?假设:
type SomeVeryBigStruct struct{
// 一大堆字段,总体大小几百字节
}
var m map[int]SomeVeryBigStruct
// m 中添加了超多记录
for k, v := range m {
// v 是一个完全拷贝
}
v := m[1] // v 还是一个拷贝
可以看到,我们在索引 map 的时候,会平白无故的进行数据拷贝,对于一些大结构体,这个性能开销会是相当可观的。
如何避免高昂的 GC 开销的同时,又能避免到处拷贝数据呢?
解法: map[uint32]int64 + []byte
bigcache 利用了go 这个特性,即对于没有指针的 map,GC 过程不需要遍历该 map,来对海量内存缓存进行了优化。
其核心有两个组件:
- map[uint32]int64,这个保存的是 hash(key) 到 offset 的映射
- []byte ,以单一的字节数组来保存所有的数据,1 中的 offset 正是对应记录值在 []byte 的偏移量
插入分成两个步骤:
- 将 value ([]byte,可能需要对原来的对象进行合适的序列化)插入到底层 []byte (bigcache 中用一个 ByteQueue 表示),获得插入的 offset
- 将 key 和 offset 的映射保存起来
相应的,查询时:
- 使用 key 查到 offset
- 读取 []byte 中指定 offset 的数据返回
要注意到,因为底层是将 value 存储在一个 []byte 内,所以 bigcache 提高的接口,其 value 只能是 []byte。所以对于非 []byte 的 value,要想使用类似的优化方式,还是需要引入一个额外的序列化(插入时)和反序列化(读取后)。这样做可能会引入额外的 CPU 消耗和编码逻辑,这也是需要注意的。这种情况下,此优化对于性能的提升就需要做具体的 profiling 才可以确定。
最后大家不要忘记,[]byte 的传递,不会引起底层数据的拷贝。
总结
- golang 中要避免存有大量指针的 map,要不然会引来高额的 GC
- bigcache 通过将 value 保存在一个 []byte,保存 key 与 offset 的映射来解决
- 这种优化策略有其特有的适用场景:海量存储 + 大结构体,如果你存储的都是一些小结构体,不会因为按值传递代来性能负担的话,是不需要采用上述优化措施的
- 对于 value 不是 []byte 的场景,需要引入序列化与反序列化的逻辑,这些额外 CPU 消耗,对系统的性能影响,需要通过具体的 profiling 来确定