你为什么不应该过度关注go语言的逃逸分析

逃逸分析算是go语言的特色之一,编译器自动分析变量/内存应该分配在栈上还是堆上,程序员不需要主动关心这些事情,保证了内存安全的同时也减轻了程序员的负担。

然而这个“减轻负担”的特性现在却成了程序员的心智负担。尤其是各路八股文普及之后,逃逸分析相关的问题在面试里出现的频率越来越高,不会往往意味着和工作机会失之交臂,更有甚者会认为不了解逃逸分析约等于不会go。

我很不喜欢这些现象,不是因为我不会go,而是我知道逃逸分析是个啥情况:分析规则有版本间差异、规则过于保守很多时候把可以在栈上的变量逃逸到堆上、规则繁杂导致有很多corner case等等。更不提有些质量欠佳的八股在逃逸分析的描述上还有误导了。

所以我建议大部分人回归逃逸分析的初心——对于程序员来说逃逸分析应该就像是透明的,不要过度关心它。

怎么知道变量是不是逃逸了

我还见过一些比背过时的八股文更过分的情况:一群人围着一段光秃秃的代码就变量到底会不会逃逸争得面红耳赤。

他们甚至没有用go编译器自带的验证方法来论证自己的观点。

那样的争论是没有意义的,你应该用下面的命令来检查编译器逃逸分析的结果:

$ go build -gcflags=-m=2 a.go

# command-line-arguments
./a.go:5:6: cannot inline main: function too complex: cost 104 exceeds budget 80
./a.go:12:20: inlining call to fmt.Println
./a.go:12:21: num escapes to heap:
./a.go:12:21:   flow: {storage for ... argument} = &{storage for num}:
./a.go:12:21:     from num (spill) at ./a.go:12:21
./a.go:12:21:     from ... argument (slice-literal-element) at ./a.go:12:20
./a.go:12:21:   flow: fmt.a = &{storage for ... argument}:
./a.go:12:21:     from ... argument (spill) at ./a.go:12:20
./a.go:12:21:     from fmt.a := ... argument (assign-pair) at ./a.go:12:20
./a.go:12:21:   flow: {heap} = *fmt.a:
./a.go:12:21:     from fmt.Fprintln(os.Stdout, fmt.a...) (call parameter) at ./a.go:12:20
./a.go:7:19: make([]int, 10) does not escape
./a.go:12:20: ... argument does not escape
./a.go:12:21: num escapes to heap

哪些东西逃逸了哪些没有显示得一清二楚——escapes to heap表示变量或表达式逃逸了,does not escape则表示没有发生逃逸。

另外本文讨论的是go官方的gc编译器,像一些第三方编译器比如tinygo没义务也没理由使用和官方完全相同的逃逸规则——这些规则并不是标准的一部分也不适用于某些特殊场景。

本文的go版本是1.23,我也不希望未来某一天有人用1.1x或者1.3x版本的编译器来问我为啥实验结果不一样了。

八股文里的问题

先声明,对事不对人,愿意分享信息的精神还是值得尊敬的。

不过分享之前至少先做点简单的验证,不然那些倒果为因还有胡言乱语的内容就止增笑耳了。

编译期不知道大小的东西会逃逸

这话其实没说错,但很多八股文要么到这里结束了,要么给出一个很多时候其实不逃逸的例子然后做一大通令人捧腹的解释。

比如:

package main

import "fmt"

type S struct {}

func (*S) String() string { return "hello" }

type Stringer interface {
        String() string
}

func getString(s Stringer) string {
        if s == nil {
                return "<nil>"
        }
        return s.String()
}

func main() {
        s := &S{}
        str := getString(s)
        fmt.Println(str)
}

一些八股文会说getString的参数s在编译期很难知道实际类型是什么,所以大小不好确定,所以会导致传给它的参数逃逸。

这话对吗?对也不对,因为编译期这个时间段太宽泛了,一个interface在“编译期”的前半段时间不知道实际类型,但后半段就有可能知道了。所以关键在于逃逸分析在什么时候进行,这直接决定了类型为接口的变量的逃逸分析结果。

我们验证一下:

# command-line-arguments
...
./b.go:22:18: inlining call to getString
...
./b.go:22:18: devirtualizing s.String to *S
...
./b.go:23:21: str escapes to heap:
./b.go:23:21:   flow: {storage for ... argument} = &{storage for str}:
./b.go:23:21:     from str (spill) at ./b.go:23:21
./b.go:23:21:     from ... argument (slice-literal-element) at ./b.go:23:20
./b.go:23:21:   flow: fmt.a = &{storage for ... argument}:
./b.go:23:21:     from ... argument (spill) at ./b.go:23:20
./b.go:23:21:     from fmt.a := ... argument (assign-pair) at ./b.go:23:20
./b.go:23:21:   flow: {heap} = *fmt.a:
./b.go:23:21:     from fmt.Fprintln(os.Stdout, fmt.a...) (call parameter) at ./b.go:23:20
./b.go:21:14: &S{} does not escape
./b.go:23:20: ... argument does not escape
./b.go:23:21: str escapes to heap

