go 虽然支持自动管理内存,但如果可以确定对象的生命期,使用手工管理,可以减少一些 GC 的压力。 本文探讨手工管理 []byte 的各个方面。
free list
首先需要一个 free list。可以自己实现,也可以用标准库的 sync.Pool。 sync.Pool 在 1.13 版本有一个改进,不会在每次 GC 时都清空了,所以是可以用的。
有一个提案对实现这类结构有益:Proposal: percpu.Sharded, an API for reducing cache contention, 不过还没有实现。简洁起见,本文就用 sync.Pool。
支持多个大小
一个 sync.Pool 应当只支持特定大小的 []byte,所以应该有多个 sync.Pool。 []byte 的大小可以分成多级,每一级对应一个 sync.Pool。
代码如下:
var bytesClasses, bytesPools = func() (
classes []int,
pools []sync.Pool,
) {
for i := 7; i <= 16; i++ {
size := 1 << i
classes = append(classes, size)
pools = append(pools, sync.Pool{
New: func() interface{} {
return make([]byte, size)
},
})
}
return
}()
上面代码,初始化了大小从 128 到 65536 的多个 sync.Pool。 对于大于 64K 的,直接分配而不做管理。大小的上下界可以根据具体场景做调整。
分配
分配就是根据传入的大小,找到对应级别的 sync.Pool,用 Get 方法获得 []byte,将切片长度改成传入的大小,返回即可。
返回的是一个 Bytes 对象,使用时,就用它的 Bytes 字段。
type Bytes struct {
Bytes []byte
class int
}
func getBytes(size int) Bytes {
class := 0
for size > bytesClasses[class] {
class++
if class == len(bytesClasses) {
break
}
}
if class == len(bytesClasses) {
return Bytes{
Bytes: make([]byte, size),
class: -1,
}
}
return Bytes{
Bytes: bytesPools[class].Get().([]byte)[:size],
class: class,
}
}
如果大小参数超出最大值,则返回一个用 make 分配的对象。 这是用于兜底,保证正确性的。如果实际场景里触发了这个,那应该调整分配的最大值。
回收
回收就是将 []byte 放回 sync.Pool。
如果级别是 -1,表示这是 make 分配的,不需要管理。
func (b Bytes) Put() {
if b.class == -1 {
return
}
bytesPools[b.class].Put(b.Bytes)
}
正确性保证
手工管理对象的难点在于确定对象的生命期。 如果回收过早,或者回收后扔有指针指向内部,就可能引发竞态。 不像 rust,go 是没有语言机制保证这些方面的安全的。 所以使用的时候要小心。
运行时提供的竞态检测器,对保证正确性有帮助。如果对象被错误回收,极有可能触发竞态告警。
可能静态代码分析,也可以实现类似 rust 的生命期分析。 如果分析器不能推断一个手工分配的对象的使用是正确的,就报错,要求改动代码直到分析器可以推断。 我不太熟悉这个方面,感觉是个值得研究的方向。
逃逸分析就类似生命期分析,编译器不能保证对象的生命期不大于栈帧的生命期,就只能分配到堆上。 但对于堆上的对象的生命期,逃逸分析是不关心的。 感觉这方面有优化的空间,如果某些堆对象也可以通过静态分析确定生命期,那 GC 的时候,是可以跳过的。
可以参考或者使用的: