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

打印 上一主题 下一主题

主题 891|帖子 891|积分 2673

逃逸分析算是go语言的特色之一,编译器自动分析变量/内存应该分配在栈上还是堆上,步调员不必要自动关心这些事变,保证了内存安全的同时也减轻了步调员的负担。
然而这个“减轻负担”的特性现在却成了步调员的心智负担。尤其是各路八股文普及之后,逃逸分析相关的问题在口试里出现的频率越来越高,不会每每意味着和工作机会失之交臂,更有甚者会以为不了解逃逸分析约等于不会go。
我很不喜欢这些现象,不是因为我不会go,而是我知道逃逸分析是个啥环境:分析规则有版本间差异、规则过于保守很多时间把可以在栈上的变量逃逸到堆上、规则繁杂导致有很多corner case等等。更不提有些质量欠佳的八股在逃逸分析的描述上另有误导了。
所以我发起大部分人回归逃逸分析的初心——对于步调员来说逃逸分析应该就像是透明的,不要过度关心它。
怎么知道变量是不是逃逸了

我还见过一些比背过时的八股文更过分的环境:一群人围着一段光秃秃的代码就变量到底会不会逃逸争得面红耳赤。
他们甚至没有用go编译器自带的验证方法来论证自己的观点。
那样的争论是没故意义的,你应该用下面的下令来检查编译器逃逸分析的结果:
  1. $ go build -gcflags=-m=2 a.go
  2. # command-line-arguments
  3. ./a.go:5:6: cannot inline main: function too complex: cost 104 exceeds budget 80
  4. ./a.go:12:20: inlining call to fmt.Println
  5. ./a.go:12:21: num escapes to heap:
  6. ./a.go:12:21:   flow: {storage for ... argument} = &{storage for num}:
  7. ./a.go:12:21:     from num (spill) at ./a.go:12:21
  8. ./a.go:12:21:     from ... argument (slice-literal-element) at ./a.go:12:20
  9. ./a.go:12:21:   flow: fmt.a = &{storage for ... argument}:
  10. ./a.go:12:21:     from ... argument (spill) at ./a.go:12:20
  11. ./a.go:12:21:     from fmt.a := ... argument (assign-pair) at ./a.go:12:20
  12. ./a.go:12:21:   flow: {heap} = *fmt.a:
  13. ./a.go:12:21:     from fmt.Fprintln(os.Stdout, fmt.a...) (call parameter) at ./a.go:12:20
  14. ./a.go:7:19: make([]int, 10) does not escape
  15. ./a.go:12:20: ... argument does not escape
  16. ./a.go:12:21: num escapes to heap
复制代码
哪些东西逃逸了哪些没有显示得一清二楚——escapes to heap表现变量或表达式逃逸了,does not escape则表现没有发生逃逸。
别的本文讨论的是go官方的gc编译器,像一些第三方编译器比如tinygo没义务也没来由使用和官方完全相同的逃逸规则——这些规则并不是标准的一部分也不适用于某些特殊场景。
本文的go版本是1.23,我也不盼望未来某一天有人用1.1x或者1.3x版本的编译器来问我为啥实验结果不一样了。
八股文里的问题

先声明,对事不对人,乐意分享信息的精神还是值得尊敬的。
不过分享之前至少先做点简单的验证,不然那些倒果为因另有胡言乱语的内容就止增笑耳了。
编译期不知道巨细的东西会逃逸

这话其实没说错,但很多八股文要么到这里结束了,要么给出一个很多时间其实不逃逸的例子然后做一大通令人捧腹的解释。
比如:
  1. package main
  2. import "fmt"
  3. type S struct {}
  4. func (*S) String() string { return "hello" }
  5. type Stringer interface {
  6.         String() string
  7. }
  8. func getString(s Stringer) string {
  9.         if s == nil {
  10.                 return "<nil>"
  11.         }
  12.         return s.String()
  13. }
  14. func main() {
  15.         s := &S{}
  16.         str := getString(s)
  17.         fmt.Println(str)
  18. }
