Go1.16逃逸分析原理剖析

2,624 阅读19分钟

警告!!本篇文章9千字,现在逃还来得及!

前言

本篇文章不会着重讲解逃逸分析是什么,有哪些逃逸分析例子,这些在网上太多博客了,继续介绍这些实在没意思,我打算介绍的是逃逸分析的思路和编译期的代码逻辑实现。

举个网上找的例子吧(用C代码更能体现栈内存与堆内存的差别):

#include <stdio.h>
  
int *foo() {
    int c = 11;
    return &c;
}

int main() {
    int *p = foo();
    printf("the return value of foo = %d\n", *p);
}

如代码所示,在上面这个例子中,我们将foo函数内的自动变量c的地址通过函数返回值返回给foo函数的调用者(main)了,这样当我们在main函数中引用该地址输出该变量值的时候,我们就会收到异常,比如在ubuntu上运行上述程序,我们会得到如下结果:

# gcc cstack_dumpcore.c
cstack_dumpcore.c: In function ‘foo’:
cstack_dumpcore.c:5:12: warning: function returns address of local variable [-Wreturn-local-addr]
     return &c;
            ^~
# ./a.out 
Segmentation fault (core dumped)

这样一来我们就需要一种内存对象,可以在全局(跨函数间)合法使用,这就是堆内存对象。但是和位于栈上的内存对象由程序自行创建销毁不同,堆内存对象需要通过专用API手工分配和释放,在C中对应的分配和释放方法就是malloc和free:

#include <stdio.h>
#include <stdlib.h>

int *foo() {
 int *c = malloc(sizeof(int));
 *c = 12;
 return c;
}

int main() {
 int *p = foo();
 printf("the return value of foo = %d\n", *p);
 free(p);
}

在这个示例中我们使用malloc在foo函数中分配了一个堆内存对象,并将该对象返回给main函数,main函数使用完该对象后调用了free函数手工释放了该堆内存块。

显然和自动变量相比,堆内存对象的生命周期管理将会给开发人员带来很大的心智负担。为了降低这方面的心智负担,带有GC(垃圾回收)的编程语言出现了,比如Java、Go等。这些带有GC的编程语言会对位于堆上的对象进行自动管理。当某个对象不可达时(即函数执行完后,栈回收,此时没有其它对象引用它),它将会被回收并被重用。

但GC的出现虽然降低了开发人员在内存管理方面的心智负担,但GC不是免费的,它给程序带来的性能损耗是不可忽视的,尤其是当堆内存上有大量待扫描的堆内存对象时,将会给GC带来过大的压力,从而使得GC占用更多本应用于处理业务逻辑的计算和存储资源。于是人们开始想方法尽量减少在堆上的内存分配,可以在栈上分配的变量尽量留在栈上。这就是逃逸分析。

逃逸分析(escape analysis)就是在程序编译阶段根据程序代码中的数据流,对代码中哪些变量需要在栈上分配,哪些变量需要在堆上分配进行静态分析的方法。一个理想的逃逸分析算法自然是能将那些人们认为需要分配在栈上的变量尽可能保留在栈上,尽可能少的“逃逸”到堆上的算法。

内联对逃逸分析的影响

在分析内存逃逸之前,有必要讲一下它和函数内联的关系。

看一下编译器函数内联部分的代码(src/cmd/compile/internal/gc/main.go):

image.png

从源码可以看到逃逸分析是放在内联检查的后面的。为什么要这么干呢?

示例代码:

package main

type smallStruct struct {
   a, b int64
   c, d float64
}

func main() {
   smallAllocation()
}

//go:noinline
func smallAllocation() *smallStruct {
   return &smallStruct{}
}

函数上面的注释//go:noinline将禁止Go对该函数进行内联,这样main函数就会使用smallAllocation函数返回的指针变量,因为被多个函数使用,返回的这个变量将被分配到堆上。

关于内联的概念:

内联是一种手动或编译器优化,用于将简短函数的调用替换为函数体本身。这么做的原因是它可以消除函数调用本身的开销,也使得编译器能更高效地执行其他的优化策略。

所以如果上面的例子不干预编译器的话,编译器通过内联将smallAllocation函数体里的内容直接放到main函数里,这样就不会产生smallAllocation这个函数的调用了,所有的变量都是main函数内这个范围使用的,也就不在需要将变量往堆上分配了。至此可以明确,函数内联会对逃逸分析产生影响,并且在编译期需要把内联分析放在逃逸分析之前。

补充说明一下,通过逃逸分析命令 go tool compile  -m main.go 可以确认我们上面的分析,&smallStruct{}会被分配到堆上去。

go tool compile -m main.go\
main.go:12:6: can inline main\
main.go:10:9: &smallStruct literal escapes to heap

逃逸分析原理

下面正式开始逃逸分析的原理讲解。

两个原则

逃逸分析的代码都放在 src\cmd\compile\internal\gc\escape.go 下,注释里用了很长的篇幅来介绍逃逸分析,我来大概介绍一下。

