Go 切片的一种有趣内存泄漏方式

今天我在看 Prashant Varanasi 的 Go 发布会演讲:使用火焰图进行生产分析[1](Analyzing production using Flamegraphs) , 在演讲开始的第 28 分钟他提到了一种涉及切片的有趣且棘手的内存泄漏 。 为了自我提升 , 我将在这里写一下该内存泄漏的一种形式 , 并说明它是如何发生的 。
首先 , 对于像 Go 这样的垃圾收集语言来说 , 内存泄漏是保留了对对象的非预期引用所造成的 。 垃圾收集器会帮你寻找并释放对象 , 但前提是它们事实上并没有被使用 。 如果你保留了对它们的引用 , 它们会留下来 。 有时最终结果很简单(也行你故意保留一个较小的结构 , 但没意识到它引用了一个较大的结构) , 但有时候这种保留隐藏在某些东西的运行时实现里 。 这改变了我们对切片的看法 。
简化之后 , Prashant 处理的代码在一个切片中维护了当前在使用的元素的集合 。 当一个元素不再被使用时 , 它被转移到了切片的末尾 , 然后切片被截断而缩小(保持不变的是切片只保留使用的元素) 。 然而 , 缩小切片并不会缩小其依赖的数组 , 用 Go 的术语来说 , 减小了切片的长度但是并没有减少容量 。 由于底层依赖的数组没有变动 , 而该数组保留了一个理论上已经被丢弃了的元素的引用 , 以及该元素所引用的所有其他对象 。 即使是代码不可见的引用被保留 , Go 垃圾收集器仍然会将该元素看做是还在使用中 。 代码认为以及被丢弃了的元素实际上并没有被释放 , 这就造成了内存泄漏 。
现在 , 我查看了 Go 运行时和编译器代码 , 并对该问题进行了一些思考 , 我清楚地意识到了这是任何切片截断的通用问题 。 Go 绝不会尝试缩小切片的底层数组 , 而且通常来说这样做是不可能的 , 因为一个底层数组可能被多个切片[2]或其他引用所共享 。 这显然会严重影响指向包含指针的对象的切片 , 但对于指向普通的旧数据的切片也可能很重要 , 尤其是当它们比较大的时候(比如你有一个 Point 的切片 , 每个 Point 有三个浮点数) 。
对于包含指针或者包含持有着指针的结构的切片来说 , 明显的修复方式(这是Uber 代码中采用的修复方式[3])是在截断切片之前将末尾的指针置为空 。 这样保留了完整的底层数组 , 但抛弃了对其他内存的引用 , 而这些其他的内存是真正内存泄漏的地方 。
对于实际的底层数组可能会有大量内存消耗的切片来说 , 我想到可能有两种做法 , 一种特殊 , 一种通用 。 特殊的一种是检查代码中“大小截断为零”的情况 , 并专门将切片本身置为空 , 而不是仅仅使用标准的切片截断功能来截断 。 通用的做法是明确地强制使用切片拷贝而不是仅仅截断(就如我对切片可变性的评论[4]提到的) 。 强制使用拷贝所带来的缺点是 , 某些时候可能会带来更大的开销 。 你可以通过仅在切片的容量远远超出新切片的长度的时候才强制使用拷贝的方式来进行优化 。
补充:(对垃圾收集而言)三索引的切片截断是危险的Go 切片表达式[5]允许在起终点之外 , 使用很少使用的第三个索引来设置新切片的容量 。 你也许会想到采用这种形式限制切片 , 来作为解决垃圾收集问题的办法:
slc = slc[:newlen:newlen]不幸的是 , 这样并不会达到你想要的效果 , 而且会适得其反 。 设置新切片的容量完全不会改变底层的依赖数组 , 也不会让 Go 分配一个新的内存 , 但这却意味着你无法获取数组大小的信息(否则可以通过切片的容量来得到它) 。 这样造成的唯一影响是强制随后的 append() 重新分配新的底层数组 。
via: ~cks/space/blog/programming/GoSlicesMemoryLeak
作者:ChrisSiebenmann[6]译者:dust347[7]校对:polaris1119[8]
本文由 GCTT[9] 原创编译 , Go 中文网[10] 荣誉推出
参考资料[1]
使用火焰图进行生产分析:
[2]