通過禁止比較讓 Go 二進位制檔案變小

2020-05-22 10:17:00

大家常規的認知是,Go 程式中宣告的型別越多,生成的二進位制檔案就越大。這個符合直覺,畢竟如果你寫的程式碼不去操作定義的型別,那麼定義一堆型別就沒有意義了。然而,連結器的部分工作就是檢測沒有被程式參照的函數(比如說它們是一個庫的一部分,其中只有一個子集的功能被使用),然後把它們從最後的編譯產出中刪除。常言道,“型別越多,二進位制檔案越大”,對於多數 Go 程式還是正確的。

本文中我會深入講解在 Go 程式的上下文中“相等”的意義,以及為什麼像這樣的修改會對 Go 程式的大小有重大的影響。

定義兩個值相等

Go 的語法定義了“賦值”和“相等”的概念。賦值是把一個值賦給一個識別符號的行為。並不是所有宣告的識別符號都可以被賦值,如常數和函數就不可以。相等是通過檢查識別符號的內容是否相等來比較兩個識別符號的行為。

作為強型別語言,“相同”的概念從根源上被植入識別符號的型別中。兩個識別符號只有是相同型別的前提下,才有可能相同。除此之外,值的型別定義了如何比較該型別的兩個值。

例如,整型是用算數方法進行比較的。對於指標型別,是否相等是指它們指向的地址是否相同。對映和通道等參照型別,跟指標類似,如果它們指向相同的地址,那麼就認為它們是相同的。

上面都是按位元比較相等的例子,即值佔用的記憶體的位元型樣是相同的,那麼這些值就相等。這就是所謂的 memcmp,即記憶體比較,相等是通過比較兩個記憶體區域的內容來定義的。

記住這個思路,我過會兒再來談。

結構體相等

除了整型、浮點型和指標等標量型別,還有複合型別:結構體。所有的結構體以程式中的順序被排列在記憶體中。因此下面這個宣告:

type S struct {    a, b, c, d int64}

會佔用 32 位元組的記憶體空間;a 占用 8 個位元組,b 占用 8 個位元組,以此類推。Go 的規則說如果結構體所有的欄位都是可以比較的,那麼結構體的值就是可以比較的。因此如果兩個結構體所有的欄位都相等,那麼它們就相等。

a := S{1, 2, 3, 4}b := S{1, 2, 3, 4}fmt.Println(a == b) // 輸出 true

編譯器在底層使用 memcmp 來比較 a 的 32 個位元組和 b 的 32 個位元組。

填充和對齊

然而,在下面的場景下過分簡單化的按位元比較的策略會返回錯誤的結果:

type S struct {    a byte    b uint64    c int16    d uint32}func main()    a := S{1, 2, 3, 4}    b := S{1, 2, 3, 4}    fmt.Println(a == b) // 輸出 true}

編譯程式碼後,這個比較表示式的結果還是 true,但是編譯器在底層並不能僅依賴比較 ab 的位元型樣,因為結構體有填充

Go 要求結構體的所有欄位都對齊。2 位元組的值必須從偶數地址開始,4 位元組的值必須從 4 的倍數地址開始,以此類推 1。編譯器根據欄位的型別和底層平台加入了填充來確保欄位都對齊。在填充之後,編譯器實際上看到的是 2

type S struct {    a byte    _ [7]byte // 填充    b uint64    c int16    _ [2]int16 // 填充    d uint32}

填充的存在保證了欄位正確對齊,而填充確實佔用了記憶體空間,但是填充位元組的內容是未知的。你可能會認為在 Go 中 填充位元組都是 0,但實際上並不是 — 填充位元組的內容是未定義的。由於它們並不是被定義為某個確定的值,因此按位元比較會因為分布在 s 的 24 位元組中的 9 個填充位元組不一樣而返回錯誤結果。

Go 通過生成所謂的相等函數來解決這個問題。在這個例子中,s 的相等函數只比較函數中的欄位略過填充部分,這樣就能正確比較型別 s 的兩個值。

型別演算法

呵,這是個很大的設定,說明了為什麼,對於 Go 程式中定義的每種型別,編譯器都會生成幾個支援函數,編譯器內部把它們稱作型別的演算法。如果型別是一個對映的鍵,那麼除相等函數外,編譯器還會生成一個雜湊函數。為了維持穩定,雜湊函數在計算結果時也會像相等函數一樣考慮諸如填充等因素。

憑直覺判斷編譯器什麼時候生成這些函數實際上很難,有時並不明顯,(因為)這超出了你的預期,而且連結器也很難消除沒有被使用的函數,因為反射往往導致連結器在裁剪型別時變得更保守。

通過禁止比較來減小二進位制檔案的大小

現在,我們來解釋一下 Brad 的修改。向型別新增一個不可比較的欄位 3,結構體也隨之變成不可比較的,從而強制編譯器不再生成相等函數和雜湊函數,規避了連結器對那些型別的消除,在實際應用中減小了生成的二進位制檔案的大小。作為這項技術的一個例子,下面的程式:

package mainimport "fmt"func main() {    type t struct {        // _ [0][]byte // 取消註釋以阻止比較        a byte        b uint16        c int32        d uint64    }    var a t    fmt.Println(a)}

用 Go 1.14.2(darwin/amd64)編譯,大小從 2174088 降到了 2174056,節省了 32 位元組。單獨看節省的這 32 位元組似乎微不足道,但是考慮到你的程式中每個型別及其傳遞閉包都會生成相等和雜湊函數,還有它們的依賴,這些函數的大小隨型別大小和複雜度的不同而不同,禁止它們會大大減小最終的二進位制檔案的大小,效果比之前使用 -ldflags="-s -w" 還要好。

最後總結一下,如果你不想把型別定義為可比較的,可以在原始碼層級強制實現像這樣的奇技淫巧,會使生成的二進位制檔案變小。


附錄:在 Brad 的推動下,Cherry ZhangKeith Randall 已經在 Go 1.15 做了大量的改進,修復了最嚴重的故障,消除了無用的相等和雜湊函數(雖然我猜想這也是為了避免這類 CL 的擴散)。

相關文章:

  1. Go 執行時如何高效地實現對映(不使用泛型)
  2. 空結構體
  3. 填充很難
  4. Go 中有型別的 nil(2)

  1. 在 32 位平台上 int64unit64 的值可能不是按 8 位元組對齊的,因為平台原生的是以 4 位元組對齊的。檢視 議題 599 了解內部詳細資訊。 ?

  2. 32 位平台會在 ab 的宣告中填充 _ [3]byte。參見前一條。 ?

  3. Brad 使用的是[0]func(),但是所有能限制和禁止比較的型別都可以。新增了一個有 0 個元素的陣列的宣告後,結構體的大小和對齊不會受影響。 ?