Dig101:Go 之聊聊 struct 的記憶體對齊

newbmiao發表於2020-02-14

檢視更多:歷史集錄


Dig101: dig more, simplified more and know more

經過前邊幾篇文章,相信你也發現了,struct 幾乎無處不在。

string,slice 和 map 底層都用到了 struct。

今天我們來重點關注下 struct 的記憶體對齊,

​理解它,對更好的運用 struct 和讀懂一些原始碼庫的實現會有很大的幫助。

文章目錄

  • 0x01 為什麼要對齊
  • 0x02 資料結構對齊
    • 大小保證(size guarantee)
    • 對齊保證(align guarantee)
  • 0x03 零大小欄位對齊
  • 0x04 記憶體地址對齊
  • 0x05 64 位字安全訪問保證
    • 為什麼要保證
    • 怎麼保證
    • 改為加鎖

在此之前,我們先明確幾個術語,便於後續分析。

  • 字(word)

是用於表示其自然的資料單位,也叫machine word。字是電腦用來一次性處理事務的一個固定長度。

  • 字長

一個字的位數(即字長)。

現代電腦的字長通常為 16、32、64 位。(一般 N 位系統的字長是 N/8 位元組。)

電腦中大多數暫存器的大小是一個字長。CPU 和記憶體之間的資料傳送單位也通常是一個字長。還有而記憶體中用於指明一個儲存位置的地址也經常是以字長為單位。

參見維基百科中

0x01 為什麼要對齊

簡單來說,作業系統的 cpu 不是一個位元組一個位元組訪問記憶體的,是按 2,4,8 這樣的字長來訪問的。

所以當處理器從儲存器子系統讀取資料至暫存器,或者,寫暫存器資料到儲存器,傳送的資料長度通常是字長。

如 32 位系統訪問粒度是 4 位元組(bytes),64 位系統的是 8 位元組。

當被訪問的資料長度為 n 位元組且該資料地址為n位元組對齊,那麼作業系統就可以一次定位到資料,這樣會更加高效。無需多次讀取、處理對齊運算等額外操作。

0x02 資料結構對齊

我們先看下基礎資料結構的大小定義

大小保證(size guarantee)

如 Go 官方的文件size and alignment guarantees所示:

type size in bytes
byte, uint8, int8 1
uint16, int16 2
uint32, int32, float32 4
uint64, int64, float64, complex64 8
complex128 16

A struct or array type has size zero if it contains no fields (or elements, respectively) that have a size greater than zero. Two distinct zero-size variables may have the same address in memory.

struct{}[0]T{} 的大小為 0; 不同的大小為 0 的變數可能指向同一塊地址。

對齊保證(align guarantee)

  • For a variable x of any type: unsafe.Alignof(x) is at least 1.
  • For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.
  • For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array's element type.

對這段描述翻譯到對應型別的對齊就是下表

參考go101-memory layout

type alignment guarantee
bool, byte, uint8, int8 1
uint16, int16 2
uint32, int32 4
float32, complex64 4
arrays 由其元素(element)型別決定
structs 由其欄位(field)型別決定
other types 一個機器字(machine word)的大小

這裡機器字(machine word)對應的大小, 在 32 位系統上是 4bytes,64 位系統上是 8bytes

下面程式碼驗證下:

type T1 struct {
    a [2]int8
    b int64
    c int16
}
type T2 struct {
    a [2]int8
    c int16
    b int64
}
fmt.Printf("arrange fields to reduce size:\n"+
    "T1 align: %d, size: %d\n"+
    "T2 align: %d, size: %d\n",
    unsafe.Alignof(T1{}), unsafe.Sizeof(T1{}),
    unsafe.Alignof(T2{}), unsafe.Sizeof(T2{}))
/*
output:
arrange fields to reduce size:
T1 align: 8, size: 24
T2 align: 8, size: 16
*/

以 64 位系統為例,分析如下:

T1,T2內欄位最大的都是int64, 大小為 8bytes,對齊按機器字確定,64 位下是 8bytes,所以將按 8bytes 對齊

T1.a 大小 2bytes,填充 6bytes 使對齊(後邊欄位已對齊,所以直接填充)

T1.b 大小 8bytes,已對齊

T1.c 大小 2bytes,填充 6bytes 使對齊(後邊無欄位,所以直接填充)

總大小為 8+8+8=24

T2中將c提前後,ac總大小 4bytes,在填充 4bytes 使對齊

總大小為 8+8=16

所以,合理重排欄位可以減少填充,使 struct 欄位排列更緊密

0x03 零大小欄位對齊

