在日常编写Go代码时,我们会编写很多包,也会在编写的包中引入了各种依赖包。在大型Go工程中,这些直接依赖和间接依赖的包数目可能会有几十个甚至上百个。依赖包有大有小,但通常我们不会使用到依赖包中的所有导出函数或类型方法。
这时Go初学者就会有一个疑问:这些直接依赖包和间接依赖包中的所有代码是否会进入到最终的可执行文件中呢?即便我们只是使用了某个依赖包中的一个导出函数。
这里先给出结论:不会!在这篇文章中,我们就来探索一下这个话题,了解一下其背后的支撑机制以及对Go可执行文件Size的影响。
我们先来做个实验,验证一下究竟哪些函数进入到最终的可执行文件中了!我们建立demo1,其目录结构和部分代码如下:
// dead-code-elimination/demo1 $tree -F ..├── go.mod├── main.go└── pkga/ └── pkga.go// main.gopackage main import ( "fmt" "demo/pkga")func main() { result := pkga.Foo() fmt.Println(result)}// pkga/pkga.gopackage pkgaimport ( "fmt")func Foo() string { return "Hello from Foo!"}func Bar() { fmt.Println("This is Bar.")}
这个示例十分简单!main函数中调用了pkga包的导出函数Foo,而pkga包中除了Foo函数,还有Bar函数(但并没有被任何其他函数调用)。现在我们来编译一下这个module,然后查看一下编译出的可执行文件中都包含pkga包的哪些函数!(本文实验中使用的Go为1.22.0版本[1])
$go build$go tool nm demo|grep demo
在输出的可执行文件中,居然没有查到关于pkga的任何符号信息,这可能是Go的优化在“作祟”。我们关闭掉Go编译器的优化后,再来试试:
$go build -gcflags '-l -N'$go tool nm demo|grep demo 108ca80 T demo/pkga.Foo
关掉内联优化[2]后,我们看到pkga.Foo出现在最终的可执行文件demo中,但并未被调用的Bar函数并没有进入可执行文件demo中。
我们再来看一下有间接依赖的例子:
// dead-code-elimination/demo2$tree ..├── go.mod├── main.go├── pkga│ └── pkga.go└── pkgb └── pkgb.go// pkga/pkga.gopackage pkgaimport ( "demo/pkgb" "fmt")func Foo() string { pkgb.Zoo() return "Hello from Foo!"}func Bar() { fmt.Println("This is Bar.")}
在这个示例中,我们在pkga.Foo函数中又调用了一个新包pkgb的Zoo函数,我们来编译一下该新示例并查看一下哪些函数进入到最终的可执行文件中:
$go build -gcflags='-l -N'$go tool nm demo|grep demo 1093b40 T demo/pkga.Foo 1093aa0 T demo/pkgb.Zoo
我们看到:只有程序执行路径上能够触达(被调用)的函数才会进入到最终的可执行文件中!
在复杂的示例中,我们也可以通过带有-ldflags='-dumpdep'的go build命令来查看这种调用依赖关系(这里以demo2为例):
$go build -ldflags='-dumpdep' -gcflags='-l -N' > deps.txt 2>&1$grep demo deps.txt# demomain.main -> demo/pkga.Foodemo/pkga.Foo -> demo/pkgb.Zoodemo/pkga.Foo -> go:string."Hello from Foo!"demo/pkgb.Zoo -> math/rand.Int31ndemo/pkgb.Zoo -> demo/pkgb..stmp_0demo/pkgb..stmp_0 -> go:string."Zoo in pkgb"
到这里,我们知道了Go通过某种机制保证了只有真正使用到的代码才会最终进入到可执行文件中,即便某些代码(比如pkga.Bar)和那些被真正使用的代码(比如pkga.Foo)在同一个包内。这同时保证了最终可执行文件大小在可控范围内。
接下来,我们就来看看Go的这种机制。
我们先来复习一下go build的构建过程,以下是 go build 命令的步骤概述:
上述的整个构建过程可以由下图表示:
图片
在构建过程中,go build 命令还执行各种优化,例如未用代码消除和内联,以提高生成二进制文件的性能和降低二进制文件的大小。其中的未用代码消除就是保证Go生成的二进制文件大小可控的重要机制。
未用检测算法的实现位于 $GOROOT/src/cmd/link/internal/ld/deadcode.go文件中。该算法通过图遍历的方式进行,具体过程如下:
不过,这里有一个特殊的语法元素要注意,那就是带有方法的类型。类型的方法是否进入到最终的可执行文件中,需要考虑不同情况。在deadcode.go,用于标记可达符号的函数实现将可达类型的方法的调用方式分为三种:
第一种情况,可以直接将调用的方法被标记为可到达。第二种情况通过将所有可到达的接口类型分解为方法签名来处理。遇到的每个方法都与接口方法签名进行比较,如果匹配,则将其标记为可到达。这种方法非常保守,但简单且正确。
第三种情况通过寻找编译器标记为REFLECTMETHOD的函数来处理。函数F上的REFLECTMETHOD意味着F使用反射进行方法查找,但编译器无法在静态分析阶段确定方法名。因此所有调用reflect.Value.Method 或reflect.Type.Method的函数都是REFLECTMETHOD。调用reflect.Value.MethodByName或reflect.Type.MethodByName且参数为非常量的函数也是REFLECTMETHOD。如果我们找到了REFLECTMETHOD,就会放弃静态分析,并将所有可到达类型的导出方法标记为可达。
下面是一个来自参考资料中的示例:
// dead-code-elimination/demo3/main.gotype X struct{}type Y struct{}func (*X) One() { fmt.Println("hello 1") }func (*X) Two() { fmt.Println("hello 2") }func (*X) Three() { fmt.Println("hello 3") }func (*Y) Four() { fmt.Println("hello 4") }func (*Y) Five() { fmt.Println("hello 5") }func main() { var name string fmt.Scanf("%s", &name) reflect.ValueOf(&X{}).MethodByName(name).Call(nil) var y Y y.Five()}
在这个示例中,类型*X有三个方法,类型*Y有两个方法,在main函数中,我们通过反射调用X实例的方法,通过Y实例直接调用Y的方法,我们看看最终X和Y都有哪些方法进入到最后的可执行文件中了:
$go build -gcflags='-l -N'$go tool nm ./demo|grep main 11d59c0 D go:main.inittasks 10d4500 T main.(*X).One 10d4640 T main.(*X).Three 10d45a0 T main.(*X).Two 10d46e0 T main.(*Y).Five 10d4780 T main.main... ...
我们看到通过直接调用的可达类型Y只有代码中直接调用的方法Five进入到最终可执行文件中,而通过反射调用的X的所有方法都可以在最终可执行文件找到!这与前面提到的第三种情况一致。
本文介绍了Go语言中的未用代码消除和可执行文件瘦身机制。通过实验验证,只有在程序执行路径上被调用的函数才会进入最终的可执行文件,未被调用的函数会被消除。
本文解释了Go编译过程,包括包依赖图计算、编译和链接等步骤,并指出未用代码消除是其中的重要优化策略。具体的未用代码消除算法是通过图遍历实现的,标记可达的符号并将未被标记的符号视为未用。文章还提到了对类型方法的处理方式。
通过这种未用代码消除机制,Go语言能够控制最终可执行文件的大小,实现可执行文件瘦身。
本文涉及的源码可以在这里[3]下载。
本文链接://www.dmpip.com//www.dmpip.com/showinfo-26-87033-0.htmlGo未用代码消除与可执行文件瘦身
声明:本网页内容旨在传播知识,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。邮件:2376512515@qq.com
上一篇: 15个适合后端程序员的前端框架
下一篇: 我使用缓存,踩过的7个坑