当我们分析函数来决定哪个 Go 变量可以被分配到堆上时(包括 new 和 make 分配的),有两个关键的原则需要遵守:

  1. 指向栈上对象的指针不能分配到堆上
  2. 指向栈上对象的指针,其生命周期不能长于该对象

对于第一点,指向栈上对象的指针不能分配到堆上。如果一个指针被分配到堆上,但是它却指向栈对象,当函数执行完并回收栈空间的时候,指针指向的将会是一个非法地址。换言之,如果一个指针被分配到了堆上,那么它指向的对象一定要分配到堆上。

对于第二点,指向栈上对象的指针,其生命周期不能长于该对象。画个示意图: 图片.png

如图所示,函数A中有一个指针pointer指向了内层函数B的某object对象,当内层函数B返回时,其栈帧的内存也会被释放,那么pointer指向的内存不再合法。 换言之,如果一个指针的生命周期长于其指向的对象,那么该对象一定要分配到堆上。

挺好玩的发现没,两个原则解决的都是同一个问题,即防止指针指向的内存突然变得不合法了迷途指针)。换言之,只要违反了这两个原则,那么指针指向的对象一定会被分配到堆上。

数据流构建思路

实现逃逸分析的方法就是基于AST树的静态数据流分析(static data-flow analysis)。

数据流就是一个有向加权图(directed weighted graph),它是基于抽象语法树构造出来的,用来表示变量之间的关系。其中 顶点(locations) 表示由语句(例如变量声明)和表达式(例如“new”或“make”)分配的变量,边(Edge) 表示变量之间的赋值,每条边都有权重(derefs)

权重的计算方式为:

derefs = 引用解析次数(Dereferences)- 取地址次数(Addressing)

即赋值语句右侧 * 操作次数减去 & 操作的次数。参见下面例子:

图片.png 图中箭头上的数字就是 derefs 值。图中的 p=&q,可以理解成 q 节点的指针流向 p 节点,因此有向边是 q 节点指向 p 节点,而不是从 p 指向 q。

由于 &x 本身是不可寻址(non-addressable)的,也就是&&x是不合法的,所以任何边的权重最小值只能是 -1。

看一个简单例子:

type T struct {
    Name string
}

func escapeAnalysis(arg T) *T {
    l1 := arg
    l2 := &l1
    l3 := *l2
    return &l3
}

该函数内变量形成的有向图如下:

图片.png

其中 ~r1 为编译器内部为函数返回值取的变量名。

上图中权重算法的准则:对任何变量A,以及直接指向该变量的任意顶点B, 我们都需要知道 A 是否持有的是 B 的指针。如果是的话,那么当 A 逃逸时,B 也必然需要逃逸。例如上图中的路径:l3 -> ~r1, 其中 ~r1 作为返回值需要逃逸,此时 l3 到 ~r1 边的权重为 -1, 意味着将 l3 赋值给 ~r1 时取了地址,即 ~r1 拿到的是 l3 的指针,因此 l3 也必须逃逸;但 l2 -> l3 的权重为 1, 所以即使 l3 逃逸了,l2 也不需要逃逸,由于 l2 不需要逃逸,即便 l1 -> l2 的权重为 -1, l1 也不需要逃逸。

我们再将该思路进行推广:在一条赋值链 A <- B <- … <- X <- Y 中,如果 A 逃逸,赋值链上各级可能应用了解析操作(*),也可能是取址操作(&),这样导致的结果可能是 A 最终持有的是 X 的指针,那么我们的算法就必须让 X 逃逸,而 X 与 A 之间的所有变量则可能并不需要逃逸。例如下列代码:

type T struct {
    Name string
}

func escapeAnalysis() **T {
    var t T
    l1 := &t
    l2 := &l1
    l3 := &l2
    l4 := *l3

    return l4
}

函数的数据流有向图为:

图片.png

通过赋值链我们可以发现,返回值 ~r1 实际持有的是 l1 的指针(因为 ~r1 到 l1 的路径加权和为 -1),而 l1 又持有 t 的指针,所以该函数中最终逃逸变量是 l1 与 t, 而中间的 l4, l3, l2 则不会逃逸。

至此,可以总结出逃逸分析简单流程如下:

1.通过 AST 树构造一个有向加权图

2.接下来,遍历这个有向加权图,寻找可能违反上述两个原则的赋值路径。如果对象v的地址存储在堆中或其他可能比它生命周期长的地方,那么对象v就被标记为需要分配到堆上。

这只是一个简略的流程,后面会一步步扩散,总结出更详细的整体流程。

逃逸分析实现

SCC概念

函数的调用关系形成了一个有向图(Directed Graph),在对函数进行内联分析或者逃逸分析时,我们需要反向对函数的调用链进行分析。例如函数的调用链是 A()->B()-C(),我们的遍历分析顺序是C(),B(),A()。