零大小欄位(zero sized field)是指struct{},

大小為 0,按理作為欄位時不需要對齊,但當在作為結構體最後一個欄位(final field)時需要對齊的。

為什麼?

因為,如果有指標指向這個final zero field, 返回的地址將在結構體之外(即指向了別的記憶體),

如果此指標一直存活不釋放對應的記憶體,就會有記憶體洩露的問題(該記憶體不因結構體釋放而釋放)

所以,Go 就對這種final zero field也做了填充,使對齊。

程式碼驗證如下:

type T1 struct {
    a struct{}
    x int64
}

type T2 struct {
    x int64
    a struct{}
}
a1 := T1{}
a2 := T2{}
fmt.Printf("zero size struct{} in field:\n"+
    "T1 (not as final field) size: %d\n"+
    "T2 (as final field) size: %d\n",
    // 8
    unsafe.Sizeof(a1),
    // 64位:16;32位:12
    unsafe.Sizeof(a2))

0x04 記憶體地址對齊

unsafe 包規範中,有如下說明:

Computer architectures may require memory addresses to be aligned; that is, for addresses of a variable to be a multiple of a factor, the variable's type's alignment. The function Alignof takes an expression denoting a variable of any type and returns the alignment of the (type of the) variable in bytes. For a variable x:

uintptr(unsafe.Pointer(&x)) % unsafe.Alignof(x) == 0

大致意思就是,如果型別 t 的對齊保證是 n,那麼型別 t 的每個值的地址在執行時必須是 n 的倍數。

這一點在sync.WaitGroup有很好的應用:

type WaitGroup struct {
  noCopy noCopy
  state1 [3]uint32
}

// state returns pointers to the state and sema fields stored within wg.state1.
func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
  // 判定地址是否8位對齊
  if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
    // 前8bytes做uint64指標statep,後4bytes做sema
    return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
  } else {
    // 後8bytes做uint64指標statep,前4bytes做sema
    return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0]
  }
}

重點是WaitGroup.state1這個欄位,

我們知道uint64的對齊是由機器字決定,32 位系統是 4bytes,64 位系統是 8bytes