我只截取了关键信息,否则杂音太大。&S{} does not escape这句直接告诉我们getString的参数并没有逃逸。

为啥?因为getString被内联了,内联后编译器发现参数的实际类型就是S,所以devirtualizing s.String to *S做了去虚拟化,这下接口的实际类型编译器知道了,所以没有让参数逃逸的必要了。

而str逃逸了,str的类型是已知的,内容也是常量字符串,按八股文的理论不是不应该逃逸么?其实上面的信息也告诉你为什么了,因为fmt.Println内部的一些函数没法内联,而它们又用any去接受参数,这时候编译器没法做去虚拟化,没法最终确定变量的真实大小,所以str只能逃逸了。记得最开头我说的吗,逃逸分析是很保守的,因为内存安全和程序的正确性是第一位的。

如果禁止函数inline,情况就不同了,我们在go里可以手动禁止一个函数被内联:

+//go:noinline
func getString(s Stringer) string {
        if s == nil {
                return "<nil>"
        }
        return s.String()
}

这回再看结果:

# command-line-arguments
./b.go:14:6: cannot inline getString: marked go:noinline
...
./b.go:22:14: &S{} escapes to heap:
./b.go:22:14:   flow: s = &{storage for &S{}}:
./b.go:22:14:     from &S{} (spill) at ./b.go:22:14
./b.go:22:14:     from s := &S{} (assign) at ./b.go:22:11
./b.go:22:14:   flow: {heap} = s:
./b.go:22:14:     from s (interface-converted) at ./b.go:23:19
./b.go:22:14:     from getString(s) (call parameter) at ./b.go:23:18
./b.go:22:14: &S{} escapes to heap
./b.go:24:20: ... argument does not escape
./b.go:24:21: str escapes to heap

getString没法内联,所以没法做去虚拟化,最后无法在逃逸分析前得知变量的大小,所以作为参数的s最后逃逸了。

因此“编译期”这个表述不太对,正确的应该是“在逃逸分析执行时不能知道确切大小的变量/内存分配会逃逸”。还有一点要注意:内联和一部分内置函数/语句的改写发生在逃逸分析之前。内联是什么大家应该知道,改写改天有空了再好好介绍。

而且go对于什么能在逃逸分析前计算出来也是比较随性的:

func main() {
        arr := [4]int{}
        slice := make([]int, 4)
        s1 := make([]int, len(arr)) // not escape
        s2 := make([]int, len(slice)) // escape
}

s1不逃逸但s2逃逸,因为len在计算数组的长度时会直接返回一个编译期常量。而len计算slice的长度时并不能在编译期完成计算,所以即使我们很清楚slice此时的长度就是4,但go还是会认为s2的大小不能在逃逸分析前就确定。

这也是为什么我告诫大家不要过度关心逃逸分析这东西,很多时候它是反常识的。

编译期知道大小就不会逃逸吗

有的八股文基于上一节的现象,得出了下面这样的结论:make([]T, 常数)不会逃逸。

我觉得一个合格的go或者c/c++/rust程序员应该马上近乎本能地反驳:不逃逸就会分配在栈上,栈空间通常有限(系统栈通常8-10M,goroutine则是固定的1G),如果这个make需要的内存空间大小超过了栈的上限呢?

很显然超过了上限就会逃逸到堆上,所以上面那句不太对。go当然有规定一次在栈空间上分配内存的上限,这个上限也远小于栈大小的上限,但我不会告诉你是多少,因为没人保证以后不会改,而且我说了,你关心这个并没有什么用。

还有一种经典的情况,make生成的内容做返回值:

func f1() []int {
        return make([]int, 64)
}

逃逸分析会给出这样的结果:

# command-line-arguments
...
./c.go:6:13: make([]int, 64) escapes to heap:
./c.go:6:13:   flow: ~r0 = &{storage for make([]int, 64)}:
./c.go:6:13:     from make([]int, 64) (spill) at ./c.go:6:13
./c.go:6:13:     from return make([]int, 64) (return) at ./c.go:6:2
./c.go:6:13: make([]int, 64) escapes to heap

这没什么好意外的,因为返回值要在函数调用结束后继续被使用,所以它只能在堆上分配。这也是逃逸分析的初衷。

不过因为这个函数太简单了,所以总是能内联,一旦内联,这个make就不再是返回值,所以编译器有机会不让它逃逸。你可以用上一节教的//go:noinline试试。