遍历函数的逻辑封装在文件src/cmd/compile/internal/gc/scc.go中,该文件内封装了自底向上遍历函数 AST 的方法。SCC 的全称是strongly connected components,下面是网上对SCC的官方解释:

在有向图G中,如果两个顶点 vi,vj 间有一条从 vi 到 vj 的有向路径,同时还有一条从 vj 到vi 的有向路径,则称两个顶点强连通(strongly connected)。如果有向图G的每两个顶点都强连通,称G是一个强连通图。有向图的极大强连通子图,称为强连通分量(strongly connected components)。

简单来说,一个 SCC 是由有向图的节点构成的子集,其中任意两个节点之间都至少有一条连通的路径。

对于函数调用链形成的有向图,该文件中的函数会自底向上找出每个 SCC, 然后依次传递给分析函数进行处理。遍历函数的签名如下:

func visitBottomUp(list []*Node, analyze func(list []*Node, recursive bool)) {
   var v bottomUpVisitor
   v.analyze = analyze
   v.nodeID = make(map[*Node]uint32)
   for _, n := range list {
      if n.Op == ODCLFUNC && !n.Func.IsHiddenClosure() {
         v.visit(n)
      }
   }
}

对于参数 list 中的每个函数节点,函数会将找出的 SCC 依次传递给 analyze 函数进行处理。对于如下代码:

func B() {
    println("B")
}

func A() {
    println("A")
    B()
}

func C() {
    println("C")
    D()
}

func D() {
    println("D")
    C()
}

func main() {
    A()
    C()
}

整个函数的调用关系图如下:

image.png

每种颜色的函数都形成一个 SCC

整体逻辑

逃逸分析是基于AST树来分析的,入口函数为:

// src\cmd\compile\internal\gc\main.go

func Main(archInit func(*Arch)) {
    ...
    timings.Start("fe", "escapes")
    escapes(xtop)
    ...
}

逃逸分析以函数为单元对代码进行分析,同内联操作一样,编译器自底向上地遍历源代码中函数调用链形成的有向图,然后依次对各个 SCC 进行分析。入口函数如下:

// \src\cmd\compile\internal\gc\esc.go

func escapes(all []*Node) {
   visitBottomUp(all, escapeFuncs)
}

函数 escapeFuncs 以一个 SCC(strongly connected components) 为输入,对该 SCC 内的所有函数进行逃逸分析,其总体逻辑如下:

  1. 创建静态数据流有向图
  2. 遍历有向图的每个顶点,并分析指向该顶点的所有路径(Path)上的各个顶点是否需要逃逸
  3. 对每个变量设置逃逸标记

函数的主要流程如下:

// src\cmd\compile\internal\gc\escape.go

// escapeFuncs performs escape analysis on a minimal batch of
// functions.
func escapeFuncs(fns []*Node, recursive bool) {
   for _, fn := range fns {
      if fn.Op != ODCLFUNC {
         Fatalf("unexpected node: %v", fn)
      }
   }

   var e Escape
   e.heapLoc.escapes = true

   // 遍历 SCC 中的每个函数,为函数中每个变量创建一个有向图的顶点。顶点用 EscLocation 表示
   for _, fn := range fns {
      e.initFunc(fn)
   }
   // 遍历 SCC 中函数的 IR Tree, 根据赋值语句创建有向图的边并计算权重。边用 EscEdge 表示
   for _, fn := range fns {
      e.walkFunc(fn)
   }
   e.curfn = nil
   
   // 对有向图的各个顶点进行逃逸分析
   e.walkAll()
   // 逃逸分析完成,在 IR Tree 中标记各个变量顶点的结果
   e.finish(fns)
}

数据结构

编译器定义了几个重要的结构体来支撑逃逸分析,路径:src\cmd\compile\internal\gc\escape.go,以下代码基于 Go1.16:

Escape

type Escape struct {
   allLocs []*EscLocation

   curfn *Node
   loopDepth int

   heapLoc  EscLocation
   blankLoc EscLocation
}

Escape 保存着对一个 SCC 逃逸分析的所有状态。其中 allLocs 保存整个数据流有向图的顶点,curfn 表示当前分析到的函数节点,heapLoc 是一个假设分配在堆上的顶点,它的 escapes 字段值为 true,所有需要在堆上分配内存的变量都有一条边指向 heapLoc 顶点。heapLoc 字段比较关键,后面会举例说明。

EscLocation

type EscLocation struct {
   n         *Node     // represented variable or expression, if any
   curfn     *Node     // enclosing function
   edges     []EscEdge // incoming edges
   loopDepth int       // loopDepth at declaration

   // derefs and walkgen are used during walkOne to track the
   // minimal dereferences from the walk root.
   derefs  int // >= -1
   walkgen uint32

   // dst and dstEdgeindex track the next immediate assignment
   // destination location during walkone, along with the index
   // of the edge pointing back to this location.
   dst        *EscLocation
   dstEdgeIdx int

   // queued is used by walkAll to track whether this location is
   // in the walk queue.
   queued bool

   // escapes reports whether the represented variable's address
   // escapes; that is, whether the variable must be heap
   // allocated.
   escapes bool

   // transient reports whether the represented expression's
   // address does not outlive the statement; that is, whether
   // its storage can be immediately reused.
   transient bool

   // paramEsc records the represented parameter's leak set.
   paramEsc EscLeaks
}

