Go高效能程式設計-瞭解記憶體對齊以及Go中的型別如何對齊保證

lin鍾一發表於2022-11-09

本文將介紹Go中的各種欄位型別的位元組數和對齊保證。
詳細可見個人部落格:linzyblog.netlify.app/

1、什麼是記憶體對齊?

元素是按照定義順序一個一個放到記憶體中去的,但並不是緊密排列的。從結構體儲存的首地址開始,每個元素放置到記憶體中時,它都會認為記憶體是按照自己的大小(通常它為4位元組(32位)或8位元組(64位))來劃分的,因此元素放置的位置一定會在自己寬度的整數倍上開始,這就是所謂的記憶體對齊。

2、為什麼需要記憶體對齊?

CPU 訪問記憶體時,並不是逐個位元組訪問,而是以字長(word size)為單位訪問。比如 32 位的 CPU ,字長為 4 位元組,那麼 CPU 訪問記憶體的單位也是 4 位元組。64位 CPU 訪問記憶體的單位是8個位元組。

這麼設計的目的,是減少 CPU 訪問記憶體的次數,加大 CPU 訪問記憶體的吞吐量。比如同樣讀取 8 個位元組的資料,一次讀取 4 個位元組那麼只需要讀取 2 次。CPU 始終以字長訪問記憶體

提出設想如果我們不進行記憶體對齊,而是按照型別位元組長度緊密對齊呢?

變數 a、b 各佔據 3 位元組的空間,我們使用32位 CPU 訪問記憶體讀取變數,例如:

在這裡插入圖片描述

  • 記憶體對齊後,a、b 佔據 4 位元組空間,CPU 讀取 b 變數的值只需要進行一次記憶體訪問。
  • 不進行記憶體對齊,CPU 讀取 b 變數的值需要進行 2 次記憶體訪問。第一次訪問得到 b 變數的第 1 個位元組,第二次訪問得到 b 變數的後兩個位元組,最後在暫存器中將剩餘的資料合併得到我們想要變數 b 的資料。

從這個例子中也可以看到,記憶體對齊對實現變數的原子性操作也是有好處的,每次記憶體訪問是原子的,如果變數的大小不超過字長,那麼記憶體對齊後,對該變數的訪問就是原子的,這個特性在併發場景下至關重要。

簡言之:合理的記憶體對齊可以提高記憶體讀寫的效能,並且便於實現變數操作的原子性。

記憶體對齊是為了減少訪問記憶體的次數,提高CPU讀取記憶體資料的效率,如果記憶體不對齊,訪問相同的資料需要更多的訪問記憶體次數。

為了充分利用CPU指令來達到最佳程式效能,為一個特定型別的值開闢的記憶體塊的起始地址必須為某個整數N的倍數,N被稱為此型別的值地址對齊保證,或者簡單地稱為此型別的對齊保證。 我們也可以說此型別的值的地址保證為N位元組對齊的。

1、資料型別的大小和對齊保證

對於當前的標準 Go 編譯器(版本 1.19),欄位對齊保證和型別的一般對齊保證是相等的。

對於不同的型別,對應的對齊保證大小和佔用的記憶體大小:

型別 對齊保證(位元組數) 佔用的記憶體大小(位元組數)
byte, uint8, int8 1 1
uint16, int16 2 2
uint32, int32, float32 4 4
uint64, int64, float64, complex64 8 8
complex128 16 16
string 8 16
array 取決於元素型別
struct 取決於各個欄位型別
uint, int 取決於編譯器實現。通常在32位架構上為4位元組,在64位架構上為8位元組。
uintptr 取決於編譯器實現。但必須能夠存下任一個記憶體地址。

Go白皮書僅列出了一些型別對齊保證要求,一個合格的Go編譯器必須保證:

  1. 對於任何型別的變數x,unsafe.Alignof(x)的結果最小為1。
  2. 對於一個結構體型別的變數x,unsafe.Alignof(x)的結果為x的所有欄位的對齊保證unsafe.Alignof(x.f)中的最大值(但是最小為1)。
  3. 對於一個陣列型別的變數x,unsafe.Alignof(x)的結果和此陣列的元素型別的一個變數的對齊保證相等。

如果結構或陣列型別不包含記憶體大小大於零的欄位(或元素),則其大小為零。兩個不同的零大小變數在記憶體中可能具有相同的地址。

2、Go實現記憶體對齊

Go的unsafe包中有三個函式:

func Sizeof(x ArbitraryType) uintptr
func Offsetof(x ArbitraryType) uintptr
func Alignof(x ArbitraryType) uintptr

