Go中实现手动内存分配的坑

2016-07-10

你一定想到过,分配一块大的内存,然后从里面切小的对象出来,手动管理对象分配。分配的开销非常小,就是offset加一下。尤其是有些场景,释放时直接把offset重置,就可以重用这块空间了。实现手动内存分配的好处是,减少小对象数目,从而减少垃圾回收时的扫描开销,降低延迟和提升整个性能。

想到不代表做过,做过会踩坑,这篇文章会把你可能要踩的坑都说一遍。不过先说结论:别这么干,不作死就不会死!

TL;DR

扩容

make([]byte)

不要append,别让它扩容。一旦发生扩容,会分配一块新的空间,而旧的slice将不再有任何变量引用它,于是会被垃圾回收掉。等等!之前分配的对象还在里面呢,被回收掉岂不傻逼了?

所以建议直接用固定大小的数组,而不是slice。如果想做成可增长的,用一个链表串起来。

const blockSize = 32*1024*1024 - 16
type node struct {
    block [blockSize]byte
    off   int
    next  *node
}
type Allocator {
    head *node
    tail *node
}

初始化

初始化是很容易漏掉的地方。重用之前的内存空间,如果忘记了初始化,分配出来的对象不是干净的。

一种方式是C的malloc语义,分配的对象空间就是不初始化的,用户自己去处理。比如:

t := (*T)(ac.Alloc(sizeT))
*t = T{a:3, b:5}

另一种做法可以在Reset的时候把整块空间清除一遍,这样分配出去的都是初始化为零的。

对象内部存在引用

现在分配器的接口是这样子的:

func (ac *Allocator) Alloc(size int) unsafe.Pointer

你觉得没什么问题了,拿它来分配对象,结果使用时却遇到莫名奇妙的内存错误。为什么呢?

假设用它来分配对象T:

type T struct {
    s *S
}
t := (*T)(ac.Alloc(sizeT))
t.s = &S{}

T对象的空间是从一块数组里面划出来的,垃圾回收其实并不知道T这个对象。不过只要Allocator里面的大块内存不被回收,T对象还是安全的。但是,对于T里面的S,它是标准方式分配的,这就会有问题了。

假设发生垃圾回收了,GC会以为那块内存空间就是一个大的数组,而不会被扫描对象T,那么t.s的空间未被任何对象引用到,它会被清理掉。最后t.s就变成一个悬挂指针了!

这样实现的分配器只能处理两种情况,一种是用于分配对象里面不包含其它引用。另一种,对象里包含引用,但引用的对象空间也是在这个分配器里面。

string的处理

我们的分配器不能分配包含引用的对象,这条限制是很严格的。假设T是:

type T struct {
    name string
}

这样子都是不行的!string其实就是典型引用类型,它是一个指针加一个长度,指针指向实现的数据。你明白了吧,这样的约束之后分配器几乎就不可用了。

为了能处理引用,需要改造一下。我们加一个Prevent接口:

func (ac *Allocator) Prevent(v interface{}) {
    ac.ref = append(ac.ref, v)
}
ref []interface

slice的处理

slice也是引用类型,处理起来更复杂一些。坑也更深,留点空间给大家去想了。

最后,当你把这些都考虑足够充分后,就发现跟初衷相违了。

本希望是一个简单的分配器来手动管理内存,可以减少对象分配,可以减少垃圾回收的扫描----但是不扫描就可能把还在使用的对象回收掉。为了处理,我们必须把对象的引用再加回去,减少对象扫描的努力成了无用功。再注意到Prevent的接口是interface类型,传参时其实会生成一个临时对象的,于是减少对象分配也没做到。