slice的元素数量和是否逃逸关系不大

还有的八股会这么说:“slice里的元素数量太多会导致逃逸”,还有些八股文还会信誓旦旦地说这个数量限制是什么10000、十万。

那好,我们看个例子:

package main

import "fmt"

func main() {
        a := make([]int64, 10001)
        b := make([]byte, 10001)
        fmt.Println(len(a), len(b))
}

分析结果:

...
./c.go:6:11: make([]int64, 10001) escapes to heap:
./c.go:6:11:   flow: {heap} = &{storage for make([]int64, 10001)}:
./c.go:6:11:     from make([]int64, 10001) (too large for stack) at ./c.go:6:11
...
./c.go:6:11: make([]int64, 10001) escapes to heap
./c.go:7:11: make([]byte, 10001) does not escape
...

怎么元素数量一样,一个逃逸了一个没有?说明了和元素数量就没关系,只和上一节说的栈上对内存分配大小有限制,超过了才会逃逸,没超过你分配一亿个元素都行。

关键是这种无聊的问题出镜率还不低,我和我朋友都遇到过这种:

make([]int, 10001)

就问你这个东西逃逸不逃逸,面试官估计忘了int长度不是固定的,32位系统上它是4字节,64位上是8字节,所以没有更多信息之前这个问题没法回答,你就是把Rob Pike抓来他也只能摇头。面试遇到了还能和面试官掰扯掰扯,笔试遇到了你怎么办?

这就是我说的倒果为因,slice和数组会逃逸不是因为元素数量多,而是消耗的内存(元素大小x数量)超过了规定的上限。

new和make在逃逸分析时几乎没区别

有的八股文还说new的对象经常逃逸而make不会,所以应该尽量少用new。

这是篇老八股了,现在估计没人会看,然而就算在当时这句话也是错的。我想大概是八股作者不经验证就把Java/c++里的知识嫁接过来了。

我得澄清一下,new和make确实非常不同,但只不同在两个地方:

  1. new(T)返回*T,而make(T, ...)返回T
  2. new(T)中T可以是任意类型(但slice呀接口什么的一般不建议),而make(T, ...)的T只能是slice、map或者chan。

就这两个,另外针对slice之类的东西它们在初始化的具体方式上有一点区别,但这勉强包含在第二点里了。

所以绝不会出现new更容易导致逃逸,new和make一样,会不会逃逸只受大小限制以及可达性的影响。

看个例子:

package main

import "fmt"

func f(i int) int {
        ret := new(int)
        *ret = 1
        for j := 1; j <= i; j++ {
                *ret *= j
        }
        return *ret
}

func main() {
        num := f(5)
        fmt.Println(num)
}

结果:

./c.go:5:6: can inline f with cost 20 as: func(int) int { ret := new(int); *ret = 1; for loop; return *ret }
...
./c.go:15:10: inlining call to f
./c.go:16:13: inlining call to fmt.Println
./c.go:6:12: new(int) does not escape
...
./c.go:15:10: new(int) does not escape
./c.go:16:13: ... argument does not escape
./c.go:16:14: num escapes to heap

看到new(int) does not escape了吗,流言不攻自破。

不过为了防止有人较真,我得稍微介绍一点实现细节:虽然new和make在逃逸分析上差异不大,但当前版本的go对make的大小限制更严格,这么看的话那个八股还是错的,因为make导致逃逸的概率稍大于new。所以该用new就用,不需要在意这些东西。

编译优化太弱鸡拖累逃逸分析

这两年go语言有两个让我对逃逸分析彻底失去兴趣的提交,第一个是:7015ed

改动就是给一个局部变量加了别名,这样编译器就不会让这个局部变量错误地逃逸了。

为啥编译器会让这个变量逃逸?和编译器实现可达性分析的算法有关,也和编译器没做优化导致分析精度降低有关。

如果你碰到了这种问题,你能想出这种修复手段吗?我反正是不能,因为这个提交这么做是有开发和维护编译器的大佬深入研究之后才定位问题并提出可选方案的,对普通人来说恐怕都想不明白问题出在哪。

另一个是我在1.24开发周期里遇到的。这个提交为了添加新功能对time.Time做了点小修改,以前的代码这样:

func (t Time) MarshalText() ([]byte, error) {
        b := make([]byte, 0, len(RFC3339Nano))
        b, err := t.appendStrictRFC3339(b)
        if err != nil {
                return nil, errors.New("Time.MarshalText: " + err.Error())
        }
        return b, nil
}

新的长这样:

func (t Time) appendTo(b []byte, errPrefix string) ([]byte, error) {
	b, err := t.appendStrictRFC3339(b)
	if err != nil {
		return nil, errors.New(errPrefix + err.Error())
	}
	return b, nil
}

