在Go语言中,使用切片( Slice )表示数组( Array )某一部分是一种很高效的方式。切片之所以如此有用,除了它们只需要非常少的开销这个事实之外,还有它们总是能够透明地代替一个实际的数组:

1
2
3
4
5
6
7
a := []string{"b", "a", "m", "i", "h", "n", "f"}
sort.Strings(a)
//a 现在等于 ["a", "b", "f", "h", "i", "m", "n"]

b := []string{"b", "a", "m", "i", "h", "n", "f"}
sort.Strings(b[2:4])
//b 现在等于 ["b", "a", "i", "m", "h", "n", "f"]

但是需要记住一个关于切片的重点,尤其是当你要保持它们的引用很长一段时间时:它们持有对底层数组的引用。当然是这样,你很明白,它们不仅仅是一个指向底层数组的指针,然而所以呢?

你到底是什么?

这个问题(如果你这么慷慨让我如此称呼)又回到了切片的透明性( transparency )问题。切片是如此透明,以至于,你是在处理切片还是数组并不总是那么明显。举个例子,考虑令人喜爱的函数 ioutil.Readall 的签名:

1
func ReadAll(r io.Reader) ([]byte, error)

它是返回一个数组还是一个数组的切片呢?如果它是一个切片,它的内存占用空间可能比函数 len 获得的结果多很多。为了得到底层数据结构的大小,你想要使用 cap 函数。任何返回数组的函数可能实际上会比明面上显示的更加浪费内存,意识到这一点让人并不愉快(尤其是那些新手)。

512 字节的烦恼

等等,不止这点。在 Go 自己的代码库中、第三方的代码库中以及可能你自己的代码中,有许多利用了 bytes.Buffer 数据结构的代码,那是一个底层是 []byte 的增长数据结构。 ioutil.ReadAll 自身就是一个对 bytes.Buffer 的简易封装。而这就是我遇到的出乎我意料的地方。

第一份代码片段是在 Go 里读取 HTTP 响应数据的地道方式:

1
body, _ := ioutil.ReadAll(resp.Body)

如果我们知道响应数据的大小,以上的方式是非常低效的。它的缓冲区初始大小为 512 字节,并使用两倍增长算法( 2x growth algorithm )。其导致的结果是大量不必要的分配和极高可能性的浪费内存空间。同样,后者也不是立刻就能发现的,直到你将响应体的字节长度( length )与它的容量( capacity )作对比。结果如何?

1
2
3
buffer := bytes.NewBuffer(make([]byte, 0, resp.ContentLength)
buffer.ReadFrom(res.Body)
body := buffer.Bytes()

给一个 ContentLength 为 70KB 的响应体,你猜想一下,~len(body)~ 和 cap(body) 各自等于多少?它的长度会是 70KB ,但是它的容量将会是 143872B 。因为一个我不清楚的理由,~bytes.Buffer~ 需要一快 bytes.MinRead 的额外空间。而 bytes.MinRead 的值就是 512 字节。所以,除非我们自己创建一块大小为 res.ContentLength + bytes.MinRead 的缓冲区,我们最终得到的数组将会有 70K*2+512 字节的实际内存空间。

内存泄漏

让我们看看,在提供垃圾收集的 runtime 中会发生什么样的内存泄漏?它们通常是你不关心的一个根对象或则一个来自根对象的引用。这明显不同于你可能没有意识到的额外内存。把对象放在 root 中非常有可能是你故意为之的,但是你并没有意识到,你将多少内存与 root 关联。的确,你的无知至少有 75% 的责任。我也情不自禁地感觉到这一切都太过微妙。任何代码都能返回一些东西,它们看上去和说起来像是含2个整数的数组,但却需要一些内存。此外,将 bytes.MinRead 作为一个全局变量是一个糟糕的设计。我不能想象,有多少人在实际分配了 X*2+512 字节内存时认为他们只分配了 X 字节内存。

解决方案

如果你处理过短寿命的对象,上述任何情况都不是问题。无论如何,对于长寿命对象有两种简单的解决方式。第一且是最重的,如果你需要追踪实际使用了多少内存(假设你正在构建一个能保证自己运行状态良好的 LRU 高数缓存),那么请使用 cap 而不是 len 函数。但是,这没有解决浪费内存空间的问题。

当你知道数据的大小时,像是上述有 ContentLength 的情况,你应该使用 ioReadFull :

1
2
3
l := resp.ContentLength
body := make([]byte, l, l)
_, err := io.ReadFull(res.Body, body)

否则,你将不得不先把它读入一个可增长的缓冲区中,然后把它拷贝回定长数组中。你将需要判决你节省的内存是否值得你花费额外的 CPU 。我们使用如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//ioutil.ReadAll starts at a very small 512
//it really should let you specify an initial size
buffer := bytes.NewBuffer(make([]byte, 0, 65536))
io.Copy(buffer, r.Body)
temp := buffer.Bytes()
length := len(temp)
var body []byte
//are we wasting more than 10% space?
if cap(temp) > (length + length / 10) {
body = make([]byte, length)
copy(body, temp)
} else {
body = temp
}

我的无知之雾略有减少。