EscLocation 表面上看代表的是数据流有向图中的一个顶点(Vertex),本质上指的是程序中需要使用的一个内存区块,例如赋值语句 c := make(chan string) 右侧并没有变量,但表达式 make(chan string) 依然需要一个 EscLocation 来表示。

EscLocation 包含的属性也比较多,其中需要特别注意的有:

  • edges: 有向图中指向本顶点的所有边
  • derefs: 该属性在逃逸分析时使用,不是前文讨论的权重
  • escapes: 标记该变量是否需要逃逸

EscEdge

type EscEdge struct {
   src    *EscLocation
   derefs int // >= -1
   notes  *EscNote
}

EscEdge 代表数据流有向图中的一条边(Edge),属性 derefs 表示该边的权重,一条边代表程序中的一个赋值语句。比如 x=**y,对应的 EscEdge 实例内容为 src==y,derefs==2。 notes 用来记录编译器逃逸分析时的 dump 信息。

EscHole

type EscHole struct {
   dst    *EscLocation
   derefs int // >= -1
   notes  *EscNote

   // uintptrEscapesHack indicates this context is evaluating an
   // argument for a //go:uintptrescapes function.
   uintptrEscapesHack bool
}

EscHole 封装了赋值语句中对右侧表达式的执行策略。例如对于语句 x = **p, 对应的 EscHole 实例的内容为 dst==x,derefs==2。

构建有向图

创建数据分析流只需要两步:创建顶点创建边

创建顶点

对于函数中的每个局部变量,都需要为其创建一个顶点,该逻辑由函数 initFunc() 完成:

// src\cmd\compile\internal\gc\escape.go
// 以下函数都只展示重要部分代码

func (e *Escape) initFunc(fn *Node) {
   fn.Esc = EscFuncPlanned

   e.curfn = fn
   e.loopDepth = 1

   // Allocate locations for local variables.
   for _, dcl := range fn.Func.Dcl {
      if dcl.Op == ONAME {
         e.newLoc(dcl, false)
      }
   }
}

func (e *Escape) newLoc(n *Node, transient bool) *EscLocation {
   loc := &EscLocation{
      n:         n,
      curfn:     e.curfn,
      loopDepth: e.loopDepth,
      transient: transient,
   }
   e.allLocs = append(e.allLocs, loc)
   // 检查各个变量的大小,大对象直接逃逸到堆上
   if n != nil {
      if why := heapAllocReason(n); why != "" {
         e.flow(e.heapHole().addr(n, why), loc)
      }
   }
   return loc
}

创建顶点的逻辑由一个 for 循环完成,其遍历函数所有的局部变量,为其创建一个 EscLocation 对象,并将其添加到 e.allLocs 属性中,该逻辑由 e.newLoc() 完成。newLoc()函数还检查了各个变量的大小,大对象直接逃逸到堆上

注意此处创建的顶点仅是函数内的命名局部变量,对于通过函数 newmake 创建的顶点,以及各种字面量初始化创建的顶点,需要在构造有向图边的时候才能创建。

创建边

构造有向边的逻辑更加复杂,因为只有对函数的所有语句进行分析之后,才能够构建出完整的赋值关系,从而构建出完整的数据流有向图。

分析赋值关系的逻辑入口是 e.walkFunc(fn), 该方法对目标函数的所有语句递归遍历并进行分析,如果语句中存在赋值关系,则为其创建有向边

遍历有向图

当数据流有向图构建完成之后,编译器便会基于该结构进行逃逸分析。编译器首先遍历所有顶点,依次分析指向该顶点的路径(Path)上的所有顶点是否需要逃逸,编译器内部通过两个 LIFO Queue 来实现该双重循环。入口函数 walkAll() 也是外层循环,其代码如下:

// walkAll computes the minimal dereferences between all pairs of
// locations.
func (e *Escape) walkAll() {
   todo := make([]*EscLocation, 0, len(e.allLocs)+1)
   enqueue := func(loc *EscLocation) {
      if !loc.queued {
         todo = append(todo, loc)
         loc.queued = true
      }
   }

   for _, loc := range e.allLocs {
      enqueue(loc)
   }
   enqueue(&e.heapLoc)

   var walkgen uint32
   for len(todo) > 0 {
      root := todo[len(todo)-1]
      todo = todo[:len(todo)-1]
      root.queued = false

      walkgen++
      e.walkOne(root, walkgen, enqueue)
   }
}

该方法用于构建外层队列,内层循环及分析逻辑在方法 walkOne() 中。可以发现入队函数 enqueue() 也作为参数传递给了 walkOne(), 这意味着对顶点 root 的分析过程中,有可能会将某些顶点再次压入队列(这里说再次是因为前面通过遍历 b.allLocs 列表,已经将所有的顶点压入队列了)。

