Go编译器——AST到SSA流程分析

 

  1. 检查常量、类型和函数的类型;

  2. 处理变量的赋值;

  3. 对函数的主体进行类型检查;

  4. 决定如何捕获变量;

  5. 检查内联函数的类型;

  6. 进行逃逸分析;

 

类型检查是 Go 语言编译的第二个阶段,在词法和语法分析之后我们得到了每个文件对应的抽象语法树,随后的类型检查会遍历抽象语法树中的节点,对每个节点的类型进行检验,找出其中存在的语法错误,在这个过程中也可能会对抽象语法树进行改写,这不仅能够去除一些不会被执行的代码对编译进行优化提高执行效率,而且也会修改 make 等关键字对应节点的操作类型。

注:Xtop保存有抽象语法树的所有节点。

  // Phase 1: const, type, and names and types of funcs.
    // 常量,类型,函数签名的类型检查
    timings.Start("fe", "typecheck", "top1")
    for i := 0; i < len(xtop); i++ {
        n := xtop[i]
        if op := n.Op; op != ODCL && op != OAS && op != OAS2 && (op != ODCLTYPE || !n.Left.Name.Param.Alias) {
            xtop[i] = typecheck(n, ctxStmt)
        }
    }
​
    // Phase 2: Variable assignments.
    // 变量定义及赋值的类型检查
    timings.Start("fe", "typecheck", "top2")
    for i := 0; i < len(xtop); i++ {
        n := xtop[i]
        if op := n.Op; op == ODCL || op == OAS || op == OAS2 || op == ODCLTYPE && n.Left.Name.Param.Alias {
            xtop[i] = typecheck(n, ctxStmt)
        }
    }
​
    // Phase 3: Type check function bodies.
    // 函数体的类型检查
    timings.Start("fe", "typecheck", "func")
    var fcount int64
    for i := 0; i < len(xtop); i++ {
        n := xtop[i]
        if op := n.Op; op == ODCLFUNC || op == OCLOSURE {
            Curfn = n
            decldepth = 1
            saveerrors()
            typecheckslice(Curfn.Nbody.Slice(), ctxStmt)
            checkreturn(Curfn)
            if nerrors != 0 {
                Curfn.Nbody.Set(nil) 
            }
            deadcode(Curfn)
            fcount++
        }
    }
编译器类型检查的主要逻辑都在 typecheck 和 typecheck1 这两个函数中。

typecheck 的主要作用就是判断编译器是否对当前节点执行过类型检查,同时做一些类型检查之前的准备工作。这个函数里边调用了另一个函数typecheck1。这个函数是类型检查的核心函数。

typecheck1 包含1700多行代码,大部分代码是一个switch/case结构,根据传入点的操作类型的不同,进入不同的case中进行工作。所有的操作类型大概200个,全部定义在文件syntax.go中。

    // 然后通过checkMapKeys检查哈希中键的类型。因为在此之前我们可能会因其他错误而退出,从而跳过了map键错误。
    checkMapKeys()
该函数会遍历 mapqueue 队列中等待检查的节点,判断这些类型能否作为哈希的键,如果当前类型并不合法就会在类型检查的阶段直接报错中止整个检查的过程。

func checkMapKeys() {
    for _, n := range mapqueue {
        k := n.Type.MapType().Key
        if !k.Broke() && !IsComparable(k) {
            yyerrorl(n.Pos, "invalid map key type %v", k)
        }
    }
    mapqueue = nil
}

Phase4:如何捕捉变量

完成所有类型检查后,将在单独的阶段中调用capturevars。它决定是否应该通过值或引用来捕获由闭包捕获的每个变量。对于小于等于128字节的值,我们将使用值捕获,捕获后再也不会重新分配(有效常数)。

接下来进行捕获封闭变量。这需要在逃逸分析之前运行,因为按值捕获的变量不会发生逃逸。

    // Phase 4: Decide how to capture closed variables.
    // 捕获变量
    timings.Start("fe", "capturevars")
    for _, n := range xtop {
        if n.Op == ODCLFUNC && n.Func.Closure != nil {
            Curfn = n
            capturevars(n)
        }
    }
    capturevarscomplete = true
​
    Curfn = nil
​
    if nsavederrors+nerrors != 0 {
        errorexit()
    }

Phase5:内联

什么是内联:内联是将较小的函数组合到各自的调用程序中的行为。在计算的早期,这种优化通常是手工完成的。如今,内联是在编译过程中自动执行的一类基本优化之一。

