Plymorphism in Go using interface

Go struct 記憶體對齊如何提升記憶體運用效率

前言

在學習 Go 時會了解到 struct 其實就是一串自訂的資料結構,而其中欄位的「順序」將直接影響到在記憶體中所佔用的實際大小甚至程式的速度。

為什麼需要記憶體填充?

理論上 CPU 可以從任何一個地址讀取資料,但實際上為了提高硬體的存取效率,CPU 讀取記憶體時通常並不是以單一 Byte 為單位讀取的而是以字長 (Word Size) 為單位進行讀取,通常來說:

  • 在 32-bit 系統中,字長為 4 Bytes
  • 在 64-bit 系統中,字長為 8 Bytes

假設在 64-bit 系統上,一個 int64 (8 Bytes) 的變數剛好跨越了兩個字長,CPU 就必須執行兩次讀取操作,並將結果拼接起來才能得到完整的數值。這不僅造成多餘的運算,在某些硬體架構上甚至會直接拋出異常。

main.c
char data[10]; // char 只有 1 byte,所以它可以放任何位置
int *p = (int *)&data[1]; // 假設 &data[1] 是 0x1001(奇數位址)
int val = *p; // 在嚴格對齊的架構(如 ARM, SPARC)上會有機會直接崩潰觸發 SIGBUS
Alignment Fault (Signal 7 - SIGBUS)
0x1000 0x1001 0x1002 0x1003 0x1004
[---------- int ----------]

為了避免這種情況,編譯器會自動介入,確保資料的起始地址符合特定的規則,這就是「記憶體對齊」,而為了達成對齊,編譯器會在資料之間塞入空白的 Byte,這個動作稱為「記憶體填充」。

設計良好的記憶體佈局

不好的佈局
type BadStruct struct {
A int8 // 1 Byte
B int64 // 8 Bytes
C int16 // 2 Bytes
}
func main() {
var bad BadStruct
fmt.Printf("BadStruct Size: %d Bytes\n", unsafe.Sizeof(bad)) // 24 Bytes
}

在不好的範例中 int64 必須從 8 的倍數地址開始,因此自動填充了 7 Byte 的記憶體空間,而 1+7+8+2 = 18 不是 8 的倍數所以又會在結尾填充 6 Byte 最終大小為 24 Byte。

好的佈局
type GoodStruct struct {
B int64 // 8 Bytes
C int16 // 2 Bytes
A int8 // 1 Byte
}
func main() {
var good GoodStruct
fmt.Printf("GoodStruct Size: %d Bytes\n", unsafe.Sizeof(good)) // 16 Bytes
}

在好的範例中欄位總大小只有 11 Bytes,struct 本身仍需滿足最大對齊需求(此例為 int64 的 8 Bytes 對齊),因此結尾會再填充 5 Bytes,最終大小為 16 Bytes。

以上範例僅是調整順序就省下了 24 - 16 = 8 Bytes (將近 33%) 的記憶體空間,如果該 struct 重複被初始化,節省的記憶體空間也能非常可觀。

空結構體與填充

type StructWithEmpty struct {
A int32
B struct{} // 放在最後
}

如果空結構體被放在另一個結構體的最後一個欄位,它將會佔用記憶體,以避免產生超出邊界的指標,Go 編譯器會為作為最後一個欄位的空結構體進行填充。

檢測記憶體對齊

Terminal window
go vet -fieldalignment
fieldalignment -fix ./...

總結

了解記憶體對齊的概念除了「節省空間」外也能為打造「更佳的緩存局部性(Cache locality)」。

  1. 將相同或相近大小的欄位定義在一起,通常是按照欄位大小由大到小(或由小到大)排序。
  2. 不要將空結構體放在最後一個欄位
  3. 記憶體對齊固然重要,但只是開發中的其中一個面向,避免過度工程

延伸閱讀