【译】Go: 逃逸分析介绍

本文基于 Go 1.13

逃逸分析是 Go 编译器的一部分。它分析源代码并确定哪些变量应该分配到栈上、哪些逃逸到堆上。

静态分析

Go 在编译阶段就定义了什么应该在堆,什么应该在栈上。在 go rungo build 时加上 -gcflags="-m" 就可以得到分析结果。
这里有个简单的例子:

func main() {
	num := getRandom()
	println(*num)
}

//go:noinline
func getRandom() *int {
	tmp := rand.Intn(100)
	return &tmp
}

逃逸分析告诉我们 tmp 逃逸到了堆上

./main.go:12:2: moved to heap: tmp

静态分析的第一步时构建源代码的抽象语法树,允许 Go 理解在哪里进行赋值和分配,以及变量寻址和解引用。
下面是上一份代码的示例:
【译】Go: 逃逸分析介绍

然而,为了放便分析,我们去掉 AST 的不相关信息,可以得到一个简单的版本

【译】Go: 逃逸分析介绍

由于数公开了定义的变量(NAME 表示)和对指针的操作(ADDR 或 DEREF 表示),因此它将所有信息提供给 Go 执行逃逸分析,一旦树被构建并解析了函数和参数,Go 就可以使用逃逸分析逻辑查看应该给哪些分配堆和栈。

存活时间超过栈帧

在运行逃逸分析并从 AST 图中遍历函数时,Go 会查找比当前栈帧存活时间更长的变量,因此这些变量会分配到堆上。首先我们定义什么是 outlive,假如上个例子的栈帧中没有堆分配。下面是两个函数被调用时栈向下增长的情况:

【译】Go: 逃逸分析介绍

那么,当函数 getRandom 返回, 此函数创建的栈失效时,任何在函数栈上创建的变量都无法访问。

【译】Go: 逃逸分析介绍

在这种情况下,变量 num 不能指向在前一个栈上分配的变量。在这个例子中, Go 必须将变量分配到堆上,确保它比栈帧活的长:

【译】Go: 逃逸分析介绍

变量 tmp 包含了分配到栈上的内存地址,并且可以安全的从一个栈复制到另一个栈。然而,返回值并不是唯一可以 outlive的值,它们的规则如下:

  • 任何返回值都超过该函数的寿命,因为被调用的函数不知道该值

  • 在循环外声明的变量比循环内活的更久

    func main() {
      var l *int
      for i := 0; i < 10; i++ {
        l = new(int)
        *l = i
      }
      println(*l)
    }
    
    ./main.go:6:10: new(int) escapes to heap
    
  • 在闭包外部声明的变量比在闭包内部赋值活的更久:

    func main() {
      var l *int
      func() {
        l = new(int)
        *l = 1
      }()
      println(*l)
    }
    
    ./main.go:8:3: new(int) escapes to heap
    

逃逸分析的第二部分包括确定它如何操作指针,帮助理解哪些内容可能留在栈上。

寻址和解引用

构建表示寻址/解引用计数的加权图能让 Go 优化栈的分配。让我们分析一个例子来理解它是如何工作的:

func main() {
  n := getAnyNumber()
  println(*n)
}

//go:noinline
func getAnyNumber() *int {
  l := new(int)
  *l = 42
  
  m := &l
  n := &m
  o := **n
  
  return o
}

./main.go:10:10: new(int) escapes to heap

这里是生成的 AST 简单版本

【译】Go: 逃逸分析介绍

Go 通过构建加权图来定义分配。每次解引用(* 或 DEREF 表示) 权重增加 1,每次寻址操作(& 或 ADDR 表示)权重减去1。

下面是通过逃逸分析的顺序定义:

variable o has a weight of 0, o has an edge to n
variable n has a weight of 2, n has an edge to m
variable m has a weight of 1, m has an edge to l
variable l has a weight of 0, l has an edge to new(int)
variable new(int) has a weight of -1

每个以负数结束的变量如果超过当前栈帧寿命就会逃逸到堆上。返回值的寿命超过其函数的栈帧,并且通过计算得到了负值,就会分配到堆上。

通过构建这个图,Go可以了解哪些变量应该留在栈上,尽管它比栈活的长。

下面是另一个基本的例子:

func main() {
  num := func1()
  println(*num)
}

//go:noinline
func func1() *int {
  n1 := func2()
  *n1++
  
  return n1
}

//go:noinline
func func2() *int {
  n2 := rand.Intn(99)
  
  return &n2
}

./main.go:20:2: moved to heap: n2

变量 n1 存活的比栈帧长, 但是它的权重不是负数,因为 func1 在任何地方都不指向它的地址,然而, n2 一直存活并被解引用,Go 可以安全的将它分配到堆上。

【译】Go: 逃逸分析介绍

上一篇:Nginx&&HAProxy&&LVS


下一篇:Windows Server 2003支持还有不到半年时间