为什么要进行内联:首先是它消除了函数调用本身的开销。第二个是它允许编译器更有效地应用其他优化策略。

func max(a, b int) int {
    if a > b {
        return a
    }
    return b
}
​
var Result int
​
func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = max(-1, i)
    }
    Result = r
}
手动内联之后:

func BenchmarkMax(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        if -1 > i {
            r = -1
        } else {
            r = i
        }
    }
    Result = r
}

效果提升了78%。

phase6:逃逸分析

也就是由编译器决定内存分配的位置,不需要程序员指定。变量分配在堆上还是栈上不是由是否new/malloc决定,而是通过编译器的“逃逸分析”来决定。

在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法——分析在程序的哪些地方可以访问到指针。也是就是说逃逸分析是解决指针作用范围的编译优化方法。编程中常见的两种逃逸情景:

1,函数中局部对象指针被返回(不确定被谁访问)

2,对象指针被多个子程序(如线程协程)共享使用

逃逸分析用于优化程序,函数中生成一个新对象:

1,如果分配到栈上,待函数返回资源就被回收了

2,如果分配到堆上,函数返回后交给GC来管理该对象资源

栈资源的分配及回收速度比堆要快,所以逃逸分析最大的好处应该是减少了GC的压力。

逃逸策略

每当函数中申请新的对象,编译器会跟据该对象是否被函数外部引用来决定是否逃逸:

  1. 如果函数外部没有引用,则优先放到栈中;

  2. 如果函数外部存在引用,则必定放到堆中;

注意,对于函数外部没有引用的对象,也有可能放到堆中,比如内存过大超过栈的存储能力。

逃逸分析的好处:减少GC的压力,不逃逸的对象分配在栈上,函数返回时回收,不需要GC标记清除,栈的回收速度更快;逃逸的对象分配在堆上,函数返回后交给GC来管理该对象资源。

指针逃逸:

package main
​
type Student struct {
    Name string
    Age  int
}
​
func StudentRegister(name string, age int) *Student {
    s := new(Student)           //局部变量s逃逸到堆 局部变量指针被返回
​
    s.Name = name
    s.Age = age
​
    return s
}
​
func main() {
    StudentRegister("Jim", 18)
}

栈空间不足引起的逃逸:

package main
​
func Slice() {
    s := make([]int, 10000, 10000)   // 切片所占空间过大,栈无法存放;或者无法判断当前切片长度时会将对象分配到堆中发生逃逸。
​
    for index, _ := range s {
        s[index] = index
    }
}
​
func main() {
    Slice()
}

动态类型逃逸:

package main
​
import "fmt"
​
func main() {
    s := "Escape"
    fmt.Println(s)      // 类似于函数Println,参数为interface类型,在编译期间很难确定参数的具体类型,也会产生逃逸。
}

逃逸分析的总结:

  • 栈上分配内存比在堆中分配内存有更高的效率

  • 栈上分配的内存不需要GC处理

  • 堆上分配的内存使用完毕会交给GC处理

  • 逃逸分析目的是决定内分配地址是栈还是堆

  • 逃逸分析在编译阶段完成

phase7:闭包转换

阶段7:转换闭包主体以正确引用捕获的变量。

什么是闭包:Go 函数可以是一个闭包。闭包是一个函数值,它引用了函数体之外的变量。 这个函数可以对这个引用的变量进行访问和赋值

闭包是匿名函数与匿名函数所引用环境的组合。匿名函数有动态创建的特性,该特性使得匿名函数不用通过参数传递的方式,就可以直接引用外部的变量。这就类似于常规函数直接使用全局变量一样,个人理解为:匿名函数和它引用的变量以及环境,类似常规函数引用全局变量处于一个包的环境。

闭包的意义:

1,没有闭包的时候,函数就是一次性买卖,函数执行完毕后就无法再更改函数中变量的值(应该是内存释放了);有了闭包后函数就成为了一个变量的值,只要变量没被释放,函数就会一直处于存活并独享的状态,因此可以后期更改函数中变量的值(因为这样就不会被go给回收内存了,会一直缓存在那里)。

2,缩小变量作用域,减少对全局变量的污染。下面的累加如果用全局变量进行实现,全局变量容易被其他人污染。

func main() {
    n := 0
    f := func() int {
        n += 1
        return n
    }
    fmt.Println(f())  
    fmt.Println(f())
}
/*
输出:
1
2
*/
n := 0
    f := func() int {
        n += 1
        return n
    }

上述代码就是一个闭包。

 

你可能感兴趣的:(LLVM)