為保證在 32 位系統上,也可以返回一個 64 位對齊(8bytes aligned)的指標(*uint64

就巧妙的使用了[3]uint32

首先在 64 位系統和 32 位系統上,uint32能保證是 4bytes 對齊

state1地址是 4N: uintptr(unsafe.Pointer(&wg.state1))%4 == 0

而為保證 8 位對齊,我們只需要判斷state1地址是否為 8 的倍數

  • 如果是(N 為偶數),那前 8bytes 就是 64 位對齊
  • 否則(N 為奇數),那後 8bytes 是 64 位對齊

而且剩餘的 4bytes 可以給sema欄位用,也不浪費記憶體

可是為什麼要在 32 位系統上也要保證一個 64 位對齊的uint64指標呢?

答案是,為了保證在 32 位系統上也能原子訪問 64 位對齊的 64 位字。我們下邊來詳細看下。

0x05 64 位字安全訪問保證

atomic-bug中提到:

On x86-32, 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, x86-32, and 32-bit MIPS, it is the caller's responsibility to arrange for 64-bit alignment of 64-bit words accessed atomically. The first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned.

大致意思是,在 32 位系統上想要原子操作 64 位字(如 uint64)的話,需要由呼叫方保證其資料地址是 64 位對齊的,否則原子訪問會有異常。

為什麼呢?

為什麼要保證

這裡簡單分析如下:

還拿uint64來說,大小為 8bytes,32 位系統上按 4bytes 對齊,64 位系統上按 8bytes 對齊。

在 64 位系統上,8bytes 剛好和其字長相同,所以可以一次完成原子的訪問,不被其他操作影響或打斷。

而 32 位系統,4byte 對齊,字長也為 4bytes,可能出現uint64的資料分佈在兩個資料塊中,需要兩次操作才能完成訪問。

如果兩次操作中間有可能別其他操作修改,不能保證原子性。

這樣的訪問方式也是不安全的。

這一點issue-6404中也有提到:

This is because the int64 is not aligned following the bool. It is 32-bit aligned but not 64-bit aligned, because we're on a 32-bit system so it's really just two 32-bit values side by side.

怎麼保證

The first word in a variable or in an allocated struct, array, or slice can be relied upon to be 64-bit aligned.

變數或開闢的結構體、陣列和切片值中的第一個 64 位字可以被認為是 8 位元組對齊

這一句中開闢的意思是通過宣告,make,new 方式建立的,就是說這樣建立的 64 位字可以保證是 64 位對齊的。

但還是比較抽象,我們舉例分析下

32 位系統下可原子安全訪問的 64 位字有:

  • 64 位字本身
// GOARCH=386 go run types/struct/struct.go
var c0 int64
fmt.Println("64位字本身:",
    atomic.AddInt64(&c0, 1))
  • 64 位字陣列、切片
c1 := [5]int64{}
fmt.Println("64位字陣列、切片:",
    atomic.AddInt64(&c1[:][0], 1))
  • 結構體首欄位為對齊的 64 位字及相鄰的 64 位字
c2 := struct {
    val   int64 // pos 0
    val2  int64 // pos 8
    valid bool  // pos 16
}{}
fmt.Println("結構體首欄位為對齊的64位字及相鄰的64位字:",
    atomic.AddInt64(&c2.val, 1),
    atomic.AddInt64(&c2.val2, 1))
  • 結構體中首欄位為巢狀結構體,且其首元素為 64 位字
type T struct {
    val2 int64
    _    int16
}
c3 := struct {
    val   T
    valid bool
}{}
fmt.Println("結構體中首欄位為巢狀結構體,且其首元素為64位字:",
    atomic.AddInt64(&c3.val.val2, 1))
  • 結構體增加填充使對齊的 64 位字
c4 := struct {
    val   int64   // pos 0
    valid bool    // pos 8
    // 或者 _ uint32
    // 使32位系統上多填充 4bytes
    _     [4]byte // pos 9
    val2  int64   // pos 16
}{}
fmt.Println("結構體增加填充使對齊的64位字:",
    atomic.AddInt64(&c4.val2, 1))
  • 結構體中 64 位字切片
c5 := struct {
    val   int64
    valid bool
    val2 []int64
}{val2: []int64{0}}
fmt.Println("結構體中64位字切片:",
    atomic.AddInt64(&c5.val2[0], 1))

The first element in slices of 64-bit elements will be correctly aligned

此處切片相當指標,資料是指向底層堆上開闢的 64 位字陣列,如 c1

如果換成陣列則會 panic,

因為結構體的陣列的對齊還是依賴於結構體內欄位

c51 := struct {
  val   int64
  valid bool
  val2  [3]int64
}{val2: [3]int64{0}}
// will panic
atomic.AddInt64(&c51.val2[0], 1)
  • 結構體中 64 位字指標
c6 := struct {
    val   int64
    valid bool
    val2  *int64
}{val2: new(int64)}
fmt.Println("結構體中64位字指標:",
    atomic.AddInt64(c6.val2, 1))

改為加鎖

是不是有些複雜,要在 32 位系統上保證 8bytes 對齊的 64 位字, 確實不是很方便

當然也可以選擇不使用原子訪問 (atomic),用加鎖 (mutex) 的方式避免此 bug

c := struct{
    val int16
    val2 int64
}{}
var mu sync.Mutex
mu.Lock()
c.val2 += 1
mu.Unlock()

最後,其實前邊WaitGroup.state1那樣保證 8bytes 對齊還有有個有點點沒有分析:

就是為啥 state 原子訪問不直接用uint64,並使用上邊提到的 64 位字對齊保證?

答案相信你也想到了:如果WaitGroup巢狀到別的結構體時,如果不放到結構體首位會有問題, 這會使其使用受限。

總結一下:

  • 記憶體對齊是為了 cpu 更高效訪問記憶體中資料
  • struct 的對齊是:如果型別 t 的對齊保證是 n,那麼型別 t 的每個值的地址在執行時必須是 n 的倍數。

uintptr(unsafe.Pointer(&x)) % unsafe.Alignof(x) == 0

  • struct 內欄位如果填充過多,可以嘗試重排,使欄位排列更緊密,減少記憶體浪費
  • 零大小欄位要避免作為 struct 最後一個欄位,會有記憶體浪費
  • 32 位系統上對 64 位字的原子訪問要保證其是 8bytes 對齊的;當然如果不必要的話,還是用加鎖(mutex)的方式更清晰簡單

推薦一個工具包:dominikh/go-tools ,裡邊 structlayout, structlayout-optimize, structlayout-pretty 三個工具比較有意思

本文程式碼見 NewbMiao/Dig101-Go

See more: Golang 是否有必要記憶體對齊?

歡迎關注,不定期挖點技術 歡迎關注我,不定期挖點技術

文章首發:公眾號 newbmiao

更多原創文章乾貨分享,請關注公眾號
  • Dig101:Go 之聊聊 struct 的記憶體對齊
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章