Go 未用代碼消除與可執行文件瘦身
在日常編寫 Go 代碼時,我們會編寫很多包,也會在編寫的包中引入了各種依賴包。在大型 Go 工程中,這些直接依賴和間接依賴的包數目可能會有幾十個甚至上百個。依賴包有大有小,但通常我們不會使用到依賴包中的所有導出函數或類型方法。
這時 Go 初學者就會有一個疑問:這些直接依賴包和間接依賴包中的所有代碼是否會進入到最終的可執行文件中呢?即便我們只是使用了某個依賴包中的一個導出函數。
這裏先給出結論:不會!在這篇文章中,我們就來探索一下這個話題,瞭解一下其背後的支撐機制以及對 Go 可執行文件 Size 的影響。
- 實驗:哪些函數進入到最終的可執行文件中了?
我們先來做個實驗,驗證一下究竟哪些函數進入到最終的可執行文件中了!我們建立 demo1,其目錄結構和部分代碼如下:
// 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.")
}
這個示例十分簡單!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.go
package pkga
import (
"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
# 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 通過某種機制保證了只有真正使用到的代碼纔會最終進入到可執行文件中,即便某些代碼(比如 pkga.Bar)和那些被真正使用的代碼(比如 pkga.Foo)在同一個包內。這同時保證了最終可執行文件大小在可控範圍內。
接下來,我們就來看看 Go 的這種機制。
- 未用代碼消除 (dead code elimination)
我們先來複習一下 go build 的構建過程,以下是 go build 命令的步驟概述:
-
讀取 go.mod 和 go.sum:如果當前目錄包含 go.mod 文件,go build 會讀取該文件以確定項目的依賴項。它還會根據 go.sum 文件中的校驗和驗證依賴項的完整性。
-
計算包依賴圖:go build 分析正在構建的包及其依賴項中的導入語句,以構建依賴圖。該圖表示包之間的關係,使編譯器能夠確定包的構建順序。
-
決定要構建的包:基於構建緩存和依賴圖,go build 確定需要構建的包。它檢查構建緩存,以查看已編譯的包是否是最新的。如果自上次構建以來某個包或其依賴項發生了更改,go build 將重新構建這些包。
-
調用編譯器(go tool compile):對於每個需要構建的包,go build 調用 Go 編譯器(go tool compile)。編譯器將 Go 源代碼轉換爲特定目標平臺的機器碼,並生成目標文件(.o 文件)。
-
調用鏈接器(go tool link):在編譯所有必要的包之後,go build 調用 Go 鏈接器(go tool link)。鏈接器將編譯器生成的目標文件合併爲可執行二進制文件或包歸檔文件。它解析包之間的符號和引用,執行必要的重定位,並生成最終的輸出。
上述的整個構建過程可以由下圖表示:
在構建過程中,go build 命令還執行各種優化,例如未用代碼消除和內聯,以提高生成二進制文件的性能和降低二進制文件的大小。其中的未用代碼消除就是保證 Go 生成的二進制文件大小可控的重要機制。
未用檢測算法的實現位於 $GOROOT/src/cmd/link/internal/ld/deadcode.go 文件中。該算法通過圖遍歷的方式進行,具體過程如下:
-
從系統的入口點開始,標記所有可通過重定位到達的符號。重定位是兩個符號之間的依賴關係。
-
通過遍歷重定位關係,算法標記所有可以從入口點訪問到的符號。例如,在主函數 main.main 中調用了 pkga.Foo 函數,那麼 main.main 會有對這個函數的重定位信息。
-
標記完成後,算法會將所有未被標記的符號標記爲不可達的未用。這些未被標記的符號表示不會被入口點或其他可達符號訪問到的代碼。
不過,這裏有一個特殊的語法元素要注意,那就是帶有方法的類型。類型的方法是否進入到最終的可執行文件中,需要考慮不同情況。在 deadcode.go,用於標記可達符號的函數實現將可達類型的方法的調用方式分爲三種:
-
直接調用
-
通過可到達的接口類型調用
-
通過反射調用:reflect.Value.Method(或 MethodByName)或 reflect.Type.Method(或 MethodByName)
第一種情況,可以直接將調用的方法被標記爲可到達。第二種情況通過將所有可到達的接口類型分解爲方法簽名來處理。遇到的每個方法都與接口方法簽名進行比較,如果匹配,則將其標記爲可到達。這種方法非常保守,但簡單且正確。
第三種情況通過尋找編譯器標記爲 REFLECTMETHOD 的函數來處理。函數 F 上的 REFLECTMETHOD 意味着 F 使用反射進行方法查找,但編譯器無法在靜態分析階段確定方法名。因此所有調用 reflect.Value.Method 或 reflect.Type.Method 的函數都是 REFLECTMETHOD。調用 reflect.Value.MethodByName 或 reflect.Type.MethodByName 且參數爲非常量的函數也是 REFLECTMETHOD。如果我們找到了 REFLECTMETHOD,就會放棄靜態分析,並將所有可到達類型的導出方法標記爲可達。
下面是一個來自參考資料中的示例:
// 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()
}
在這個示例中,類型 * 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] 下載。
- 參考資料
-
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
參考資料
[1]
Go 爲 1.22.0 版本: https://tonybai.com/2024/02/18/some-changes-in-go-1-22/
[2]
內聯優化: https://tonybai.com/2022/10/17/understand-go-inlining-optimisations-by-example
[3]
這裏: https://github.com/bigwhite/experiments/tree/master/dead-code-elimination
[4]
Getting the most out of Dead Code elimination: https://golab.io/talks/getting-the-most-out-of-dead-code-elimination
[5]
all: binaries too big and growing: https://github.com/golang/go/issues/6853
[6]
aarzilli/whydeadcode: https://github.com/aarzilli/whydeadcode
[7]
Gopher 部落知識星球: https://public.zsxq.com/groups/51284458844544
[8]
鏈接地址: https://m.do.co/c/bff6eed92687
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Ph4Osn89GUCgdu9qoK-wzw