Go 程序太大了,能要個延遲初始化不?
大家好,我是煎魚。
在公司的不斷發展中,一開始大多是大單體,改造慢了,一個倉庫會有使用十幾年的情況,倉庫的規模基本是不斷增大的過程。
影響之一就是會應用程序打包后的體積越來越大,不知道被用哪里去了...今天要探討的提案《proposal: language: lazy init imports to possibly import without side effects[1]》,就與此有關。
提案
背景
我們來觀察一段很簡單的 Go 代碼,研究研究。如下代碼:
package main
import _ "crypto/x509"
func main() {}
這個 Go 程序只有 3 行代碼,看起來就沒有任何東西。實際上是這樣嗎?
我們可以執行以下命令看看初始化過程:
$ go build --ldflags=--dumpdep main.go 2>&1 | grep inittask
輸出結果:
runtime.main -> runtime..inittask
runtime.main -> main..inittask
main..inittask -> crypto/x509..inittask
crypto/x509..inittask -> bytes..inittask
crypto/x509..inittask -> crypto/sha256..inittask
crypto/x509..inittask -> encoding/pem..inittask
crypto/x509..inittask -> errors..inittask
crypto/x509..inittask -> sync..inittask
crypto/x509..inittask -> crypto/aes..inittask
crypto/x509..inittask -> crypto/cipher..inittask
crypto/x509..inittask -> crypto/des..inittask
...
context..inittask -> context.init.0
vendor/golang.org/x/net/dns/dnsmessage..inittask -> vendor/golang.org/x/net/dns/dnsmessage.init
vendor/golang.org/x/net/route..inittask -> vendor/golang.org/x/net/route.init
vendor/golang.org/x/net/route..inittask -> vendor/golang.org/x/net/route.init.0
...
這段程序其實初始化了超級多的軟件包(標準庫、第三方包等)。使得包的的大小從標準的 1.3 MB 變成了 2.3 MB。
在一定規模下,大家認為該影響是非常昂貴的。因為你可以看到只有 3 行的 Go 程序并沒有做任何實質性的事情。
對啟動性能敏感的程序會比較難受,普通程序也會隨著日積月累進入惡性循環,啟動會比常規的更慢。
方案
在解決方案上我們結合另外一個提案《proposal: spec: Go 2: allow manual control over imported package initialization[2]》一起來看。
核心思想是:引入惰性初始化(lazy init),業內也常稱為延遲加載。也就是必要的時候再真正的導入,不在引入包時就完成初始化。
優化方向上:主要是在導入包路徑后增加懶惰初始化的聲明,例如在下方即將會提到的:go:lazyinit 或 go:deferred 注解。再等待程序真正使用到時再正式初始化。
1.go:lazyinit 的例子:
package main
import (
"crypto/x509" // go:lazyinit
"fmt"
)
func main() {...}
2.go:deferred 的例子:
package main
import (
_ "github.com/eddycjy/core" // go:deferred
_ "github.com/eddycjy/util" // go:deferred
)
func main() {
if os.Args[1] != "util" {
// 現在要使用這個包,開始初始化
core, err := runtime.InitDeferredImport("github.com/some/module/core")
...
}
...
}
以此來實現,可以大大提高啟動性能。
討論
實際上在大多數的社區討論中,對這個提案是又愛又恨。因為它似乎又有合理的訴求,但細思似乎又會發現完全不對勁。
這個提案的背景和解決方案,是治標不治本的。因為根本原因是:許多庫濫用了 init 函數,讓許多不必要的東西都初始化了。
Go 核心開發團隊認為讓庫作者去修復這些庫,而不是讓 Go 來 “解決” 這些問題。如果支持惰性初始化,也會為這些低質量庫的作者提供繼續這樣做的借口。
似曾相識的感覺
在寫這篇文章時,我想起了 Go 的依賴管理(Go modules),其有一個設計是基于語義化版本的規范。
如下圖:
版本格式為 “主版本號.次版本號.修訂號”,版本號的遞增規則如下:
- 主版本號:當你做了不兼容的 API 修改。
- 次版本號:當你做了向下兼容的功能性新增。
- 修訂號:當你做了向下兼容的問題修正。
Go modules 的原意是軟件庫都遵守這個規范,因此內部會有最小版本選擇的邏輯。
也就是一個模塊往往依賴著許多其它許許多多的模塊,并且不同的模塊在依賴時很有可能會出現依賴同一個模塊的不同版本,Go 會把版本清單都整理出來,最終得到一個構建清單。
如下圖:
你會發現最終構建出來的依賴版本很有可能是與預期的不一致,從而導致許多業務問題。最經典的就是 grpc-go、protoc-go、etcd 多版本兼容問題,讓許多人痛苦不已。
Go 團隊在這一塊的設計是比較理想化的,曹大也將其歸類在 Go modules 的七宗罪之一了。而軟件包的 init 函數亂初始化一堆的問題,也是有些似曾相識了。
總結
這個問題的解決方案(提案)仍然在討論中,顯然 Go 團隊更希望軟件庫的作者能夠約束好自己的代碼,不要亂初始化。
參考資料
[1]proposal: language: lazy init imports to possibly import without side effects: https://github.com/golang/go/issues/38450
[2]proposal: spec: Go 2: allow manual control over imported package initialization: https://github.com/golang/go/issues/48174