1.抽象语法树构建
- 编译器前端必须构建程序的中间表示形式,以便在编译器中间阶段及后端使用。
- 抽象语法树(Abstract Syntax Tree,AST)是一种常见的树状结构的中间态。
- 在Go语言源文件中的任何一种import、type、const、func声明都是一个根节点,在根节点下包含当前声明的子节点。
- 核心逻辑代码位于
go/src/cmd/compile/internal/gc/noder.go
中。 - 每个节点都包含了当前节点属性的Op字段,定义在
go/src/cmd/compile/internal/gc/syntax.go
中,以O开头。 - 与词法解析阶段中的token相同的是,Op 字段也是一个整数。不同的是,每个 Op 字段都包含了语义信息,例如,当一个节点的 Op 操作为 OAS 时,该节点代表的语义为 Left:=Right,而当节点的操作为 OAS2 时,代表的语义为x,y,z=a,b,c。
func (p *noder) decls(decls []syntax.Decl) (l []*Node) { var cs constState for _, decl := range decls { p.setlineno(decl) switch decl := decl.(type) { case *syntax.ImportDecl: p.importDecl(decl) case *syntax.VarDecl: l = append(l, p.varDecl(decl)...) case *syntax.ConstDecl: l = append(l, p.constDecl(decl, &cs)...) case *syntax.TypeDecl: l = append(l, p.typeDecl(decl)) case *syntax.FuncDecl: l = append(l, p.funcDecl(decl)) default: panic("unhandled Decl") } } return}
2.类型检查
- 完成抽象语法树的初步构建后,就进入类型检查阶段遍历节点树并决定节点的类型。
- 这其中包括了语法中明确指定的类型,例如
var a int
,也包括了需要通过编译器类型推断得到的类型。 - 在类型检查阶段,会对一些类型做特别的语法或语义检查。例如:引用的结构体字段是否是大写可导出的?数组字面量的访问是否超过了其长度?数组的索引是不是正整数?
- 在类型检查阶段还会进行其他工作。例如:计算编译时常量、将标识符与声明绑定等。类型检查的核心逻辑位于
go/src/cmd/compile/internal/gc/typecheck.go
中。
3.变量捕获
- 变量捕获主要是针对闭包场景而言的,由于闭包函数中可能引用闭包外的变量,因此变量捕获需要明确在闭包中通过值引用或地址引用的方式来捕获变量。
- 类型检查阶段完成后,Go语言编译器将对抽象语法树进行分析及重构,从而完成一系列优化。
- 变量捕获的核心逻辑位于
go/src/cmd/compile/internal/gc/closure.go
文件的capturevars
函数中。
package main import ( "fmt") func main() { a := "qinshixian" b := make(map[string]int) c := "haoweilai" go func() { fmt.Println(a) fmt.Println(b) fmt.Println(c) }() a = "asddss"}
使用 go tool compile -m=2 getVar.go| grep capturing
命令可以查看变量捕获信息,如下图:
从上图可以看出变量 a
采用 ref
引用传递方式,变量 b
、c
采用 value
值传递的方式。
4.函数内联
- 函数内联指将较小的函数直接组合进调用者的函数。
- 函数内联的优势在于,可以减少函数调用带来的开销。
- 对于 Go 语言来说,函数调用的成本在于参数与返回值栈复制、较小的栈寄存器开销以及函数序言部分的检查栈扩容(Go语言中的栈是可以动态扩容的)。
- Go语言编译器会计算函数内联花费的成本,只有执行相对简单的函数时才会内联。
- 函数内联的核心代码位于
go/src/cmd/compile/internal/gc/inl.go
中。 - 当函数内部有
for
、range
、go
、select
等语句时,该函数不会被内联。 - 若希望程序中所有的函数都不执行内联操作,那么可以添加编译器选项 “-l”。
- 函数内联效率提升举例:
package main import "testing" //go:noinlinefunc 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}
Tips:
//go:noinline
表示当前函数禁止函数内联优化。
加上注释 //go:noinline
运行 go test leetcode_test.go -bench=.
如下图:
没有加上注释 //go:noinline
运行 go test leetcode_test.go -bench=.
如下图:
函数内部有 for
、range
、go
、select
等语句时,如下图:
若想查看函数是否可以使用函数内联,可使用 命令 go tool compile -m=2 leetcode_test.go
,如下图所示:
4.逃逸分析
- 逃逸分析也是 Go 编译阶段中的优化,用于标识变量内存应该被分配在栈区还是堆区。
- 若函数返回了一个栈上的对象指针,函数执行完成后,栈被销毁,访问被销毁栈上的对象指针就会出现问题,逃逸分析能识别这种问题,将该变量放置到堆区,并借助 Go 运行时的垃圾回收机制自动释放内存。
- 编译器会尽可能地将变量放置到栈中,因为栈中的对象随着函数调用结束会被自动销毁,减轻运行时分配和垃圾回收的负担。
- 逃逸分析核心代码位置在
go/src/cmd/compile/internal/gc/escape .go
文件中。 - 不管是字符串、数组字面量,还是通过 new、make 标识符创建的对象,都既可能被分配到栈中,也可能被分配到堆中。分配时,遵循以下两个原则:
(1)原则1:指向栈上对象的指针不能被存储到堆中 (2)原则2:指向栈上对象的指针不能超过该栈对象的生命周期
- 推荐一篇煎鱼大佬写的《灵魂拷问 Go 语言:这个变量到底分配到哪里了?》
- 逃逸现象举例:
package main var n *int func escape(){ a := 100 n = &a} func main() { escape()}
使用命令 go tool compile -m getVar.go
,如下图所示:
其中变量 n
是一个 int
型指针,若 a
被分配到栈中,变量 n
超出了 a
的生命周期范围,违背了上述原则2,所以 a
需要被分配到堆中。