Go:在栈上分配内存
前言
本文翻译自 Go 官方博客 Allocating on the Stack,原作者 Keith Randall。
这篇文章讲的是 Go 最近两个版本里和内存分配有关的一组优化。主题并不复杂:尽量把原本会发生在堆上的分配,搬到栈上。堆分配慢,还会给 GC 增加负担;栈分配便宜,有时候甚至几乎是免费的。
我们一直在寻找让 Go 程序更快的办法。在最近两个版本里,我们主要集中在缓解一个特定的性能来源:堆分配。Go 程序每次从堆上分配内存时,都需要执行一大段代码来完成这次分配。另外,堆分配还会给垃圾回收器带来额外负载。即便有 Green Tea 这样的近期增强,垃圾回收器仍然会带来不小的开销。
所以我们一直在研究,如何让更多分配发生在栈上,而不是堆上。栈分配要便宜得多(有时甚至完全免费)。更重要的是,栈分配不会给垃圾回收器增加负担,因为栈上的分配可以随着栈帧本身一起自动回收。栈分配也能让内存更快被复用,这对缓存非常友好。
固定大小 slice 的栈分配
考虑这样一个任务:构建一个待处理任务的 slice:
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
让我们看看运行时从 channel c 中取出任务,并把它们加入 tasks slice 时会发生什么。
第一次循环时,tasks 还没有 backing store,所以 append 必须分配一个。因为它不知道这个 slice 最终会有多大,所以不能太激进。目前,它会分配一个大小为 1 的 backing store。
第二次循环时,backing store 已经存在,但已经满了。append 又必须分配一个新的 backing store,这次大小为 2。原来大小为 1 的 backing store 就变成了垃圾。
第三次循环时,大小为 2 的 backing store 又满了。append 再一次分配新的 backing store,这次大小为 4。原来大小为 2 的 backing store 也变成了垃圾。
第四次循环时,大小为 4 的 backing store 里只有 3 个元素。append 可以直接把新元素放进去,再把 slice 长度加一。不错,这次不用调用分配器了。
第五次循环时,大小为 4 的 backing store 满了,append 又得分配一个新的 backing store,这次大小为 8。
后面也差不多。一般来说,我们每次都会把分配大小翻倍,这样最终大多数新的 task 都可以追加到 slice 中,而不需要重新分配。但 slice 还比较小时,这个“启动阶段”有相当多额外开销。在这个阶段,我们会花很多时间在分配器上,还会产生一堆垃圾,看起来挺浪费的。而且在你的程序里,这个 slice 可能根本不会变得很大。你遇到的也许只有这个启动阶段。
如果这段代码真的是程序里的热点,你可能会想,一开始就给 slice 一个更大的容量,避免这些分配。
func process2(c chan task) {
tasks := make([]task, 0, 10) // 大概最多 10 个 task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
这是一个合理的优化。它永远不会让程序变错,程序仍然可以正确运行。如果估计太小,那就和之前一样,append 会继续触发分配。如果估计太大,只是浪费一些内存。
如果你对 task 数量的估计是准确的,那么这个程序里只有一个分配点。make 调用会分配一个大小正确的 slice backing store,append 再也不需要重新分配。
有趣的是,如果你用 channel 里 10 个元素来 benchmark 这段代码,会发现分配次数不是从多次降到了 1 次,而是降到了 0 次。
原因是编译器决定把 backing store 分配到栈上。因为编译器知道它需要的大小(10 乘以一个 task 的大小),所以可以把这段存储放进 process2 的栈帧里,而不是放到堆上1。注意,这依赖于 backing store 不会在 processAll 内部逃逸到堆上。
变量大小 slice 的栈分配
当然,把估计大小硬编码进去有点死板。也许我们可以把估计长度传进来?
func process3(c chan task, lengthGuess int) {
tasks := make([]task, 0, lengthGuess)
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
这样调用方就可以为 tasks slice 选择一个合适大小,而这个大小可能会随着调用位置不同而变化。
不幸的是,在 Go 1.24 中,backing store 的大小不是常量,意味着编译器不能再把它分配到栈上。它最终会落到堆上,把我们的 0 分配代码变成 1 分配代码。虽然仍然比让 append 做所有中间分配要好,但还是有点遗憾。
但别急,Go 1.25 来了。
想象一下,为了只在估计值比较小时拿到栈分配,你决定这么写:
func process4(c chan task, lengthGuess int) {
var tasks []task
if lengthGuess <= 10 {
tasks = make([]task, 0, 10)
} else {
tasks = make([]task, 0, lengthGuess)
}
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
有点丑,但它确实能工作。当估计值比较小时,你使用常量大小的 make,于是 backing store 分配在栈上;当估计值更大时,你使用变量大小的 make,backing store 从堆上分配。
但在 Go 1.25 中,你不需要走这条丑路。Go 1.25 编译器会替你做这个转换。对于某些 slice 分配位置,编译器会自动分配一个较小的(目前是 32 字节)slice backing store,并在请求大小足够小时,把这个 backing store 用作 make 的结果。否则,它会像平常一样使用堆分配。
在 Go 1.25 中,如果 lengthGuess 足够小,小到这个长度的 slice 可以放进 32 字节里,那么 process3 就不会产生堆分配。(当然,这也要求 lengthGuess 对 c 中元素数量的估计是正确的。)
我们一直在改进 Go 的性能,所以升级到最新 Go 版本,也许会惊讶于你的程序变得更快、更省内存。
append 分配的 slice 的栈分配
好吧,但你可能仍然不想为了这个奇怪的长度估计值修改 API。还有其他办法吗?
升级到 Go 1.26。
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
在 Go 1.26 中,我们会在栈上分配同样的小型、推测性的 backing store,但现在可以直接在 append 位置使用它。
第一次循环时,tasks 没有 backing store,所以 append 会使用一个小的、栈分配的 backing store 作为第一次分配。比如,如果这个 backing store 能放下 4 个 task,第一次 append 就会从栈上分配一个长度为 4 的 backing store。
接下来的 3 次循环会直接追加到这个栈上的 backing store 中,不需要任何分配。
到第 4 次循环时,栈上的 backing store 终于满了,我们才需要去堆上申请更多 backing store。但我们已经避开了本文前面描述的几乎所有启动阶段开销。没有大小为 1、2、4 的堆分配,也没有它们最终变成的垃圾。如果你的 slice 很小,也许一次堆分配都不会发生。
append 分配且会逃逸的 slice 的栈分配
好,这些在 tasks slice 不逃逸时都很好。但如果我要返回这个 slice 呢?那它就不能分配在栈上了,对吧?
对。下面 extract 返回的 slice 的 backing store 不能分配在栈上,因为 extract 返回后,extract 的栈帧就消失了。
func extract(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
return tasks
}
但你可能会想,返回的 slice 不能分配在栈上。那那些中间 slice 呢?它们只是变成垃圾,也许可以分配在栈上?
func extract2(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
tasks2 := make([]task, len(tasks))
copy(tasks2, tasks)
return tasks2
}
这样 tasks slice 就不会逃逸出 extract2。它可以受益于上面描述的所有优化。然后在 extract2 的最后,当我们知道 slice 的最终大小时,只做一次所需大小的堆分配,把这些 task 复制进去,并返回这个副本。
但你真的想写这些额外代码吗?看起来很容易出错。也许编译器可以替我们做这个转换?
在 Go 1.26 中,可以。
对于会逃逸的 slice,编译器会把原来的 extract 代码转换成类似这样:
func extract3(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
tasks = runtime.move2heap(tasks)
return tasks
}
runtime.move2heap 是一个特殊的 compiler + runtime 函数。对于已经分配在堆上的 slice,它相当于 identity function。对于还在栈上的 slice,它会在堆上分配一个新的 slice,把栈分配的 slice 复制到堆上的副本里,并返回这个堆副本。
这保证了,对于原始的 extract 代码,如果元素数量能放进我们那个小的栈分配缓冲区里,那么我们会正好执行 1 次分配,并且大小正好合适。如果元素数量超过了小型栈分配缓冲区的容量,那么一旦这个栈分配缓冲区溢出,我们就走正常的翻倍分配逻辑。
Go 1.26 做的优化实际上比手写优化更好,因为它不需要手写优化最后总会发生的额外分配和复制。只有在我们一路都只操作栈 backing 的 slice,直到 return 位置时,它才需要这次分配和复制。
我们确实付出了复制成本,但这个成本几乎完全被我们不再需要执行的启动阶段复制抵消了。(事实上,新方案最坏情况下只需要比旧方案多复制一个元素。)
总结
手写优化仍然可能有收益,尤其是你提前就能很好地估计 slice 大小时。但希望现在编译器可以替你抓住很多简单场景,让你把注意力放在真正重要的剩余部分上。
为了正确完成这些优化,编译器需要处理大量细节。如果你认为其中某个优化给你带来了正确性问题或性能下降,可以用 -gcflags=all=-d=variablemakehash=n 关闭它们。如果关闭这些优化之后问题消失了,请提交 issue,这样我们可以进一步调查。
1 Go 的栈没有 alloca 这种动态大小栈帧机制。所有 Go 栈帧都是固定大小的。
原文内容遵循 Creative Commons Attribution 4.0 License,代码遵循 BSD license。本文为中文翻译。