复制代码
一些八股文会说getString的参数s在编译期很难知道实际类型是什么,所以巨细不好确定,所以会导致传给它的参数逃逸。
这话对吗?对也不对,因为编译期这个时间段太宽泛了,一个interface在“编译期”的前半段时间不知道实际类型,但后半段就有大概知道了。所以关键在于逃逸分析在什么时间进行,这直接决定了类型为接口的变量的逃逸分析结果。
我们验证一下:
  1. # command-line-arguments
  2. ...
  3. ./b.go:22:18: inlining call to getString
  4. ...
  5. ./b.go:22:18: devirtualizing s.String to *S
  6. ...
  7. ./b.go:23:21: str escapes to heap:
  8. ./b.go:23:21:   flow: {storage for ... argument} = &{storage for str}:
  9. ./b.go:23:21:     from str (spill) at ./b.go:23:21
  10. ./b.go:23:21:     from ... argument (slice-literal-element) at ./b.go:23:20
  11. ./b.go:23:21:   flow: fmt.a = &{storage for ... argument}:
  12. ./b.go:23:21:     from ... argument (spill) at ./b.go:23:20
  13. ./b.go:23:21:     from fmt.a := ... argument (assign-pair) at ./b.go:23:20
  14. ./b.go:23:21:   flow: {heap} = *fmt.a:
  15. ./b.go:23:21:     from fmt.Fprintln(os.Stdout, fmt.a...) (call parameter) at ./b.go:23:20
  16. ./b.go:21:14: &S{} does not escape
  17. ./b.go:23:20: ... argument does not escape
  18. ./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里可以手动禁止一个函数被内联:
  1. +//go:noinline
  2. func getString(s Stringer) string {
  3.         if s == nil {
  4.                 return "<nil>"
  5.         }
  6.         return s.String()
  7. }
复制代码
这回再看结果:
  1. # command-line-arguments
  2. ./b.go:14:6: cannot inline getString: marked go:noinline
  3. ...
  4. ./b.go:22:14: &S{} escapes to heap:
  5. ./b.go:22:14:   flow: s = &{storage for &S{}}:
  6. ./b.go:22:14:     from &S{} (spill) at ./b.go:22:14
  7. ./b.go:22:14:     from s := &S{} (assign) at ./b.go:22:11
  8. ./b.go:22:14:   flow: {heap} = s:
  9. ./b.go:22:14:     from s (interface-converted) at ./b.go:23:19
  10. ./b.go:22:14:     from getString(s) (call parameter) at ./b.go:23:18
  11. ./b.go:22:14: &S{} escapes to heap
  12. ./b.go:24:20: ... argument does not escape
  13. ./b.go:24:21: str escapes to heap
复制代码
getString没法内联,所以没法做去假造化,最后无法在逃逸分析前得知变量的巨细,所以作为参数的s最后逃逸了。
因此“编译期”这个表述不太对,正确的应该是“在逃逸分析实行时不能知道确切巨细的变量/内存分配会逃逸”。另有一点要注意:内联和一部分内置函数/语句的改写发生在逃逸分析之前。内联是什么大家应该知道,改写改天有空了再好好先容。
而且go对于什么能在逃逸分析前计算出来也是比力随性的:
  1. func main() {
  2.         arr := [4]int{}
  3.         slice := make([]int, 4)
  4.         s1 := make([]int, len(arr)) // not escape
  5.         s2 := make([]int, len(slice)) // escape
  6. }
复制代码
s1不逃逸但s2逃逸,因为len在计算数组的长度时会直接返回一个编译期常量。而len计算slice的长度时并不能在编译期完成计算,所以即使我们很清楚slice此时的长度就是4,但go还是会以为s2的巨细不能在逃逸分析前就确定。
这也是为什么我告诫大家不要过度关心逃逸分析这东西,很多时间它是反常识的。
编译期知道巨细就不会逃逸吗