可以看到把heapLoc顶点也入队了,因为在 walkFunc() 中是有可能把节点的地址流向 heapLoc 的,这个会在最后一节说明。

方法 walkOne() 以参数 root 为根节点,逐个分析指向该顶点的路径(Path)上的所有顶点,例如路径:root <- A … <- P … <- Z 中,如果 root 需要逃逸,而 root 实际上持有的是 P 的指针的话,那么 P 也需要逃逸。而此时我们就需要通过参数 enqueue 将顶点 P 再次压入外层队列,因为 walkOne() 只分析 root 与路径上各个顶点的关系,如果我们发现路径上某节点 P 需要逃逸的话,那么就有必要将 P 作为根节点再次进行分析(可能外围队列之前已经遍历过顶点 P 了,而当时 P 可能不需要逃逸,所以这里是再次分析),所以我们需要将顶点 P 再次压入外层队列。

// walkOne computes the minimal number of dereferences from root to
// all other locations.
func (e *Escape) walkOne(root *EscLocation, walkgen uint32, enqueue func(*EscLocation)) {
   root.walkgen = walkgen
   root.derefs = 0
   root.dst = nil

   todo := []*EscLocation{root} // LIFO queue
   // 遍历队列
   for len(todo) > 0 {
      l := todo[len(todo)-1]
      todo = todo[:len(todo)-1]

      // 删除对 l 进行逃逸分析的代码

      // 在 for 循环末尾将指向该顶点的所有顶点压入队列
      for i, edge := range l.edges {
         if edge.src.escapes {
            continue
         }
         derefs := base + edge.derefs
         if edge.src.walkgen != walkgen || edge.src.derefs > derefs {
            edge.src.walkgen = walkgen
            edge.src.derefs = derefs
            edge.src.dst = l
            edge.src.dstEdgeIdx = i
            todo = append(todo, edge.src)
         }
      }
   }
}

逃逸分析核心逻辑

简单来说,对于根节点 root 及其路径上的节点 l, 如果下列两个关系都满足的话(注意是都满足而不是只满足其中一个),那么 l 就需要逃逸:

1.root的生命周期长于l。判断该逻辑的方法是 e.outlives(), 其代码如下:

// outlives reports whether values stored in l may survive beyond
// other's lifetime if stack allocated.
func (e *Escape) outlives(l, other *EscLocation) bool {
   // The heap outlives everything.
   if l.escapes {
      return true
   }

   // 我们不知道调用者会对函数返回值做什么,所以悲观地说,我们需要假设它们流向堆,
   // 并且生命周期比函数中所有变量都长。
   if l.isName(PPARAMOUT) {
      // 省略其他的判断分支...
      return true
   }

   // 省略其他的判断分支...

   return false
}

PPARAMOUT是个常量,定义在 src\cmd\compile\internal\gc\go.go 中,指的是 output results,也就是函数的返回值。当 l 是函数的返回值的时候,我们可以认为 l 的生命周期比 other 更长。

2.rootl的总权重是-1

我们定义 W(a, b) 为赋值链 a -> … -> b 中 a 相对于 b 的总权重。对于如下调用链: 图片.png

  • W(l2, ~r1) = -1 + 1 + 0 = 0,所以 ~r1 没有持有 l2 的指针
  • W(l1, ~r1) = W(l2, ~r1) + -1 = -1, 表明 ~r1 实际持有的是 l1 的指针。

为什么计算 W(l1, ~r1) 的时候是 W(l2, ~r1) + -1 而不是直接将整个路径所有的 Derefs 相加(-1 + -1 + 1 + 0)呢?由于 Go 语言中表达式 &l 本身是不可寻址的,即 &&l 为非法表达式,因此在计算 W(a, b) 时,不能简单得将整个路径上所有边的权重(Derefs)直接加起来,例如上图中,我们不能说 W(t, ~r1) = -2,因为 -2 代表的意义是 &&t 这样的操作,不符合实际情况。所以对于赋值链 t -> x -> ... -> root,当 W(x, root) = -1 时,我们可以认为 W(t, root) = W(t, x)。如果 W(x, root) >= 0, 那么易得 W(t, root) = W(t, x) + W(x, root)。

基于以上两点的分析,在内层队列循环中,逃逸分析的代码可以精简成如下如下样子:

func (e *Escape) walkOne(root *EscLocation, walkgen uint32, enqueue func(*EscLocation)) {
   root.walkgen = walkgen
   root.derefs = 0
   root.dst = nil
   
   // 存储 root 的调用链
   todo := []*EscLocation{root} // LIFO queue
   for len(todo) > 0 {
      l := todo[len(todo)-1]
      todo = todo[:len(todo)-1]

      base := l.derefs

      // 如果 l.derefs < 0,则 l 的地址流到 root。
      addressOf := base < 0
      if addressOf {
         base = 0
      }

      if e.outlives(root, l) {
        
         if addressOf && !l.escapes {
            l.escapes = true
            enqueue(l)
            continue
         }
      }
      // 删除掉构建内部队列的代码
   }
}

