從 CPU 角度理解 Go 中的結構體記憶體對齊

yudotyang發表於2022-01-20

大家好,我是 Go 學堂的漁夫子。今天跟大家聊聊結構體欄位記憶體對齊相關的知識點。

原文連結:https://mp.weixin.qq.com/s/H3399AYE1MjaDRSllhaPrw

大家在寫 Go 時有沒有注意過,一個 struct 所佔的空間不見得等於各個欄位加起來的空間之和,甚至有時候把欄位的順序調整一下,struct 的所佔空間又有不同的結果。

本文就從 cpu 讀取記憶體的角度來談談記憶體對齊的原理。

01 結構體欄位對齊示例

我們先從一個示例開始。T1 結構體,共有 3 個欄位,型別分別為 int8,int64,int32。所以變數 t1 所屬的型別佔用的空間應該是 1+8+4=13 位元組。但執行程式後,實際上是 24 位元組。和我們計算的 13 位元組不一樣啊。如果我們把該結構體的欄位調整成 T2 那樣,結果是 16 位元組。但和 13 位元組還是不一樣。這是為什麼呢?

type T1 struct {
    f1 int8  // 1 byte
    f2 int64 // 8 bytes
    f3 int32 // 4 bytes
}

type T2 struct {
    f1 int8  // 1 byte
    f3 int32 // 4 bytes
    f2 int64 // 8 bytes
}

func main() {
    fmt.Println(runtime.GOARCH) // amd64

    t1 := T1{}
    fmt.Println(unsafe.Sizeof(t1)) // 24 bytes

    t2 := T2{}
    fmt.Println(unsafe.Sizeof(t2)) // 16 bytes
}

02 CPU 按字的方式從記憶體讀取資料

在買電腦時或看自己電腦的屬性時,都會發現 CPU 的規格是 64 位或 32 位的,近些年的都是 64 位的。而這 64 位指的就是 CPU 一次可以從記憶體中讀取 64 位的資料,即 8 個位元組。這個長度也稱為 CPU 的字長(注意這裡和位元組的區別,位元組是固定的 8 位,而字長隨著 CPU 的規格變化,32 位的字長是 4 位元組,64 位的字長是 8 位元組)。

雖然 CPU 一次可以抓取 8 位元組,但也是想從哪裡抓就從哪裡抓取的。因為記憶體也會以 8 位元組為單位分成一個一個的字(如下圖),而 CPU 一次只能拿某一個字。所以,如果所需要讀取的資料正好跨了兩個字,那就得花兩個 CPU 週期的時間去讀取了。

03 struct 欄位記憶體對齊

瞭解了 CPU 從記憶體讀取資料是按塊讀取的之後,我們再來看看開頭的 T1 結構體各欄位在記憶體中如果緊密排列的話會是怎麼樣的。在 T1 結構體中各欄位的順序是按 int8、int64、int32 定義的,所以把各欄位在記憶體中的佈局應該形如下面這樣:因為第 2 個欄位需要 8 位元組,所以會有一個位元組的資料排列到第 2 個字中。

那這樣排列會有什麼問題呢?如果我們的程式想要讀取 t1.f2 欄位的資料,那 CPU 就得花兩個時鐘週期把 f2 欄位從記憶體中讀取出來,因為 f2 欄位分散在兩個字中。

所以,為了能讓 CPU 可以更快的存取到各個欄位,Go 編譯器會幫你把 struct 結構體做資料的對齊。所謂的資料對齊,是指記憶體地址是所儲存資料大小(按位元組為單位)的整數倍,以便 CPU 可以一次將該資料從記憶體中讀取出來。 編譯器通過在 T1 結構體的各個欄位之間填充一些空白已達到對齊的目的。

重新排列後,記憶體的佈局會長如下這樣,有 13 個位元組的空間是真正儲存資料的,而深色的 11 個位元組的空間則是為了對齊而填充上的,不儲存任何資料,以確保每個欄位的資料都會落到同一個字長裡面,所以才會有了開頭的 13 個位元組的資料型別實際上變成了 24 位元組。

04 如何減少 struct 的填充

雖然通過填充的方式可以提高 CPU 讀寫資料的效率,但這些填充的記憶體實際上是不存數任何資料的,也就相當於浪費掉了。以 T1 結構體為例,實際儲存資料的只有 13 位元組,但實際用了 24 位元組,浪費了將近一半,那有沒有什麼辦法既可以做到記憶體對齊提高 CPU 讀取效率又能減少記憶體浪費的嗎?