有的八股文基于上一节的现象,得出了下面这样的结论:make([]T, 常数)不会逃逸。
我觉得一个合格的go或者c/c++/rust步调员应该立刻近乎本能地反驳:不逃逸就会分配在栈上,栈空间通常有限(体系栈通常8-10M,goroutine则是固定的1G),如果这个make必要的内存空间巨细凌驾了栈的上限呢?
很显然凌驾了上限就会逃逸到堆上,所以上面那句不太对。go当然有规定一次在栈空间上分配内存的上限,这个上限也远小于栈巨细的上限,但我不会告诉你是多少,因为没人保证以后不会改,而且我说了,你关心这个并没有什么用。
另有一种经典的环境,make生成的内容做返回值:
  1. func f1() []int {
  2.         return make([]int, 64)
  3. }
复制代码
逃逸分析会给出这样的结果:
  1. # command-line-arguments
  2. ...
  3. ./c.go:6:13: make([]int, 64) escapes to heap:
  4. ./c.go:6:13:   flow: ~r0 = &{storage for make([]int, 64)}:
  5. ./c.go:6:13:     from make([]int, 64) (spill) at ./c.go:6:13
  6. ./c.go:6:13:     from return make([]int, 64) (return) at ./c.go:6:2
  7. ./c.go:6:13: make([]int, 64) escapes to heap
复制代码
这没什么好意外的,因为返回值要在函数调用结束后继续被使用,所以它只能在堆上分配。这也是逃逸分析的初衷。
不过因为这个函数太简单了,所以总是能内联,一旦内联,这个make就不再是返回值,所以编译器有机会不让它逃逸。你可以用上一节教的//go:noinline试试。
slice的元素数目和是否逃逸关系不大

另有的八股会这么说:“slice里的元素数目太多会导致逃逸”,另有些八股文还会信誓旦旦地说这个数目限制是什么10000、十万。
那好,我们看个例子:
  1. package main
  2. import "fmt"
  3. func main() {
  4.         a := make([]int64, 10001)
  5.         b := make([]byte, 10001)
  6.         fmt.Println(len(a), len(b))
  7. }
复制代码
分析结果:
  1. ...
  2. ./c.go:6:11: make([]int64, 10001) escapes to heap:
  3. ./c.go:6:11:   flow: {heap} = &{storage for make([]int64, 10001)}:
  4. ./c.go:6:11:     from make([]int64, 10001) (too large for stack) at ./c.go:6:11
  5. ...
  6. ./c.go:6:11: make([]int64, 10001) escapes to heap
  7. ./c.go:7:11: make([]byte, 10001) does not escape
  8. ...
复制代码
怎么元素数目一样,一个逃逸了一个没有?说明了和元素数目就不要紧,只和上一节说的栈上对内存分配巨细有限制,凌驾了才会逃逸,没凌驾你分配一亿个元素都行。
关键是这种无聊的问题出镜率还不低,我和我朋友都遇到过这种:
  1. make([]int, 10001)
复制代码
就问你这个东西逃逸不逃逸,口试官估计忘了int长度不是固定的,32位体系上它是4字节,64位上是8字节,所以没有更多信息之前这个问题没法回答,你就是把Rob Pike抓来他也只能摇头。口试遇到了还能和口试官掰扯掰扯,笔试遇到了你怎么办?
这就是我说的倒果为因,slice和数组会逃逸不是因为元素数目多,而是消耗的内存(元素巨细x数目)凌驾了规定的上限。
new和make在逃逸分析时几乎没区别

有的八股文还说new的对象经常逃逸而make不会,所以应该尽量少用new。
这是篇老八股了,现在估计没人会看,然而就算在当时这句话也是错的。我想大概是八股作者不经验证就把Java/c++里的知识嫁接过来了。
我得澄清一下,new和make确实非常不同,但只不同在两个地方:

  • new(T)返回*T,而make(T, ...)返回T
  • new(T)中T可以是任意类型(但slice呀接口什么的一般不发起),而make(T, ...)的T只能是slice、map或者chan。
就这两个,别的针对slice之类的东西它们在初始化的详细方式上有一点区别,但这勉强包含在第二点里了。
所以绝不会出现new更容易导致逃逸,new和make一样,会不会逃逸只受巨细限制以及可达性的影响。
看个例子:
[code]package mainimport "fmt"func f(i int) int {        ret := new(int)        *ret = 1        for j := 1; j
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

民工心事

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表