bigywater.tech
Go · 12

逃逸分析:一个变量什么时候会从栈搬到堆

一个变量放在栈上还是堆上,不由 new 或者 &T{} 这样的写法决定,而是由编译器的逃逸分析决定。这篇先读 -m 的诊断,再用 AllocsPerRun 数出真实的分配次数,发现两者并不总是一致;然后量出栈上限、对照内联的影响,最后量一量逃逸到底多花多少。

128 KiB / 64 KiB
显式局部变量 / 隐式分配(make、new、&T{})能留在栈上的上限(二分法实测)
32 字节
变长 make 的栈上缓冲:n=4 个 int 零分配,n=5 个就分配 1 次
3.3 倍
同样调用 500 万次,逃到堆上的 33.3 ms,留在栈上的 10.1 ms

九种写法的逃逸结论

实测 · 本机执行输出
  源码(省略 main):
      1 | package main
      2 | 
      3 | import (
      4 | 	"fmt"
      5 | 	"io"
      6 | )
      7 | 
      8 | type Point struct{ X, Y int }
      9 | 
     10 | //go:noinline
     11 | func byValue() int { p := Point{1, 2}; return p.X + p.Y }
     12 | 
     13 | //go:noinline
     14 | func returnPtr() *Point { p := Point{1, 2}; return &p }
     15 | 
     16 | //go:noinline
     17 | func toInterface() { p := Point{1, 2}; fmt.Fprintln(io.Discard, p) }
     18 | 
     19 | //go:noinline
     20 | func smallMake() int { s := make([]int, 10); s[0] = 1; return s[0] }
     21 | 
     22 | //go:noinline
     23 | func varMake(n int) int { s := make([]int, n); s[0] = 1; return s[0] }
     24 | 
     25 | //go:noinline
     26 | func closure() func() int { x := 0; return func() int { x++; return x } }
     27 | 
     28 | //go:noinline
     29 | func make64k() int { s := make([]int, 8000); s[0] = 1; return s[0] }
     30 | 
     31 | //go:noinline
     32 | func make65k() int { s := make([]int, 8200); s[0] = 1; return s[0] }
     33 | 
     34 | //go:noinline
     35 | func arr8M() int { var a [1 << 20]int; a[0] = 1; return a[0] }
     36 | 
     37 | func main() {
     38 | 	fmt.Println(byValue(), returnPtr().X, smallMake(), varMake(10), closure()(), make64k(), make65k(), arr8M())
     39 | 	toInterface()
  编译器诊断(只列 11–38 行的函数体,省略 main 里的):
  main.go:14:27: moved to heap: p    ← func returnPtr() *Point { p := Point{1, 2}; return &p }
  main.go:17:52: ... argument does not escape    ← func toInterface() { p := Point{1, 2}; fmt.Fprintln(io.Discard, p) }
  main.go:17:65: p escapes to heap    ← func toInterface() { p := Point{1, 2}; fmt.Fprintln(io.Discard, p) }
  main.go:20:33: make([]int, 10) does not escape    ← func smallMake() int { s := make([]int, 10); s[0] = 1; return s[0] }
  main.go:23:36: make([]int, n) does not escape    ← func varMake(n int) int { s := make([]int, n); s[0] = 1; return s[0] }
  main.go:26:29: moved to heap: x    ← func closure() func() int { x := 0; return func() int { x++; return x } }
  main.go:26:44: func literal escapes to heap    ← func closure() func() int { x := 0; return func() int { x++; return x } }
  main.go:29:31: make([]int, 8000) does not escape    ← func make64k() int { s := make([]int, 8000); s[0] = 1; return s[0] }
  main.go:32:31: make([]int, 8200) escapes to heap    ← func make65k() int { s := make([]int, 8200); s[0] = 1; return s[0] }
  main.go:35:24: moved to heap: a    ← func arr8M() int { var a [1 << 20]int; a[0] = 1; return a[0] }
  运行 rc=0,输出:3 1 1 1 1 1 1 1

编译器的措辞有三种:moved to heap: p 说的是变量 p 本身被搬到堆上;… escapes to heap 说的是某个表达式的值(make 的结果、转成接口的值、函数字面量)要放在堆上;does not escape 是留在栈上。第 17 行的 p escapes to heap 说的是“把 p 转成接口这一步”,它并不等于运行时一定有一次分配,下一节会看到。

真实分配次数:-m 说逃逸,运行时不一定分配

实测 · 本机执行输出
  byValue                               0 次/调用
  returnPtr                             1 次/调用
  toInterface(常量 Point{1,2})            0 次/调用
  ifacePoint(n=1000) Point{n,2}         1 次/调用
  ifaceInt(n=7)                         0 次/调用
  ifaceInt(n=255)                       0 次/调用
  ifaceInt(n=256)                       1 次/调用
  ifaceInt(n=1000)                      1 次/调用
  smallMake  make(10)                   0 次/调用
  staticN  n:=10; make(n)               0 次/调用
  staticBig  n:=1000; make(n)           0 次/调用
  closure(返回闭包)                         2 次/调用
  make64k  make(8000)                   0 次/调用
  make65k  make(8200)                   1 次/调用
  arr8M   [1<<20]int                    1 次/调用
  varMake n=1(8 字节)                     0 次/调用
  varMake n=2(16 字节)                    0 次/调用
  varMake n=4(32 字节)                    0 次/调用
  varMake n=5(40 字节)                    1 次/调用
  varMake n=8(64 字节)                    1 次/调用
  varMake n=16(128 字节)                  1 次/调用
  varMake n=64(512 字节)                  1 次/调用
  varMake n=1000(8000 字节)               1 次/调用
  varMake n=8192(65536 字节)              1 次/调用
  varMake n=8193(65544 字节)              1 次/调用
  同一份代码加 -gcflags=-d=variablemakethreshold=1024(把变长 make 的栈缓冲从 32 字节调到 1024 字节),只看 varMake:
  varMake n=1(8 字节)                     0 次/调用
  varMake n=2(16 字节)                    0 次/调用
  varMake n=4(32 字节)                    0 次/调用
  varMake n=5(40 字节)                    0 次/调用
  varMake n=8(64 字节)                    0 次/调用
  varMake n=16(128 字节)                  0 次/调用
  varMake n=64(512 字节)                  0 次/调用
  varMake n=1000(8000 字节)               1 次/调用
  varMake n=8192(65536 字节)              1 次/调用
  varMake n=8193(65544 字节)              1 次/调用
  同一份代码加 -gcflags=-N(关闭优化),只看 varMake 与 smallMake:
  smallMake  make(10)                   0 次/调用
  staticN  n:=10; make(n)               0 次/调用
  varMake n=1(8 字节)                     1 次/调用
  varMake n=4(32 字节)                    1 次/调用

三个出乎意料的结果。第一,toInterface 传的是常量 Point{1,2},编译器直接指向只读数据,0 次分配;换成运行时才知道的值就是 1 次。我在编译器源码 walk/convert.go 里读到同一个函数的几个分支:零大小用 zerobase、单字节或布尔用 staticuint64s 表、只读全局直接用,最后才调用运行时分配。int 的 255 / 256 分界来自 runtime/iface.go 的 convT64:值小于 len(staticuint64s)(256)时直接取表项,否则调 mallocgc,实测与之一致。第二,变长 make([]int, n) 的诊断是 does not escape,但并不保证零分配:源码(walk/builtin.go 的 walkMakeSlice 与 base/flag.go 里的 VariableMakeThreshold = 32)写的是“如果 cap ≤ K,就用栈上的 [K]E,否则调 makeslice”,K 由 32 字节除以元素大小得到——int 是 8 字节,所以 K = 4,实测正是 n=4 零分配、n=5 一次分配。第三,-d=variablemakethreshold=1024 把阈值调大后 n=64 也是零分配,-N 关闭优化后变长 make 就回到一次分配,而常量长度的 make(10) 在 -N 下仍是零。

栈上能放多大

实测 · 本机执行输出
  var a [N]byte(显式声明的局部数组):131072 字节仍在栈上,131073 字节被放到堆上(= 128 KiB + 0 字节为界)
  make([]byte, N)(常量长度):65536 字节仍在栈上,65537 字节被放到堆上(= 64 KiB + 0 字节为界)
  new([N]byte):65536 字节仍在栈上,65537 字节被放到堆上(= 64 KiB + 0 字节为界)
  &[N]byte{}(取地址的复合字面量):65536 字节仍在栈上,65537 字节被放到堆上(= 64 KiB + 0 字节为界)

数字和源码里的两个常量对得上:ir/cfg.go 里 MaxStackVarSize 是 128 KiB(显式的 var x T),MaxImplicitStackVarSize 是 64 KiB(new(T)、&T{}、make([]T, n))。二分法的结果是恰好以字节为界:131072 与 65536 在栈上,多一个字节就去了堆。注意这里是 arm64 上 Go 1.27.1 的数字,其它版本的上限可能不同。

flow 链:编译器为什么这么判断

实测 · 本机执行输出
  returnPtr(第 14 行 p):
    main.go:14:27: p escapes to heap in returnPtr:
    main.go:14:27:   flow: ~r0 ← &p:
    main.go:14:27:     from &p (address-of) at main.go:14:52
    main.go:14:27:     from return &p (return) at main.go:14:45
    main.go:14:27: moved to heap: p
  toInterface(第 17 行 p):
    main.go:17:65: p escapes to heap in toInterface:
    main.go:17:65:   flow: {storage for ... argument} ← &{storage for p}:
    main.go:17:65:     from p (spill) at main.go:17:65
    main.go:17:65:     from ... argument (slice-literal-element) at main.go:17:52
    main.go:17:65:   flow: {heap} ← {storage for ... argument}:
    main.go:17:65:     from ... argument (spill) at main.go:17:52
    main.go:17:65:     from fmt.Fprintln(io.Discard, ... argument...) (call parameter) at main.go:17:52
  closure(第 26 行 x):
    main.go:26:29: x escapes to heap in closure:
    main.go:26:29:   flow: {storage for func literal} ← &x:
    main.go:26:29:     from x (captured by a closure) at main.go:26:57
    main.go:26:29:     from x (reference) at main.go:26:57
    main.go:26:29: moved to heap: x
  make65k(第 32 行):
    main.go:32:31: make([]int, 8200) escapes to heap in make65k:
    main.go:32:31:   flow: {heap} ← &{storage for make([]int, 8200)}:
    main.go:32:31:     from make([]int, 8200) (too large for stack) at main.go:32:31

-m -m 的 flow 链从右往左读:flow: ~r0 ← &p 是“返回值 ~r0 取得了 p 的地址”,所以 p 的生命周期要超过函数。接口参数那条链里出现了 {heap},意思是它流进了编译器无法证明不逃逸的调用(fmt.Fprintln 的 ...any 参数)。too large for stack 是大小原因,不涉及数据流。看到 moved to heap 时,往上找 flow 链里第一个“address-of”和最后一个流向,就能定位是哪一行造成的。

同一段代码内联与否结论相反

实测 · 本机执行输出
  默认(newPoint 被内联进 sum):
    main.go:10:6: can inline newPoint    ← func newPoint() *Point { return &Point{1, 2} }
    main.go:13:31: inlining call to newPoint    ← func sum() int { p := newPoint(); return p.X + p.Y }
    main.go:10:33: &Point{...} escapes to heap    ← func newPoint() *Point { return &Point{1, 2} }
    main.go:13:31: &Point{...} does not escape    ← func sum() int { p := newPoint(); return p.X + p.Y }
    sum() 每次调用的分配次数:0
  -gcflags=-l(关闭内联):
    main.go:10:33: &Point{...} escapes to heap    ← func newPoint() *Point { return &Point{1, 2} }
    sum() 每次调用的分配次数:1

逃逸分析是在函数边界上做的。newPoint 返回指针,在它自己的诊断里永远逃逸;但被内联进 sum 之后,指针没有离开 sum,这个值就留在了栈上。所以排查分配问题时,-gcflags=-l 能帮你分辨“这次分配是不是只因为某个调用没被内联”。

逃逸的代价

实测 · 本机执行输出
  byValue(栈)     500 万次:耗时中位 10.1 ms(最小 9.7 / 最大 12.9);Mallocs +2,TotalAlloc +80 字节,GC 次数 +0
  returnPtr(堆)   500 万次:耗时中位 33.3 ms(最小 33.0 / 最大 33.8);Mallocs +5000003,TotalAlloc +80000288 字节,GC 次数 +21

代价有两层:每次调用多一次 mallocgc(这里 16 字节),以及这些堆对象带来的 GC 工作。上面的 33.3 ms 里包含了这 21 次 GC 的成本,不能全算成分配本身。byValue 每次调用约 2 纳秒(10.1 ms / 500 万),这是 //go:noinline 函数调用本身的量级;不要把“3.3 倍”套到别的程序上,它只说明在这个最小例子里,逃逸把一次几乎没有成本的调用变成了一次分配加回收。

分步演示

逃逸分析
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。逃逸诊断与阈值均在 arm64 上用 Go 1.27.1 实测;读过的编译器源码在 brew 安装目录 libexec/src/cmd/compile/internal 下:ir/cfg.go(两个栈上限常量)、walk/convert.go(接口转换的免分配分支)、walk/builtin.go 与 base/flag.go(变长 make 的 K 与 32 字节阈值)、escape/utils.go 与 escape/escape.go(变长 make 的“混合”做法、把静态已知的长度改写成字面量);耗时是 5 轮取中位数,每次运行会有抖动
    • 没有验证:amd64 与 32 位平台上的栈上限和变长 make 阈值、Go 1.27 以前的版本是否有变长 make 的栈缓冲、-race / -msan 下的逃逸结论、cgo 指针传递规则、栈增长(大栈帧会触发复制整个栈)的代价、泛型函数的逃逸分析,以及 GC 触发节奏本身(见后面的 GC 相关文章)
    京ICP备2026067502号-1京公网安备11010502063487号