上面代码中的 base 的变化是非常重要的,是完成 W(a, b) 权重计算的关键,下面用图详细展示整个逃逸分析流程。

示例分析1

我们来看看如下代码的逃逸分析:

func escapeAnalysis() **int {
   t:=1
   l1 := &t
   l2 := &l1
   l3 := &l2
   l4 := *l3

   return l4
}

1.首先通过 initFunc() 和 walkFunc() 创建好各个顶点和有向加权边。 图片.png 2.在 walkAll() 中,操作如下:

  1. 把所有顶点入队
  2. 把 heapLoc 顶点入队
  3. 对于队列中每个顶点,采用循环的方式,先出队,然后调用 walkOne 方法分析其调用链,期间有可能会新增顶点进队列。

分析是从 heapLoc 开始的,由于没有任何地址流向 heapLoc,这里就不分析 heapLoc 的内层循环了,因为基本上啥都没干就退出了。

3.当前操作顶点是 ~r1。

操作如下:

root 为 ~r1,执行 root.walkgen = walkgen,也就是 ~r1.walkgen = 2,然后创建一个内部队列 todo,把 root 放进去,开启循环

  • l := ~r1,即当前操作节点是 ~r1
  • 把 l 从 todo 中弹出
  • base := l.derefs,即 base := 0
  • ...
  • 遍历 l.edges,可以知道有哪些顶点(edge.src)指向 l,并且边的权重是多少(Derefs)
  • derefs := base + edge.derefs,即 derefs := 0 + 0 = 0
  • edge.src.walkgen = walkgen,即 l4.walkgen = 2
  • edge.src.derefs = derefs,即 l4.derefs = 0
  • edge.src.dst = l,即 l4.dst = ~r1
  • todo = append(todo, edge.src),把 l4 放入内部队列 由于 todo 不为空,循环继续:
  • l := l4,即当前操作节点是 l4
  • 把 l 从 todo 中弹出
  • base := l.derefs,即 base := 0
  • ...
  • 遍历 l.edges,可以知道有哪些顶点(edge.src)指向 l,并且边的权重是多少(Derefs)
  • derefs := base + edge.derefs,即 derefs := 0 + 1 = 1
  • edge.src.walkgen = walkgen,即 l3.walkgen = 2
  • edge.src.derefs = derefs,即 l3.derefs = 1
  • edge.src.dst = l,即 l3.dst = l4
  • todo = append(todo, edge.src),把 l3 放入内部队列 由于 todo 不为空,循环继续:
  • l := l3,即当前操作节点是 l3
  • 把 l 从 todo 中弹出
  • base := l.derefs,即 base := 1
  • ...
  • 遍历 l.edges,可以知道有哪些顶点(edge.src)指向 l,并且边的权重是多少(Derefs)
  • derefs := base + edge.derefs,即 derefs := 1 + -1 = 0
  • edge.src.walkgen = walkgen,即 l2.walkgen = 2
  • edge.src.derefs = derefs,即 l2.derefs = 0
  • edge.src.dst = l,即 l2.dst = l3
  • todo = append(todo, edge.src),把 l2 放入内部队列 由于 todo 不为空,循环继续:
  • l := l2,即当前操作节点是 l2
  • 把 l 从 todo 中弹出
  • base := l.derefs,即 base := 0
  • ...
  • 遍历 l.edges,可以知道有哪些顶点(edge.src)指向 l,并且边的权重是多少(Derefs)
  • derefs := base + edge.derefs,即 derefs := 0 + -1 = -1
  • edge.src.walkgen = walkgen,即 l1.walkgen = 2
  • edge.src.derefs = derefs,即 l1.derefs = -1
  • edge.src.dst = l,即 l1.dst = l2
  • todo = append(todo, edge.src),把 l1 放入内部队列 由于 todo 不为空,循环继续:
  • l := l1,即当前操作节点是 l1
  • 把 l 从 todo 中弹出
  • base := l.derefs,即 base := -1
  • addressOf := base < 0,即 addressOf := true
  • 由于 address == true,base = 0
  • 进入 e.outlives(root, l) 判断逻辑,入参 root 为 ~r1,l 为 l1
    • 进入 l.isName(PPARAMOUT) 判断逻辑,~r1 确实是函数的返回值
    • outlive() 函数返回 true,说明 ~r1 的生命周期比 l1 更长
    • 由于 addressOf == true,且 l.escapes == false,进入 if addressOf && !l.escapes { ... 逻辑
      • l.escapes = true // 把 l1 标记为逃逸
      • enqueue(l) // 把 l1 放进外层队列,由于外部队列已经存在 l1,enqueue() 函数直接返回
      • continue

walkOne 执行结束回到 walkAll,此时外部队列为:

image.png

OK,大致的执行细节都展示了,关于后面的执行我大概写一下:

  1. 外层队列中 l4,l3,l2 相继出队,分别执行 walkOne(),外层队列没变化
  2. 外层队列中 l1 出队,执行 walkOne(),由于 l1 标记为逃逸,且 W(t,l1) = -1,所以 t 也被标记为逃逸
  3. 外层队列中 t 出队,执行 walkOne()
  4. walkAll 执行结束,被标记为逃逸的节点有 l1 和 t

补充说明: 对于 initFunc() 中会创建哪些顶点,我们可以通过打印 IR Tree 来看一下

命令:go build -gcflags="-m -m -m -m -m -W -W" target.go (-m 越多,打印信息越详细)

打印结果:

.   DCLFUNC l(3) esc(no) tc(1) FUNC-func() **int
.   DCLFUNC-dcl
.   .   NAME-main.~r0 g(1) l(3) x(0) class(PPARAMOUT) PTR-**int

.   .   NAME-main.t g(2) l(4) x(0) class(PAUTO) tc(1) addrtaken used int

.   .   NAME-main.l1 g(3) l(5) x(0) class(PAUTO) tc(1) addrtaken used PTR-*int

.   .   NAME-main.l2 g(4) l(6) x(0) class(PAUTO) tc(1) addrtaken used PTR-**int

.   .   NAME-main.l3 g(5) l(7) x(0) class(PAUTO) tc(1) used PTR-***int

.   .   NAME-main.l4 g(6) l(8) x(0) class(PAUTO) tc(1) used PTR-**int
.   DCLFUNC-body
.   .   AS-init
...

结合创建顶点的代码:

// Allocate locations for local variables.
for _, dcl := range fn.Func.Dcl {
   if dcl.Op == ONAME {
      e.newLoc(dcl, false)
   }
}

可以看到,逃逸分析时,只会对函数 IR Tree 中声明变量的节点来创建顶点。

示例分析2

实例代码如下:

func aa() **int {
   l := new(int)
   *l = 42
   res:=bb(l)
   return res
}

func bb(num *int) **int {
   return &num
}

这个例子和示例1明显不一样,在方法 aa 中多了函数调用。那对于这种情况应该怎么分析呢?碰到语句res:=bb(l)是怎么处理的?下面对函数 aa() 和 bb() 分析一波。

逃逸分析步骤:

  1. 先分析 bb() 函数,只有一条数据流:~r1 = &num,Derefs = -1。根据上面示例1的分析逻辑,易得 num 逃逸。由于 num 是函数参数,因此会在 bb 函数的语法树节点上做个标志,当分析 aa 函数的时候,如果发现了这个标记,就会把传给 bb 函数的实参流向堆,以保证程序逻辑的正确性。
  2. 然后分析 aa() 函数。首先通过 initFunc() 创建好各个顶点,参与的变量有 l,res,~r1。
  3. 通过 walkFunc() 创建有向加权边。
    • 发现有 new(int) 表达式,为 new(int) 创建顶点
    • 为各个顶点创建有向加权边
    • 分析到res:=bb(l)语句,发现 l 传给了 bb() 函数当入参,根据语法树中 bb() 函数已经打上的标记,发现 l 传进去 bb() 函数后会在函数内发生逃逸,于是编译器就会在 l 和 heapLoc 顶点之间创建一条边,src 是 l,代表着把 l 赋给了 heap。这样可以保证 aa() 中对于变量 l 的分析不会出错。这些操作在 escape.go 中的 call() 函数中完成。

函数 aa() 的有向图如下:

image.png

  1. walkAll() 先从 heapLoc 开始分析,分析逻辑如示例1所示,最终顶点 new 逃逸

可以发现,对于语句中有函数调用的情况,是先分析完 callee 函数,把 callee 函数每个参数的逃逸情况都标记在语法树节点上。这样就可以根据标记信息来决定 caller 中哪个入参变量需要指向 heapLoc,保证了 caller 中逃逸分析的正常进行。

代码稍微改一下:

func aa() **int {
   l := new(int)
   l2 := new(int)
   res:=bb(l,l2)
   return res
}

func bb(num1 *int,num2 *int) **int {
   *num1=2
   return &num2
}

易得 bb() 函数中参数 num1 不逃逸,num2 逃逸了。那么在分析 aa() 中的 res:=bb(l,l2) 语句时,根据 bb() 的标记,决定 l2 指向了 heapLoc,最终分析结果就是 l2 := new(int) 中的 new(int) 逃逸了。

这个标记是啥东西?其实就是 Field 结构体的 Note 字段,它是个 string 类型。

如何生成这个标记?在编译器的实现中是使用一个长度为 8 的 uint8 的数组来生成的:

// src\cmd\compile\internal\escape\escape.go
const numEscResults = 7 
type EscLeaks [1 + numEscResults]uint8

EscLeaks 数组定义:从参数结果参数的一组赋值流。每个参数都会有一个 EscLeaks 数组,第1个元素表示参数到堆的赋值流,后7个元素表示参数到7个返回值的赋值流。

以下面函数为例,看看 EscLeaks 数组的生成过程:

func bb(num *int) **int { 
    return &num 
}

1)经过 initFunc() 和 walkFunc(),为函数创建了两个顶点 num 和 ~r1 并生成数据流图。

