栈上分配

2026-02-27
13 min read
Anonymous

Keith Randall

2026 年 2 月 27 日

我们一直在寻找让 Go 程序更快的方法。在过去的两个版本中,我们专注于缓解一个特定的性能瓶颈——堆分配。每次 Go 程序从堆上分配内存时,都需要运行相当大的一段代码来满足该分配。此外,堆分配还会给垃圾回收器带来额外的负担。即使采用了像 Green Tea 这样的最新优化,垃圾回收器仍然会产生相当大的开销。

因此,我们一直在研究如何将更多的分配放在上而不是堆上。栈分配的执行成本要低得多(有时甚至完全免费)。更重要的是,它们不会给垃圾回收器带来任何负担,因为栈分配可以随栈帧本身一起自动回收。栈分配还能实现及时的复用,这对缓存非常友好。

固定大小切片的栈分配

考虑构建一个任务切片的任务:

func process(c chan task) {
    var tasks []task
    for t := range c {
        tasks = append(tasks, t)
    }
    processAll(tasks)
}

让我们过一遍运行时从通道 c 中取出任务并添加到切片 tasks 时会发生什么。

在第一次循环迭代中,tasks 没有后备存储,因此 append 必须分配一个。因为它不知道切片最终会有多大,所以无法过于激进。目前,它会分配一个大小为 1 的后备存储。

在第二次循环迭代中,后备存储已经存在,但已满。append 再次需要分配一个新的后备存储,这次大小为 2。大小为 1 的旧后备存储现在变成了垃圾。

在第三次循环迭代中,大小为 2 的后备存储已满。append 再次需要分配一个新的后备存储,这次大小为 4。大小为 2 的旧后备存储现在变成了垃圾。

在第四次循环迭代中,大小为 4 的后备存储中只有 3 个元素。append 可以直接将元素放入现有后备存储并增加切片长度。太棒了!这次迭代不需要调用分配器。

在第五次循环迭代中,大小为 4 的后备存储已满,append 再次需要分配一个新的后备存储,这次大小为 8。

以此类推。我们通常在分配填满时将大小加倍,因此最终大多数新任务都可以在不分配的情况下追加到切片中。但在切片很小的时候——"启动"阶段——会有相当多的开销。在这个启动阶段,我们在分配器上花费了大量时间,并产生了一堆垃圾,这看起来相当浪费。而且在你的程序中,切片可能永远不会变得很大,这个启动阶段可能就是你所遇到的全部情况。

如果这段代码是你程序中真正热门的路径,你可能会忍不住让切片从一个更大的大小开始,以避免所有这些分配。

func process2(c chan task) {
    tasks := make([]task, 0, 10) // 可能最多 10 个任务
    for t := range c {
        tasks = append(tasks, t)
    }
    processAll(tasks)
}

这是一个合理的优化。它永远不会出错;你的程序仍然能正确运行。如果猜测的大小太小,你会像之前一样从 append 获得分配。如果猜测的大小太大,你会浪费一些内存。

如果你对任务数量的猜测是好的,那么这个程序中只有一个分配点。make 调用分配了正确大小的切片后备存储,而 append 永远不需要重新分配。

令人惊讶的是,如果你用通道中的 10 个元素对这个代码进行基准测试,你会发现分配次数并没有减少到 1 次,而是减少到了 0 次

原因是编译器决定将后备存储在上分配。因为它知道需要的大小(10 倍任务大小),所以它可以在 process2 的栈帧中为后备存储分配空间,而不是在堆上¹。注意,这取决于后备存储不会在 processAll 内部逃逸到堆上。

可变大小切片的栈分配

当然,硬编码一个大小猜测有点死板。也许我们可以传入一个预估长度?

func process3(c chan task, lengthGuess int) {
    tasks := make([]task, 0, lengthGuess)
    for t := range c {
        tasks = append(tasks, t)
    }
    processAll(tasks)
}

这让调用者为 tasks 切片选择一个好的大小,这个大小可能根据这段代码的调用位置而有所不同。

