原文标题: Allocating on the Stack
作者: Keith Randall
来源: The Go Blog
原文日期: 2026 年 2 月 27 日
Go 程序每次从堆上申请内存,都要经过分配器的一段运行时代码;堆对象还会增加垃圾回收器的工作量。栈上的临时空间通常更便宜,有时几乎没有额外成本,而且随着函数栈帧结束即可回收,也不需要垃圾回收器追踪。Go 编译器近几个版本逐步改进了切片后备数组的栈分配,让常见的小切片场景少产生一些堆对象。
从逐次扩容开始
考虑从 channel 收集待处理任务:
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
一开始,tasks 没有后备数组。第一次 append 需要分配容量为 1 的数组;第二次容量不够,就分配容量为 2 的新数组,旧数组变成垃圾;第三次再扩到 4。数组通常以大约翻倍的方式增长,因此后续许多追加可以直接使用已有空间,但启动阶段仍会发生多次分配和复制。若切片通常很小,这一段开销可能占去不小比例。
若能估计任务数量,可以预先给切片留出容量:
func process2(c chan task) {
tasks := make([]task, 0, 10) // 大概不超过 10 个任务
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
这仍是正确的优化方式:估计偏小,append 会继续扩容;估计偏大,则会多占一些内存。若容量估得合适,这个 make 已足够,追加时不必再次扩容。更令人意外的是,当 channel 中有 10 个任务时,基准测试可能看到的不是一次分配,而是零次堆分配:编译器知道后备数组大小是 10 × sizeof(task),就可能把它放在 process2 的栈帧中。前提是 processAll 不会让这块后备数组逃逸到堆上。
Go 1.25:给变长切片一个小型栈缓冲
把估算容量交给调用方更灵活:
func process3(c chan task, lengthGuess int) {
tasks := make([]task, 0, lengthGuess)
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
在 Go 1.24 中,后备数组大小由运行时的 lengthGuess 决定,编译器无法按固定大小将它放进栈帧;即便没有后续扩容,通常仍要进行一次堆分配。
Go 1.25 对部分切片分配位置做了改进:编译器会自动准备一个目前为 32 字节的小型栈上后备数组。请求容量足够小时,make 使用这块栈空间;超过它时则照常从堆分配。因此,只要估算值既覆盖实际元素数、又使数组大小不超过该小缓冲区,process3 就可以做到零次堆分配。若猜测不准而后续需要更大容量,切片仍会按常规方式扩展。
Go 1.26:从 append 的第一次扩容就尝试用栈
之前的做法要求 API 增加一个容量估算参数。Go 1.26 进一步优化了没有预设容量的写法:
func process(c chan task) {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
processAll(tasks)
}
第一次 append 时,编译器可以先使用小型栈上后备数组。假设这块空间可容纳 4 个 task,那么前四次追加都能留在栈上;只有继续追加、容量确实不足时,才转到堆上扩容。对于始终较小的切片,启动阶段容量 1、2、4 的堆数组及其后续垃圾就可以避免;若切片变大,则仍会发生堆分配。
返回切片时,如何处理逃逸
若函数直接返回 []task,切片后备数组必须在函数返回后继续存在,不能只放在即将消失的栈帧里:
func extract(c chan task) []task {
var tasks []task
for t := range c {
tasks = append(tasks, t)
}
return tasks
}
手工实现的一种办法,是先用局部切片收集,再按最终长度分配并复制到返回值:
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 不逃逸,可以受益于前述栈分配;最后再为返回结果分配恰好所需的堆空间。不过,这段手工代码总会在结尾分配并复制一次。
Go 1.26 可以为逃逸切片自动安排类似的处理。原来的代码在概念上相当于:
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 是编译器与运行时配合使用的特殊函数:若切片本来就在堆上,它保持切片不变;若切片仍在栈上,则分配新的堆切片、复制内容并返回。这样,若元素数量一直落在栈缓冲区范围内,函数只需在返回前分配一次最终大小的堆数组。若追加过程中已经转到堆上,就沿用正常扩容得到的结果。
相较于始终手动复制的写法,编译器方案只在切片确实仍在栈上时才做这次最终复制。复制有成本,但会抵消一部分原本启动扩容时的复制;原文指出,新方案最坏只比旧方案多复制一个元素。
何时仍值得手动优化
如果能够可靠估算切片大小,手工预留容量仍可能有帮助。不过,编译器如今可以自动覆盖不少简单情况,优化前最好先观察实际程序,而不是仅凭代码外观判断分配数量。栈分配也受逃逸分析、容量和编译器优化位置等条件影响,不能把它理解成所有切片都一定留在栈上。
如果怀疑这些优化引发正确性问题或性能倒退,原文给出的临时关闭参数是:
go build -gcflags=all=-d=variablemakehash=n
若关闭后问题消失,可以向 Go 项目提交 issue 供进一步调查。Go 的栈帧大小是固定的,没有类似 alloca 的动态大小栈帧机制;这也是编译器需要为可变大小切片选择小型固定栈缓冲、并在需要时转到堆上的背景。
来源: Allocating on the Stack — The Go Blog
作者: Keith Randall
转载许可: 原文页面未注明本文的转载许可信息。











暂无评论内容