unsafe.Sizeof 返回變數x的佔用位元組數,但不包含它所指向內容的大小,對於一個string型別的變數它的大小是16位元組,一個指標型別的變數大小是8位元組
unsafe.Alignof 返回變數x需要的對齊保證,它可以被x地址整除。(一般取結構體資料型別對齊保證的最大值)
unsafe.Offsetof 返回結構體成員地址相對於結構體首地址相差的位元組數,稱為偏移量

在 Go 語言中,我們可以使用 unsafe.Sizeof 計算出一個資料型別例項需要佔用的位元組數。

type T1 struct {
    a int8  //1位元組
    b int64 //8位元組
    c int16 //2位元組
}

type T2 struct {
    a int8
    c int16
    b int64
}

type T3 struct {
    a int8  //1位元組
    b int32 //4位元組
    c int16 //2位元組
}

func main() {
    fmt.Println("T1結構體記憶體佔用:", unsafe.Sizeof(T1{}))
    fmt.Println("T2結構體記憶體佔用:", unsafe.Sizeof(T2{}))

    var s string
    fmt.Printf("string型別的記憶體佔用大小%v, 對齊保證%v\n", unsafe.Sizeof(s), unsafe.Alignof(s))
    fmt.Println("T1結構體對齊保證:", unsafe.Alignof(T2{}))
    fmt.Println("T2結構體對齊保證:", unsafe.Alignof(T3{}))

    var t1 T1
    var t2 T2
    fmt.Println("T1成員b地址相對於結構體首地址相差的位元組數:", unsafe.Offsetof(t1.b))
    fmt.Println("T2成員c地址相對於結構體首地址相差的位元組數:", unsafe.Offsetof(t2.c))
}

在這裡插入圖片描述

1)記憶體對齊規則

記憶體對齊的規則,這裡只介紹結構體的記憶體對齊:

  • 規則一:結構體第一個欄位偏移量為0,後面的欄位的偏移量等於成員資料型別大小和欄位對齊保證兩者取最小值的最小整數倍,如果不滿足規則,編譯器會在前面填充值為0的位元組空間
  • 規則二:結構體本身也需要記憶體對齊,其大小等於各欄位型別佔用記憶體最大的和編譯器預設對齊保證兩者取最小值的最小整數倍

2)計算結構體記憶體佔用大小

為什麼這裡明明結構體內欄位型別和數量都是一樣,但是記憶體大小卻不一樣?

a、T1為什麼是24位元組?

  • a是int8型別佔1位元組,對齊保證是1位元組。
    • 因為是第一個成員,偏移量為0,所有不需要填充,直接排在記憶體空間的第一位。
  • b是int64型別佔8位元組,對齊保證是8位元組。
    • 當前偏移量為2,根據規則一,其偏移量為兩者中最小值,所以調整後的偏移量為8。
    • 在64位架構上,為了讓欄位b的地址為8位元組對齊,需在這裡填充7個位元組,從第9位開始佔用8個位元組空間。
    • 在32位架構上,為了讓欄位b的地址為4位元組對齊,需在這裡填充3個位元組,從第5位開始佔用8個位元組空間。
  • c是int16型別佔2位元組,對齊保證2位元組。
    • 當前偏移量為16,根據規則一,其偏移量為兩者中最小值,所以調整後的偏移量為2。
    • 在64位架構上需在這裡填充6個位元組,從第17位開始佔用2個位元組空間。
    • 在32位架構上需在這裡填充2個位元組,從第13位開始佔用2個位元組空間。

在這裡插入圖片描述

第一條規則算下來結構體T1在64位架構上佔用大小為 1+7+8+2=18,在32位架構上佔用大小為 1+3+8+2=14

我們再來根據第二條規則計算:
結構體最大欄位記憶體大小為8位元組,

  • 結構體T1的記憶體大小在64位架構上,取兩者最小值8的最小整數倍,因本身結構體當前大小為18,所以最後結構體大小=3*8=24,為24個位元組。
  • 結構體T1的記憶體大小在32位架構上,取兩者最小值4的最小整數倍,因本身結構體當前大小為14,所以最後結構體大小=4*4=16,為16個位元組。

