Go 死码消除与调试(debug)模式
源代码/数据集已上传到 Github - high-performance-go
1 什么是死码消除
以下摘自内容 Dead code elimination - wikipedia
In compiler theory, dead code elimination (also known as DCE, dead code removal, dead code stripping, or dead code strip) is a compiler optimization to remove code which does not affect the program results.
死码消除(dead code elimination, DCE)是一种编译器优化技术,用处是在编译阶段去掉对程序运行结果没有任何影响的代码。
Removing such code has several benefits: it shrinks program size, an important consideration in some contexts, and it allows the running program to avoid executing irrelevant operations, which reduces its running time.
死码消除有很多好处:减小程序体积,程序运行过程中避免执行无用的指令,缩短运行时间。
2 Go 语言中的应用
2.1 使用常量提升性能
在某些场景下,将变量替换为常量,性能会有很大的提升。
举一个简单的例子,以下是 maxvar.go
的代码:
1 | // maxvar.go |
- max 是一个非常简单的函数,返回两个值中的较大值。
- a 和 b 是两个全局变量,赋值为 10 和 20。
- 如果 a 大于 b,那么将会调用 time.Sleep() 休眠 3 秒。
拷贝 maxvar.go
为 maxconst.go
,并将 var a, b
修改为 const a, b
。
1 | // maxconst.go |
编译 maxvar.go
和 maxconst.go
,并比较编译后的二进制大小:
1 | go build -o maxvar maxvar.go |
我们可以看到 maxconst
比 maxvar
体积小了约 10% = 0.22 MB。
为什么会出现 11% 的差异呢?
我们使用 -gcflags=-m
参数看一下编译器做了哪些优化:
1 | go build -gcflags=-m -o maxvar maxvar.go |
max 函数被内联了,即被展开了,手动展开后如下:
1 | func main() { |
那如果 a 和 b 均为常量(const)呢?那在编译阶段就可以直接进行计算:
1 | func main() { |
计算之后,10 > 20
永远为假,那么分支消除后:
1 | func main() { |
进一步,20 == 10
也永远为假,再次分支消除:
1 | func main() {} |
但是如果全局变量 a、b 不为常量,即 maxvar
中声明的一样,编译器并不知道运行过程中 a、b 会不会发生改变,因此不能够进行死码消除,这部分代码被编译到最终的二进制程序中。因此 maxvar
比 maxconst
二进制体积大了约 10%。
如果在 if 语句中,调用了更多的库,死码消除之后,体积差距会更大。
因此,在声明全局变量时,如果能够确定为常量,尽量使用 const 而非 var,这样很多运算在编译器即可执行。死码消除后,既减小了二进制的体积,又可以提高运行时的效率,如果这部分代码是 hot path
,那么对性能的提升会更加明显。
2.2 可推断的局部变量
考虑另一种情况,a、b 作为局部变量呢?
1 | // maxvarlocal |
编译结果如下,大小与 varconst
一致,即 a、b 作为局部变量时,编译器死码消除是生效的。
1 | $ go build -o maxvarlocal maxvarlocal.go |
那如果再修改一下,函数中增加修改 a、b 变量的并发操作。
1 | func main() { |
编译结果如下,大小增加了 10%,此时,a、b 的值不能有效推断,死码消除失效。
1 | $ go build -o maxvarlocal maxvarlocal.go |
其实这个结果很好理解,包(package)级别的变量和函数内部的局部变量的推断难度是不一样的。函数内部的局部变量的修改只会发生在该函数中。但是如果是包级别的变量,对该变量的修改可能出现在:
- 包初始化函数 init() 中,init() 函数可能有多个,且可能位于不同的
.go
源文件。 - 包内的其他函数。
- 如果是 public 变量(首字母大写),其他包引用时可修改。
推断 package 级别的变量是否被修改难度是非常大的,从上述的例子看,Go 编译器只对局部变量作了优化。
以上例子,基于 go1.13.6 darwin/amd64
2.3 调试(debug)模式
我们可以在源代码中,定义全局常量 debug,值设置为 false
,在需要增加调试代码的地方,使用条件语句 if debug
包裹,例如下面的例子:
1 | const debug = false |
如果是正常编译,常量 debug 始终等于 false
,调试语句在编译过程中会被消除,不会影响最终的二进制大小,也不会对运行效率产生任何影响。
那如果我们想编译出 debug 版本的二进制呢?可以将 debug 修改为 true 之后编译。这对于开发者日常调试是非常有帮助的,日常开发过程中,在进行单元测试或者是简单的集成测试时,希望能够执行一些额外的操作,例如打印日志,或者是修改变量的值。提交代码时,再将 debug 修改为 false,开发过程中增加的额外的调试代码在编译时会被消除,不会对正式版本产生任何的影响。
Go 语言源代码中有很多这样的例子:
1 | $ grep -nr "const debug = false" "$(dirname $(which go))/../src" |
2.4 条件编译
有没有不修改源代码,也能编译出 debug 版本的方式呢?
答案是肯定的:有,可结合 build tags 来实现条件编译。
新建 release.go
和 debug.go
:
- debug.go
1 | // +build debug |
- release.go
1 | // +build !debug |
在 main.go
中去掉常量 debug 的定义:
1 | package main |
// +build debug
表示 build tags 中包含 debug 时,该源文件参与编译。// +build !debug
表示 build tags 中不包含 debug 时,该源文件参与编译。
一个源文件中可以有多个 build tags,同一行的空格隔开的 tag 之间是逻辑或的关系,不同行之间的 tag 是逻辑与的关系。例如下面的写法表示:此源文件只能在 linux/386 或者 darwin/386 平台下编译。
1 | // +build linux darwin |
接下来,我们编译一个 debug 版本并运行:
1 | $ go build -tags debug -o debug . |
编译 release 版本并运行:
1 | $ go build -o release . |
除了全局布尔值常量 debug
以外,debug.go
和 release.go
还可以根据需要添加其他代码。例如,相同的函数定义,debug 和 release 模式下有不同的函数实现。
附 推荐与参考
edit this page last updated at 2023-11-15