答案就是調整 struct 欄位的順序。我們再觀察下 T1 結構體的欄位分佈,就會發現下面的 f3 欄位的 4 個位元組可以挪到 f1 欄位所在的那一排的填充位置,畢竟第一排的填充空間超大,不用也是浪費,而且挪上去之後,每個欄位還是都在同一個字裡面。

一旦把 f3 移上去,就可以省掉最下面一整個 word(8 bytes) 的空間,所以 T2 整個 struct 就只需要 16 bytes,是原本 T1 24 bytes 的三分之二。

在 Go 程式中,Go 會按照結構體中欄位的順序在記憶體中進行佈局,所以需要將欄位 f2 和 f3 的位置交換,定義的順序變成 int8、int32、int64,這樣 Go 編譯器才會順利的按上圖那樣排列。

05 在同一個字(8 位元組)中的記憶體分佈

上面都是看到的跨字長(64 位系統下是 8 位元組)的儲存示例來說明 CPU 需要從記憶體讀取兩次才能將一個完整的資料完整的讀取出來。那如果是有 n 個小於一個字長的型別在同一個字長中是否可以連續分配呢?我們通過示例來講解一下:

var x struct {
    a bool
    b int16
}

我們計算一下各個欄位佔用的記憶體空間:1 位元組(a)+2 位元組(b)= 3 位元組。沒超過 1 個字長(8 位元組),但在記憶體中的分佈是如下圖這樣:

我們發現 b 並沒有直接在 a 的後面,而是在 a 中填充了一個空白後,放到了偏移量為 2 的位置上。為什麼呢?

答案還是從記憶體對齊的定義中推匯出來。我們上面說過,記憶體對齊是指資料存放的地址是資料大小的整數倍。也就是說會有資料存放的起始地址% 資料的大小=0

我們來驗證下上面的結構體的排列。假設結構體的起始地址為 0,那麼 a 從 0 開始佔用 1 個位元組。b 欄位如果放在地址 1 處,套用上面的公式 1 % 2 = 1,就不滿足對齊的要求。所以在地址為 2 處開始存放 b 欄位。 這也就解釋了很多文章中列出的原則:構體變數中成員的偏移量必須是成員大小的整數倍

06 什麼時候該關注結構體欄位順序

由此可知,對結構體欄位的重新排列會讓結構體更節省內。但我們需要這麼做嗎?以 Student 為例,我們看下 Student 的定義:

type Student struct {
    id int8 //學號
    name string //姓名
    classID int8 //班級
    phone [10]byte //聯絡電話
    address string // 地址
    grade int32 //成績
}

我們看下該結構體在記憶體中的分佈如下,可以看到有很多深色的填充空間,總計浪費了 16 位元組,好像還可以優化。

我們通過調整 Student 結構體的欄位順序來進行下優化,可以看到從開始的 64 位元組,可以優化到 48 位元組,共剩下了 25% 的空間。

type Student struct {
    name string //姓名
    address string // 地址
    grade int32 //成績
    phone [10]byte //聯絡電話
    id int8 //學號
    classID int8 //班級  
}

調整後的結構體在記憶體中的分佈如下:

我們看到,通過調整結構體中的欄位順序確實節省了記憶體空間,那我們真的有必要這樣節省空間嗎?

以 student 結構體為例,經過重新排列後,節省了 16 位元組的空間,假設我們在程式中需要排列全校同學的成績,需要定義一個長度為 10 萬的 Student 型別的陣列,那剩下的記憶體也不過 16MB 的空間,跟現在個人電腦的 8G 到 16G 的記憶體比起來微不足道。而且在欄位重新排列後,可讀性也變的很差了。像 Student 原本是以學號,姓名,班級...這樣依次排列的,而重新調整後變成了姓名,地址,成績...,一直到最後才是學號跟班級,不符合人們的思維習慣。

所以,我的建議是對於結構體的欄位排列不需要過早的進行優化,除非一開始就知道你的程式瓶頸就卡在這裡。否則,就按照正常的習慣編寫 Go 程式即可。

07 總結

本文從 CPU 讀取記憶體的角度分析了為什麼需要進行資料對齊。該文目的是為了讓你更好的瞭解底層的執行機制,而非時刻關注結構體的欄位順序。在編寫程式碼時順其自然就好。到了這裡成為瓶頸的時候再記著調整下欄位順序就好。

更多原創文章乾貨分享,請關注公眾號
  • 從 CPU 角度理解 Go 中的結構體記憶體對齊
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章