Dig101:Go之聊聊struct的內存對齊

Dig101: dig more, simplified more and know morehtml

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

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

今天咱們來重點關注下struct的內存對齊,github

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

在此以前,咱們先明確幾個術語,便於後續分析。express

  • 字(word)

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

  • 字長

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

現代電腦的字長一般爲1六、3二、64位。(通常N位系統的字長是 N/8 字節。)安全

電腦中大多數寄存器的大小是一個字長。CPU和內存之間的數據傳送單位也一般是一個字長。還有而內存中用於指明一個存儲位置的地址也常常是以字長爲單位。session

參見維基百科中

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系列

newbmiao
相關文章
相關標籤/搜索