《从.go 文本文件到可执行文件》一文中,我们简单描述了 Go 编译器的工作流程。本文将继续深入其中的一些代码优化的工作。
前情回顾
死代码消除
死代码消除( dead code elimination, 缩写 DCE )是用来移除对程序执行结果没有任何影响的代码,以此 减少程序的体积大小 ,并且还可以避免程序在执行过程中进行一些不必要的运算行为,从而 减少执行时间 。
需要注意的是,除了不会执行到的代码( unreachable code ),一些只会影响到无关程序执行结果的变量( dead variables ),也属于死码( dead code )的范畴。
简单示例:
代码语言:javascript复制package main
func main() {
const a, b = 200, 100
var max int
if a > b {
max = a
} else {
max = b
}
if max == b {
panic(b)
}
}
对于常量 a
和 b
,编译器在编译时可以判断出 a
永远是大于 b
的,即 a > b
永远为 true
,也就是说 else {}
分支属于 unreachable code 将永远不会被执行,所以编译器会进行第一次优化:分支消除
package main
func main() {
const a, b = 200, 100
const max = a
if max == b {
panic(b)
}
}
由于 max
变量后续没有再被引用,所以 max
实际也是一个常量。相同道理,max == b
永远为 false
,编译器会进行第二次分支消除优化:
package main
func main() {
const a, b = 200, 100
const max = a
}
对于剩下的常量则明显属于 dead variables ,再次优化:
代码语言:javascript复制package main
func main() {
}
我们可以查看最初程序的 SSA 生成过程来验证:
代码语言:javascript复制$ GOSSAFUNC=main go build main.go
查看生成的 ssa.html
:
死代码消除过程
最终生成的 SSA
可以看到,main 函数内的所有逻辑确实都被编译器优化掉了。
函数内联
如果程序中存在大量的小函数的调用,函数内联(function call inlining)就会直接用函数体替换掉函数调用来 减少因为函数调用而造成的额外上下文切换开销 。
简单示例:
代码语言:javascript复制package main
func main() {
n := 1
for i := 0; i < 10; i {
n = double(n)
}
println(n)
}
func double(n int) int {
return 2 * n
}
对于上面的代码,编译器内联优化后会变成:
代码语言:javascript复制package main
func main() {
n := 1
for i := 0; i < 10; i {
n = 2 * n
}
println(n)
}
Go 编译器会计算函数内联所花费的成本,只有满足相关策略时才会进行内联优化,最简单的当函数内有 go
、defer
、select
等关键字时就不会发生内联,具体的策略可以直接查看源码:
内联优化相关源码
使用 go tool compile -m=2 main.go
或 go build -gcflags="-m -m" main.go
可以输出内联优化的相关信息( -m 的数量越多输出结果越详细)
$ go tool compile -m=2 main.go
main.go:11:6: can inline double with cost 4 as: func(int) int { return 2 * n }
main.go:3:6: can inline main with cost 28 as: func() { n := 1; for loop; println(n) }
main.go:6:13: inlining call to double
或者也可以输出汇编代码查看是否有进行 double
函数的调用,这里显然是没有的:
$ go tool compile -S main.go | grep CALL.*double
如果我们不想一个函数被内联,可以直接在其函数定义时加一个 //go:noinline
注释:
//go:noinline
func double(n int) int {
return 2 * n
}
同样可以进行验证:
代码语言:javascript复制$ go tool compile -S main.go | grep CALL.*double
0x0025 00037 (main.go:6) CALL "".double(SB)
$ go tool compile -m=2 main.go
main.go:12:6: cannot inline double: marked go:noinline
main.go:3:6: cannot inline main: function too complex: cost 81 exceeds budget 80
可以看到此时还输出了函数无法内联的原因。
如果希望所有函数都不执行内联操作,可以直接为编译器选项加上 -l
参数,即 go build -gcflags="-l" main.go
(如果 -l
数量大于等于 2 ,编译器将会采用更激进的内联策略,但也可能会生成更大的二进制文件)。
正常情况,我们直接使用编译器默认选项即可。
逃逸分析
不同于 C 语言的手动内存管理方式(通过 malloc
分配堆内存对象, free
手动释放),带有 GC 机制的 Go 语言在编译阶段会进行逃逸分析,自动决定将变量分配到 goroutine 的栈(stack)内存区或者全局的堆(heap)内存区上 。
其中的逃逸规则有很多,最简单的一种是:如果变量超出了函数调用的生命周期,编译器就会将其逃逸到堆上。
简单示例:
代码语言:javascript复制package main
func main() {
A()
B()
}
func A() int {
a := 1024
return a
}
func B() *int {
b := 1024
return &b
}
重点关注返回指针类型的 B
函数,通过 go tool compile -l -m=2 main.go
来查看逃逸结果( -l 是全局禁止函数内联,避免影响逃逸分析):
$ go tool compile -l -m=2 main.go
main.go:14:2: b escapes to heap:
main.go:14:2: flow: ~r0 = &b:
main.go:14:2: from &b (address-of) at main.go:15:9
main.go:14:2: from return &b (return) at main.go:15:2
main.go:14:2: moved to heap: b
根据结果可以看出 B
函数中分配的变量 b
逃逸到了堆上(moved to heap: b),而对于全程在 A
函数生命周期内的 a
变量则没有发生逃逸(直接在栈上分配了)。
$ go tool compile -S main.go | grep runtime.newobject
0x0020 00032 (main.go:14) CALL runtime.newobject(SB)
rel 33 4 t=7 runtime.newobject 0
从汇编来看,也只有在 main.go:14
(对应源码:b := 1024
)位置处才调用了 runtime.newobject
函数。
runtime.newobject 源码
而 runtime.newobject
函数的作用正是执行 malloc
动作 在堆上分配内存 。
在栈上分配内存,将直接由 CPU 提供 push(入栈)和 pop(出栈) 指令支持,但在堆上分配,就需要额外等待 Go GC 负责回收,虽然 Go GC 十分高效,但也不可避免会造成一定的性能损耗。
所以如果想要追求极致性能,我们就要尽量避免一些不必要的堆内存分配。