不幸的是,在 Go 1.24 中,后备存储的非固定大小意味着编译器不能再在栈上分配后备存储。它最终会在堆上,将我们的 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,从而获得栈分配的后备存储;当猜测较大时,使用可变大小的 make,从堆上分配后备存储。

但在 Go 1.25 中,你无需走上这条丑陋的道路。Go 1.25 编译器会自动为你做这个转换!对于某些切片分配位置,编译器会自动分配一个小的(目前是 32 字节)切片后备存储,并在请求的大小足够小时将其用作 make 的结果。否则,它会像正常一样使用堆分配。

在 Go 1.25 中,如果 lengthGuess 足够小(使得该长度的切片能放入 32 字节),process3 执行零次堆分配。(当然,前提是 lengthGuess 是对 c 中元素数量的正确猜测。)

我们一直在改进 Go 的性能,所以升级到最新的 Go 版本,看看你的程序变得多快、多节省内存吧!

append 分配切片的栈分配

好的,但你仍然不想改变你的 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 中,我们在栈上分配相同的小型推测性后备存储,但现在我们可以直接在 append 处使用它。

在第一次循环迭代中,tasks 没有后备存储,所以 append 使用一个小的栈分配后备存储作为第一次分配。例如,如果我们能在那个后备存储中放 4 个 task,第一次 append 会从栈上分配一个长度为 4 的后备存储。

接下来的 3 次循环迭代直接追加到栈后备存储,不需要任何分配。

在第 4 次迭代中,栈后备存储终于满了,我们不得不去堆上获取更多后备存储。但我们几乎避免了本文前面描述的所有启动开销。没有大小为 1、2、4 的堆分配,也没有它们最终变成的垃圾。如果你的切片很小,也许你永远不会有堆分配。

逃逸切片的 append 分配栈分配

好的,当 tasks 切片不逃逸时,这一切都很好。但如果我要返回这个切片呢?那它就不能在栈上分配了,对吧?

对的!下面 extract 返回的切片的后备存储不能在栈上分配,因为 extract 的栈帧在 extract 返回时就消失了。

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 切片就永远不会逃逸出 extract2。它可以受益于上面描述的所有优化。然后在 extract2 的最后,当我们知道切片的最终大小时,我们做一次所需大小的堆分配,将我们的 task 复制进去,并返回这个副本。

但你真的想写所有这些额外的代码吗?这似乎容易出错。也许编译器可以为我们做这个转换?

在 Go 1.26 中,它可以!

对于逃逸切片,编译器会将原始的 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 是一个特殊的编译器+运行时函数,对于已经在堆上分配的切片,它是恒等函数。对于在栈上的切片,它会在堆上分配一个新的切片,将栈分配的切片复制到堆副本,并返回堆副本。

这保证了对于我们原始的 extract 代码,如果元素数量适合我们的小型栈分配缓冲区,我们执行正好 1 次分配,大小完全正确。如果元素数量超过了我们小型栈分配缓冲区的容量,我们会在栈分配缓冲区溢出后执行正常的倍增分配。

Go 1.26 做的优化实际上比手工优化的代码更好,因为它不需要手工优化代码末尾总是要做的额外分配+复制。它只在我们在返回点之前一直操作栈支持切片的情况下才需要分配+复制。

我们确实需要付出复制的代价,但这个代价几乎完全被我们不再需要做的启动阶段的复制所抵消。(实际上,新方案在最坏的情况下只比旧方案多复制一个元素。)

总结

手工优化仍然是有益的,特别是如果你能提前很好地估计切片大小的话。但希望编译器现在能为你捕捉到许多简单的情况,让你能专注于那些真正重要的剩余情况。

编译器需要确保很多细节才能让所有这些优化正确无误。如果你认为其中某个优化给你的程序带来了正确性或(负面)性能问题,你可以用 -gcflags=all=-d=variablemakehash=n 关闭它们。如果关闭这些优化有帮助,请提交 issue,以便我们进行调查。

脚注

  1. Go 栈没有任何类似 alloca 的机制用于动态大小的栈帧。所有 Go 栈帧都是固定大小的。