b、T2為什麼是16位元組?

  • a是int8型別佔1位元組,對齊保證是1位元組。
    • 因為是第一個成員,偏移量為0,所有不需要填充,直接排在記憶體空間的第一位。
  • c是int16型別佔2個位元組,對齊保證2位元組。
    • 當前偏移量為2,根據規則一,其偏移量為兩者中最小值,所以調整後的偏移量為2。
    • 為了讓欄位c的地址為2位元組對齊,需在這裡填充1個位元組,從第3位開始佔用2個位元組空間。
  • b是int64型別佔8個位元組,對齊保證是8位元組。
    • 當前偏移量為4,根據規則一,其偏移量為兩者中最小值,所以調整後的偏移量為8。
    • 在64位架構上,為了讓欄位b的地址為8位元組對齊,需在這裡填充4個位元組,從第9位開始佔用8個位元組空間。
    • 在32位架構上,為了讓欄位b的地址為4位元組對齊,不需要填充,從第5位開始佔用8個位元組空間。
    • 位元組可以保證欄位b的地址為4位元組對齊的。

在這裡插入圖片描述

第一條規則算下來
結構體T1在64位架構上佔用大小為 1+1+2+4+8=16
結構體T1在32位架構上佔用大小為 1+1+2+8=12

我們再來根據第二條規則計算:
結構體最大欄位記憶體大小為8位元組,

  • 結構體T1的記憶體大小在64位架構上,取兩者最小值8的最小整數倍,因本身結構體當前大小為16,所以最後結構體大小=2*8=16,為16個位元組。
  • 結構體T1的記憶體大小在32位架構上,取兩者最小值4的最小整數倍,因本身結構體當前大小為12,所以最後結構體大小=3*4=12,為12個位元組。

3)struct 記憶體對齊的技巧

我們透過上面兩個結構體,因為欄位資料型別順序不一樣,導致記憶體佔用也不同。

每個欄位按照自身的對齊倍數來確定在記憶體中的偏移量,欄位排列順序不同,上一個欄位因偏移而浪費的大小也不同。

因此,在對記憶體特別敏感的結構體的設計上,我們可以透過調整欄位的順序,減少記憶體的佔用。

4)空 struct{} 的作用

空 struct{} 大小為 0,作為其他 struct 的欄位時,一般不需要記憶體對齊。但是有一種情況除外:即當 struct{} 作為結構體最後一個欄位時,需要記憶體對齊

因為如果有指標指向該欄位, 返回的地址將在結構體之外,如果此指標一直存活不釋放對應的記憶體,就會有記憶體洩露的問題(該記憶體不因結構體釋放而釋放)。

因此,當 struct{} 作為其他 struct 最後一個欄位時,需要填充額外的記憶體保證安全。我們做個試驗,驗證下這種情況。

type demo1 struct {
    c int32
    a struct{}
}

type demo2 struct {
    a struct{}
    c int32
}

func main() {
    fmt.Println(unsafe.Sizeof(demo1{})) // 8
    fmt.Println(unsafe.Sizeof(demo2{})) // 4
}

在這裡插入圖片描述

可以看到,demo2{} 的大小為 4 位元組,與欄位 c 佔據空間一致,而 demo1{} 的大小為 8 位元組,即額外填充了 4 位元組的空間。

3、64位字原子操作的地址對齊保證要求

一個64位字的原子操作要求此64位字的地址必須是8位元組對齊的。 這對於標準編譯器目前支援的64位架構來說並不是一個問題,因為標準編譯器保證任何一個64位字的地址在64位架構上都是8位元組對齊的。

然而,在32位架構上,標準編譯器為64位字做出的地址對齊保證僅為4個位元組。 對一個不是8位元組對齊的64位字進行64位原子操作將在執行時刻產生一個恐慌,需要分別讀取兩次合併。 更糟的是,一些非常老舊的架構並不支援64位原子操作需要的基本指令。

sync/atomic標準庫包文件的末尾提到:

On 386, the 64-bit functions use instructions unavailable before the Pentium MMX.

On non-Linux ARM, the 64-bit functions use instructions unavailable before the ARMv6k core.

On ARM, 386, and 32-bit MIPS, it is the caller's responsibility to arrange for 64-bit alignment of 64-bit words 
accessed atomically via the primitive atomic functions (types Int64 and Uint64 are automatically aligned). The 
first word in an allocated struct, array, or slice; in a global variable; or in a local variable
(because the subject of all atomic operations will escape to the heap) can be relied upon to be 64-bit aligned.

所以大致意思意思:

  1. 這些非常老舊的架構在今日已經相當的不主流了。 如果一個程式需要在這些架構上對64位字進行原子操作,還有很多其它同步技術可用。
  2. 對其它不是很老舊的32位架構,有一些方法可以保證在這些架構上對一些64位字的原子操作是安全的。

這裡的方法是已分配結構、陣列或切片中的第一個(64位)字(元素)可以被認為是8位元組對齊的