func (t Time) MarshalText() ([]byte, error) {
	return t.appendTo(make([]byte, 0, len(RFC3339Nano)), "Time.MarshalText: ")
}

其实就是开发者要复用里面的逻辑,所以抽出来单独做了一个子函数,核心内容都没变。

然而看起来没啥本质区别的新代码,却显示MarshalText的性能提升了40%。

怎么回事呢,因为现在MarshalText变简单了,所以能在很多地方被内联,而appendTo本身不分配内存,这就导致原先作为返回值的buf因为MarshalText能内联,编译器发现它在外部调用它的地方并不需要作为返回值而且大小已知,因此适用第二节里我们说到的情况,buf并不需要逃逸。不逃逸意味着不需要分配堆内存,性能自然就提高了。

这当然得赖go过于孱弱的内联优化,它创造出了在c++里几乎不可能出现的优化机会(appendTo就是个包装,还多了一个参数,正常内联展开后和原先的代码几乎不会有啥区别)。这在别的语言里多少有点反常识,所以一开始我以为提交里的描述有问题,花了大把时间排查加测试,才想到是内联可能影响了逃逸分析,一个下午都浪费在这上面了。

这类问题太多太多,issue里就有不少,如果你不了解编译器具体做了什么工作用了什么算法,排查解决这些问题是很困难的。

还记得开头说的么,逃逸分析是要减轻程序员的负担的,现在反过来要程序员深入了解编译器,有点本末倒置了。

这两个提交最终让我开始重新思考开发者需要对逃逸分析了解到多深这个问题。

该怎么做

其实还有很多对逃逸分析的民间传说,我懒得一一证实/证伪了。下面只说在逃逸分析本身就混乱而复杂的情况下,作为开发者该怎么做。

对于大多数开发者:和标题一样,不要过度关注逃逸分析。逃逸分析应该是提升你效率的翅膀而不是写代码时的桎梏。

毕竟光看代码,你很难分析出个所以然来,编译期知道大小可能会逃逸,看起来不知道大小的也可能不会逃逸,看起来相似的代码性能却天差地别,中间还得穿插可达性分析和一些编译优化,corner case多到超乎想象。写代码的时候想着这些东西,效率肯定高不了。

每当自己要想逃逸分析如何如何的时候,可以用下面的步骤帮助自己摆脱对逃逸分析的依赖:

  1. 变量的生命周期是否长于创建它的函数?
  2. 如果是,那么能选用返回“值”代替返回指针吗,函数能被内联或者值的尺寸比较小时复制的开销几乎是可以忽略不计的;
  3. 如果不是或者你发现设计可以修改使得变量的生命周期没有那么长,则往下
  4. 函数是否是性能热点?
  5. 如果不是那么到此为止,否则你需要用memprofile和cpuprofile来确定逃逸带来了多少损失
  6. 性能热点里当然越少逃逸越好,但如果逃逸带来的损失本身不是很大,那么就不值得继续往下了
  7. 复用堆内存往往比避免逃逸更简单也更直观,试试sync.Pool之类的东西而不是想着避免逃逸
  8. 到了这一步,你不得不用-gcflags=-m=2看看为什么发生逃逸了,有些原因很明显,可以被优化
  9. 对于那些你看不懂为什么逃逸的,要么就别管了要么用go以外的手段(比如汇编)解决。
  10. 求助他人也是可以的,但前提是他们不是机械式地背背八股文。

总之,遵守一些常见的规定比如在知道slice大小的情况下提前分配内存、设计短小精悍的函数、少用指针等等,你几乎没啥研究逃逸分析的必要。

对于编译器、标准库、某些性能要求较高的程序的开发者来说,了解逃逸分析是必要的。因为go的性能不是很理想,所以得抓住一切能利用的优化机会提升性能。比如我往标准库塞新功能的时候就被要求过一些函数得是“零分配”的。当然我没有上来就研究逃逸,而是先写了测试并研究了profile,之后才用逃逸分析的结果做了更进一步的优化。

总结

这篇文章其实还有一些东西没说,比如数组和闭包在逃逸分析的表现。总体上它们的行为没有和别的变量差太多,在看看文章的标题——所以我不建议过度关注它们的逃逸分析。

所以说,你不应该过度关心逃逸分析。也应该停止背/搬运/编写有关逃逸分析的八股文。

大部分人关心逃逸分析,除了面试之外就是为了性能,我常说的是性能分析一定要结合profile和benchmark,否则凭空臆断为了不逃逸而削足适履,不仅浪费时间对性能问题也没有丝毫帮助。

话说回来,不深入了解逃逸分析和不知道有逃逸分析这东西可是两回事,后者确实约等于go白学了。