在日常編寫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]下載。
本文鏈接:http://www.www897cc.com/showinfo-26-87033-0.htmlGo未用代碼消除與可執行文件瘦身
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
上一篇: 15個適合后端程序員的前端框架
下一篇: 我使用緩存,踩過的7個坑