Go未用代碼消除與可執(zhí)行文件瘦身
在日常編寫Go代碼時(shí),我們會編寫很多包,也會在編寫的包中引入了各種依賴包。在大型Go工程中,這些直接依賴和間接依賴的包數(shù)目可能會有幾十個(gè)甚至上百個(gè)。依賴包有大有小,但通常我們不會使用到依賴包中的所有導(dǎo)出函數(shù)或類型方法。
這時(shí)Go初學(xué)者就會有一個(gè)疑問:這些直接依賴包和間接依賴包中的所有代碼是否會進(jìn)入到最終的可執(zhí)行文件中呢?即便我們只是使用了某個(gè)依賴包中的一個(gè)導(dǎo)出函數(shù)。
這里先給出結(jié)論:不會!在這篇文章中,我們就來探索一下這個(gè)話題,了解一下其背后的支撐機(jī)制以及對Go可執(zhí)行文件Size的影響。
1. 實(shí)驗(yàn):哪些函數(shù)進(jìn)入到最終的可執(zhí)行文件中了?
我們先來做個(gè)實(shí)驗(yàn),驗(yàn)證一下究竟哪些函數(shù)進(jìn)入到最終的可執(zhí)行文件中了!我們建立demo1,其目錄結(jié)構(gòu)和部分代碼如下:
// dead-code-elimination/demo1
$tree -F .
.
├── go.mod
├── main.go
└── pkga/
└── pkga.go
// main.go
package main
import (
"fmt"
"demo/pkga"
)
func main() {
result := pkga.Foo()
fmt.Println(result)
}
// pkga/pkga.go
package pkga
import (
"fmt"
)
func Foo() string {
return "Hello from Foo!"
}
func Bar() {
fmt.Println("This is Bar.")
}
這個(gè)示例十分簡單!main函數(shù)中調(diào)用了pkga包的導(dǎo)出函數(shù)Foo,而pkga包中除了Foo函數(shù),還有Bar函數(shù)(但并沒有被任何其他函數(shù)調(diào)用)?,F(xiàn)在我們來編譯一下這個(gè)module,然后查看一下編譯出的可執(zhí)行文件中都包含pkga包的哪些函數(shù)!(本文實(shí)驗(yàn)中使用的Go為1.22.0版本[1])
$go build
$go tool nm demo|grep demo
在輸出的可執(zhí)行文件中,居然沒有查到關(guān)于pkga的任何符號信息,這可能是Go的優(yōu)化在“作祟”。我們關(guān)閉掉Go編譯器的優(yōu)化后,再來試試:
$go build -gcflags '-l -N'
$go tool nm demo|grep demo
108ca80 T demo/pkga.Foo
關(guān)掉內(nèi)聯(lián)優(yōu)化[2]后,我們看到pkga.Foo出現(xiàn)在最終的可執(zhí)行文件demo中,但并未被調(diào)用的Bar函數(shù)并沒有進(jìn)入可執(zhí)行文件demo中。
我們再來看一下有間接依賴的例子:
// dead-code-elimination/demo2
$tree .
.
├── go.mod
├── main.go
├── pkga
│ └── pkga.go
└── pkgb
└── pkgb.go
// pkga/pkga.go
package pkga
import (
"demo/pkgb"
"fmt"
)
func Foo() string {
pkgb.Zoo()
return "Hello from Foo!"
}
func Bar() {
fmt.Println("This is Bar.")
}
在這個(gè)示例中,我們在pkga.Foo函數(shù)中又調(diào)用了一個(gè)新包pkgb的Zoo函數(shù),我們來編譯一下該新示例并查看一下哪些函數(shù)進(jìn)入到最終的可執(zhí)行文件中:
$go build -gcflags='-l -N'
$go tool nm demo|grep demo
1093b40 T demo/pkga.Foo
1093aa0 T demo/pkgb.Zoo
我們看到:只有程序執(zhí)行路徑上能夠觸達(dá)(被調(diào)用)的函數(shù)才會進(jìn)入到最終的可執(zhí)行文件中!
在復(fù)雜的示例中,我們也可以通過帶有-ldflags='-dumpdep'的go build命令來查看這種調(diào)用依賴關(guān)系(這里以demo2為例):
$go build -ldflags='-dumpdep' -gcflags='-l -N' > deps.txt 2>&1
$grep demo deps.txt
# demo
main.main -> demo/pkga.Foo
demo/pkga.Foo -> demo/pkgb.Zoo
demo/pkga.Foo -> go:string."Hello from Foo!"
demo/pkgb.Zoo -> math/rand.Int31n
demo/pkgb.Zoo -> demo/pkgb..stmp_0
demo/pkgb..stmp_0 -> go:string."Zoo in pkgb"
到這里,我們知道了Go通過某種機(jī)制保證了只有真正使用到的代碼才會最終進(jìn)入到可執(zhí)行文件中,即便某些代碼(比如pkga.Bar)和那些被真正使用的代碼(比如pkga.Foo)在同一個(gè)包內(nèi)。這同時(shí)保證了最終可執(zhí)行文件大小在可控范圍內(nèi)。
接下來,我們就來看看Go的這種機(jī)制。
2. 未用代碼消除(dead code elimination)
我們先來復(fù)習(xí)一下go build的構(gòu)建過程,以下是 go build 命令的步驟概述:
- 讀取go.mod和go.sum:如果當(dāng)前目錄包含go.mod文件,go build會讀取該文件以確定項(xiàng)目的依賴項(xiàng)。它還會根據(jù)go.sum文件中的校驗(yàn)和驗(yàn)證依賴項(xiàng)的完整性。
- 計(jì)算包依賴圖:go build 分析正在構(gòu)建的包及其依賴項(xiàng)中的導(dǎo)入語句,以構(gòu)建依賴圖。該圖表示包之間的關(guān)系,使編譯器能夠確定包的構(gòu)建順序。
- 決定要構(gòu)建的包:基于構(gòu)建緩存和依賴圖,go build 確定需要構(gòu)建的包。它檢查構(gòu)建緩存,以查看已編譯的包是否是最新的。如果自上次構(gòu)建以來某個(gè)包或其依賴項(xiàng)發(fā)生了更改,go build將重新構(gòu)建這些包。
- 調(diào)用編譯器(go tool compile):對于每個(gè)需要構(gòu)建的包,go build調(diào)用Go編譯器(go tool compile)。編譯器將Go源代碼轉(zhuǎn)換為特定目標(biāo)平臺的機(jī)器碼,并生成目標(biāo)文件(.o 文件)。
- 調(diào)用鏈接器(go tool link):在編譯所有必要的包之后,go build 調(diào)用 Go 鏈接器(go tool link)。鏈接器將編譯器生成的目標(biāo)文件合并為可執(zhí)行二進(jìn)制文件或包歸檔文件。它解析包之間的符號和引用,執(zhí)行必要的重定位,并生成最終的輸出。
上述的整個(gè)構(gòu)建過程可以由下圖表示:
圖片
在構(gòu)建過程中,go build 命令還執(zhí)行各種優(yōu)化,例如未用代碼消除和內(nèi)聯(lián),以提高生成二進(jìn)制文件的性能和降低二進(jìn)制文件的大小。其中的未用代碼消除就是保證Go生成的二進(jìn)制文件大小可控的重要機(jī)制。
未用檢測算法的實(shí)現(xiàn)位于 $GOROOT/src/cmd/link/internal/ld/deadcode.go文件中。該算法通過圖遍歷的方式進(jìn)行,具體過程如下:
- 從系統(tǒng)的入口點(diǎn)開始,標(biāo)記所有可通過重定位到達(dá)的符號。重定位是兩個(gè)符號之間的依賴關(guān)系。
- 通過遍歷重定位關(guān)系,算法標(biāo)記所有可以從入口點(diǎn)訪問到的符號。例如,在主函數(shù)main.main中調(diào)用了pkga.Foo函數(shù),那么main.main會有對這個(gè)函數(shù)的重定位信息。
- 標(biāo)記完成后,算法會將所有未被標(biāo)記的符號標(biāo)記為不可達(dá)的未用。這些未被標(biāo)記的符號表示不會被入口點(diǎn)或其他可達(dá)符號訪問到的代碼。
不過,這里有一個(gè)特殊的語法元素要注意,那就是帶有方法的類型。類型的方法是否進(jìn)入到最終的可執(zhí)行文件中,需要考慮不同情況。在deadcode.go,用于標(biāo)記可達(dá)符號的函數(shù)實(shí)現(xiàn)將可達(dá)類型的方法的調(diào)用方式分為三種:
- 直接調(diào)用
- 通過可到達(dá)的接口類型調(diào)用
- 通過反射調(diào)用:reflect.Value.Method(或 MethodByName)或 reflect.Type.Method(或 MethodByName)
第一種情況,可以直接將調(diào)用的方法被標(biāo)記為可到達(dá)。第二種情況通過將所有可到達(dá)的接口類型分解為方法簽名來處理。遇到的每個(gè)方法都與接口方法簽名進(jìn)行比較,如果匹配,則將其標(biāo)記為可到達(dá)。這種方法非常保守,但簡單且正確。
第三種情況通過尋找編譯器標(biāo)記為REFLECTMETHOD的函數(shù)來處理。函數(shù)F上的REFLECTMETHOD意味著F使用反射進(jìn)行方法查找,但編譯器無法在靜態(tài)分析階段確定方法名。因此所有調(diào)用reflect.Value.Method 或reflect.Type.Method的函數(shù)都是REFLECTMETHOD。調(diào)用reflect.Value.MethodByName或reflect.Type.MethodByName且參數(shù)為非常量的函數(shù)也是REFLECTMETHOD。如果我們找到了REFLECTMETHOD,就會放棄靜態(tài)分析,并將所有可到達(dá)類型的導(dǎo)出方法標(biāo)記為可達(dá)。
下面是一個(gè)來自參考資料中的示例:
// dead-code-elimination/demo3/main.go
type 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()
}
在這個(gè)示例中,類型*X有三個(gè)方法,類型*Y有兩個(gè)方法,在main函數(shù)中,我們通過反射調(diào)用X實(shí)例的方法,通過Y實(shí)例直接調(diào)用Y的方法,我們看看最終X和Y都有哪些方法進(jìn)入到最后的可執(zhí)行文件中了:
$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
... ...
我們看到通過直接調(diào)用的可達(dá)類型Y只有代碼中直接調(diào)用的方法Five進(jìn)入到最終可執(zhí)行文件中,而通過反射調(diào)用的X的所有方法都可以在最終可執(zhí)行文件找到!這與前面提到的第三種情況一致。
3. 小結(jié)
本文介紹了Go語言中的未用代碼消除和可執(zhí)行文件瘦身機(jī)制。通過實(shí)驗(yàn)驗(yàn)證,只有在程序執(zhí)行路徑上被調(diào)用的函數(shù)才會進(jìn)入最終的可執(zhí)行文件,未被調(diào)用的函數(shù)會被消除。
本文解釋了Go編譯過程,包括包依賴圖計(jì)算、編譯和鏈接等步驟,并指出未用代碼消除是其中的重要優(yōu)化策略。具體的未用代碼消除算法是通過圖遍歷實(shí)現(xiàn)的,標(biāo)記可達(dá)的符號并將未被標(biāo)記的符號視為未用。文章還提到了對類型方法的處理方式。
通過這種未用代碼消除機(jī)制,Go語言能夠控制最終可執(zhí)行文件的大小,實(shí)現(xiàn)可執(zhí)行文件瘦身。
本文涉及的源碼可以在這里[3]下載。
4. 參考資料
- Getting the most out of Dead Code elimination[4] - https://golab.io/talks/getting-the-most-out-of-dead-code-elimination
- all: binaries too big and growing[5] - https://github.com/golang/go/issues/6853
- aarzilli/whydeadcode[6] - https://github.com/aarzilli/whydeadcode