2)经过 walkAll() ,为赋值流图生成 EscLeaks 数组。EscLeaks 数组为 1 1 0 0 0 0 0 0。第一个 1 表示 num 是逃逸的,需要流向堆;第二个 1 表示 num 流向了返回值 ~r1。(个人理解:EscLeaks 数组的第一位表示参数是否流向堆,只有 0 和 1 两种情况;后 7 位表示参数和返回值的引用关系,如果为 1,表示 Derefs=-1;如果大于 1,表示解引用,并且数值为 Derefs+1)。
将上述例子修改一下:

func bb(num **int) *int { 
    return *num 
}

EscLeaks 数组为 0 2 0 0 0 0 0 0。

3)经过 finish() ,-m 输出相应的逃逸和数据流打印语句,并把 EscLeaks 数组转换为 Note 标记。在最后转换为 Note 标记之前,EscLeaks 数组还得经过一次简单的优化,当第一个元素为 1 时,也就是参数流向堆,那后面 7 个元素都无关紧要了,都会清 0。也就是说,1 1 0 0 0 0 0 0 会被优化成 1 0 0 0 0 0 0 0。

下面说一下 Note 长啥样。

1)当 leaks 数组第一位为 1,Note 值为 “”

2)当 leaks 数组第一位为 0,Note 值为 "esc:" + string(leaks[:n]),n 为 leaks 数组中最后一个不为 0 元素的索引。

