乐趣区

关于后端:Go编译原理系列10逃逸分析

前言

在上一篇文章中分享了编译器的优化办法之一:函数内联 ,本文分享编译器的另一个优化办法: 逃逸剖析 。逃逸剖析是 Go 语言编译过程中比拟重要的一个优化阶段, 它次要用于标识变量应该被调配到栈上还是堆上

概述中的内容(包含示例),其实你能够在逃逸剖析的源码正文中看到,逃逸剖析源码地位:src/cmd/compile/internal/gc/escape.go(感觉是这几局部源码里边正文最全的一部分,哈哈哈)

逃逸剖析概述

首先咱们晓得,在 C /C++ 中,如果一个函数返回了一个栈上的对象指针,在函数执行实现,栈被销毁后,持续拜访被销毁栈上的对象指针,就会呈现问题

本局部介绍完 Go 语言编译过程的逃逸剖析之后,你会发现 逃逸分析阶段会辨认出一个变量应该放在堆还是放在栈,对于放在堆中的变量,会借助 Go 运行时的垃圾回收机制主动的开释内存。当然,编译器会尽可能地将变量搁置到栈中,因为栈中的对象随着函数调用完结会被主动销毁,加重运行时调配和垃圾回收的累赘

有了逃逸剖析,其实作为 Go 开发者,咱们在定义变量或对象时,都 既可能被调配到栈中,也可能被调配到堆中。比方应用 new 或 make 创立的对象

在调配时,遵循以下两个准则

  1. 指向栈上对象的指针不能被存储到堆中(因为栈上的内存会在应用完后被销毁)
  2. 指向栈上对象的指针不能超过该栈对象的生命周期(如果超过该栈对象的生命周期,它会被销毁)

下边是一个简略的逃逸的示例

package main

var a *int

func main()  {
    b := 1
    a = &b
}

示例中,a 是一个全局的整形指针变量,在 main 函数中,变量 a 援用了变量 b 的地址。依据上边咱们提到的两个分配原则,如果 b 调配到栈中,就违反了第二个准则,变量 a 超过了变量 b 的申明周期,所以 b 须要被调配到堆中。你能够通过下边命令查看逃逸信息

go tool compile -m xxx.go

Go 编译过程会构建 带权重的有向图,权重示意以后变量援用和解援用的数量。如下例所示,p 援用 q 时的权重,当权重大于 0 时,代表存在 * 解援用操作。当权重为 - 1 时,代表存在 & 援用操作

p = &q // -1
p = q //0
p = *q // 1
p = **q // 2
p = **&**&q //2

并不是权重为 - 1 就肯定要逃逸,比方在下边这个示例中,尽管 a 援用了变量 b 的地址,然而因为变量 a 并没有超过变量 b 的申明周期,因而变量 b 与变量 a 都不须要逃逸

func test() int {
    b := 666
    a := &b
    
    return *a
}

下边通过一个示例来展现解编译器带权重的有向图

package main

var o *int

func main()  {l := new(int)
    *l = 42
    m := &l
    n := &m
    o = **n
}

最终编译器在逃逸剖析中的数据流剖析,会被解析成下图所示的带权重的有向图

其中,节点代表变量,边代表变量之间的赋值,箭头代表赋值的方向,边上的数字代表以后赋值的援用或解援用的个数。节点的权重 = 前一个节点的权重 + 箭头上的数字,例如节点 m 的权重为 2 -1=1,而节点 l 的权重为 1 -1=0

遍历和计算有向权重图的目标是找到权重为 - 1 的节点 ,比方上图中的 new(int) 节点,它的节点变量地址会被传递到根节点 o 中,这时还须要思考逃逸剖析的分配原则,o 节点为全局变量,不能被调配在栈中,因而,new(int)节点创立的变量会被调配到堆中

理论的场景中会更加简单,因为一个节点可能领有多条边(比方构造体),而节点之间可能呈现环。Go 语言采纳Bellman Ford 算法(解决单源最短门路的算法)遍历查找有向图中权重小于 0 的节点

逃逸剖析的外围代码位于:src/cmd/compile/internal/gc/escape.go。下边就简略看看一下逃逸剖析的源码

逃逸剖析底层实现

同样是顺着 Go 编译的入口文件往下看,你会看到下边这行代码

// Phase 6: Escape analysis.
timings.Start("fe", "escapes")
escapes(xtop)

调用了 escapes 办法,进行逃逸剖析,下边看 escapes 办法的具体实现

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

发现它里边只调用可一个办法 visitBottomUp,是不是很眼生。没错,上一篇分享函数内联的时候,也是调用了这个办法。它的作用是通过深度优先搜寻遍历形象语法树,对每个结点进行验证,比方是不是闭包等。而后就是对满足条件的形象语法树,执行传入的办法,对于逃逸剖析,其实就是查看完之后去执行visitBottomUp 的第二个参数中传递的函数escapeFuncs

下边就次要看 escapeFuncs 的外部实现

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

    for _, fn := range fns {e.initFunc(fn)
    }
    for _, fn := range fns {e.walkFunc(fn)
    }
    e.curfn = nil

    e.walkAll()
    e.finish(fns)
}

代码很少,次要是调用了 initFuncwalkFuncwalkAllfinish 这几个办法,我这里大抵介绍它里边都做了什么,具体的实现细节,你能够自行的去看源码

  • initFunc:其实就是 从语法树结构数据流图,前边提到的带权有向图
  • walkFunc:遍历 AST,判断相应节点是不是 OGOTOOLABEL,而后将它们打上相应的标签(比方是 OGOTO 的话,就打上循环标签)
  • walkAll:它次要就是计算带权有向图中每个节点的最小解援用。它的实现就用到了上边提到的Bellman Ford 算法(对于这个算法我也不太懂,感兴趣的能够从维基百科上理解,具体点这里)
  • finish依据逃逸剖析后果更新 AST 中对应节点的 Esc字段等
退出移动版