這裡的已分配解讀為一個宣告的變數、內建函式make的呼叫返回值,或者內建函式new的呼叫返回值所引用的值。如果一個切片是從一個已分配陣列派生出來的並且此切片和此陣列共享第一個元素,則我們也可以將此切片看作是一個已分配的值。

這裡對哪些64位字可以在32位架構上被安全地原子訪問的描述是有些保守的。 有很多描述並未包括的64位字在32位架構上也是可以被安全地原子訪問的。

比如,如果一個元素型別為64位字的陣列或者切片的第一個元素可以被安全地進行64位原子訪問,則此陣列或切片中的所有元素都可以被安全地進行64位原子訪問。 只是因為很難用三言兩語將所有在32位架構上可以被安全地原子訪問的64位字都羅列出來,所以官方文件採取了一種保守的描述。

下面是一個展示了哪些64位字在32位架構上可以和哪些不可以被安全地原子訪問的例子。

type (
    T1 struct {
        v uint64
    }

    T2 struct {
        _ int16
        x T1
        y *T1
    }

    T3 struct {
        _ int16
        x [6]int64
        y *[6]int64
    }
)

var a int64    // a可以安全地被原子訪問
var b T1       // b.v可以安全地被原子訪問
var c [6]int64 // c[0]可以安全地被原子訪問

var d T2 // d.x.v不能被安全地被原子訪問
var e T3 // e.x[0]不能被安全地被原子訪問

func f() {
    var f int64           // f可以安全地被原子訪問
    var g = []int64{5: 0} // g[0]可以安全地被原子訪問

    var h = e.x[:] // h[0]可以安全地被原子訪問

    // 這裡,d.y.v和e.y[0]都可以安全地被原子訪問,
    // 因為*d.y和*e.y都是開闢出來的。
    d.y = new(T1)
    e.y = &[6]int64{}

    _, _, _ = f, g, h
}

// 事實上,c、g和e.y.v的所有以元素都可以被安全地原子訪問。
// 只不過官方文件沒有明確地做出保證。

如果一個結構體型別的某個64位字的欄位(通常為第一個欄位)在程式碼中需要被原子訪問,為了保證此欄位值在各種架構上都可以被原子訪問,我們應該總是使用此結構體的開闢值。 當此結構體型別被用做另一個結構體型別的一個欄位的型別時,此欄位應該(儘量)被安排為另一個結構體型別的第一個欄位,並且總是使用另一個結構體型別的開闢值。

如果一個結構體含有需要一個被原子訪問的欄位,並且我們希望此結構體可以自由地用做其它結構體的任何欄位(可能非第一個欄位)的型別,則我們可以用一個[15]byte值來模擬此64位值,並在執行時刻動態地決定此64位值的地址。 比如:

type Counter struct {
    x [15]byte // 模擬:x uint64
}

func (c *Counter) xAddr() *uint64 {
    // 此返回結果總是8位元組對齊的。
    return (*uint64)(unsafe.Pointer(
        (uintptr(unsafe.Pointer(&c.x)) + 7)/8*8))
}

func (c *Counter) Add(delta uint64) {
    p := c.xAddr()
    atomic.AddUint64(p, delta)
}

func (c *Counter) Value() uint64 {
    return atomic.LoadUint64(c.xAddr())
}

透過採用此方法,Counter型別可以自由地用做其它結構體的任何欄位的型別,而無需擔心此型別中維護的64位欄位值可能不是8位元組對齊的。 此方法的缺點是,對於每個Counter型別的值,都有7個位元組浪費了。而且此方法使用了非型別安全指標。

Go 1.19引入了一種更為優雅的方法來保證一些值的地址對齊保證為8位元組。 Go 1.19在sync/atomic標準庫包中加入了幾個原子型別。 這些型別包括atomic.Int64和atomic.Uint64。 這兩個型別的值在記憶體中總是8位元組對齊的,即使在32位架構上也是如此。 我們可以利用這個事實來確保一些64位字在32位架構上總是8位元組對齊的。 比如,無論在32位架構還是64位架構上,下面的程式碼所示的T型別的x欄位在任何情形下總是8位元組對齊的。

type T struct {
    _ [0]atomic.Int64
    x int64
}

這裡主要是驗證記憶體對齊規則對實際儲存空間的影響,並驗證了記憶體對齊的規則.平時工作中不需要關心值地址的對齊保證,編譯器已經自動完成了相關的工作.除非打算最佳化下記憶體消耗.特別是定義結構體時,可以參照下上面的結論.

參考文章:點選跳轉

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章