就是下面这个函数把 EscLeaks 数组转换为标记,逻辑如下:

// Encode converts l into a binary string for export data.
func (l EscLeaks) Encode() string {
   if l.Heap() == 0 {
      // Space optimization: empty string encodes more
      // efficiently in export data.
      return ""
   }
   if s, ok := leakTagCache[l]; ok {
      return s
   }

   n := len(l)
   for n > 0 && l[n-1] == 0 {
      n--
   }
   s := "esc:" + string(l[:n])
   leakTagCache[l] = s
   return s
}

小结

经过前面示例的分析,我们来对逃逸分析流程详细总结一下:

1.构造顶点

  • 遍历所有的方法,根据 IR Tree 把方法中声明的变量转换成顶点(EscLocation) 2.构造边
  • 遍历所有的方法,根据 IR Tree 中的赋值语句构造边
  • 为 new 和 make 之类的内置函数创建顶点和边 3.分析有向加权图
  • 迭代遍历图(基于 Bellman-Ford 算法)
    • 开始于每个顶点
    • 如果 root 顶点生命周期长于 l 顶点并且之间的关系权重(最短路径)为 -1,则标记 l 顶点为逃逸 4.添加逃逸的 Note 信息
  • 遍历顶点,收集被标记顶点的逃逸原因

go:noescape 注解原理

go:noescape 注解可以指定一个有声明但没有主体(通常用汇编代码实现主体)的函数,不允许编译器对其做逃逸分析。注解通常加在函数的上一行。

如果go:noescape 注解指定的函数有 go 代码实现主体,构建时候就会报错:

.\main.go:12:6: can only use //go:noescape with external func implementations

定义

go:noescape 注解作为常量定义在 src\cmd\compile\internal\gc\lex.go 路径下

type PragmaFlag int16

const (
   // Func pragmas.
   Nointerface    PragmaFlag = 1 << iota
   Noescape                  // func parameters don't escape
   Norace                    // func must not have race detector annotations
   Nosplit                   // func should not execute on separate stack
   Noinline                  // func should not be inlined
   NoCheckPtr                // func should not be instrumented by checkptr
   CgoUnsafeArgs             // treat a pointer to one arg as a pointer to them all
   UintptrEscapes            // pointers converted to uintptr escape

   // 省略其他指令...
)

const (
   FuncPragmas = Nointerface |
      Noescape |
      Norace |
      Nosplit |
      Noinline |
      NoCheckPtr |
      CgoUnsafeArgs |
      UintptrEscapes |
      Systemstack |
      Nowritebarrier |
      Nowritebarrierrec |
      Yeswritebarrierrec

   TypePragmas = NotInHeap
)

原理

示例代码如下:

func aa() {
   l := new(int)
   *l = 42
   bb(l)
}

//go:noescape
func bb(num *int) **int

前面示例2说过,对于 caller 调用 callee 的情况,是先分析完 callee,把参数的逃逸情况标记起来,caller 拿到标记之后就会知道哪个实参需要新增一条指向 heapLoc 的边。

那如果 callee 是加了//go:noescape注解的话,callee 函数就会给全部的参数都标记为不需要逃逸,caller 中的实参就不会指向 heapLoc,这样分析完数据流后,new(int) 这个顶点就不会逃逸了。

参考:

gocompiler.shizhz.me/