淺談 Golang 內存對齊

如果你在 golang spec[1] 裏以「alignment」爲關鍵字搜索的話,那麼會發現與此相關的內容並不多,只是在結尾介紹 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

The following minimal alignment properties are guaranteed:

當然,如果你以前沒有接觸過內存對齊的話,那麼對你來說上面的內容可能過於言簡意賅,在繼續學習之前我建議你閱讀以下資料,有助於消化理解:

測試

我構造了一個 struct,它有一個特徵:字段按照一小一大的順序排列,如果不看註釋中的 Sizeof、Alignof、Offsetof 信息(通過 unsafe 獲取),你能否說出它佔用多少個字節?

package main

import (
 "fmt"
 "unsafe"
)

type memAlign struct {
 a byte     // Sizeof: 1  Alignof: 1 Offsetof: 0
 b int      // Sizeof: 8  Alignof: 8 Offsetof: 8
 c byte     // Sizeof: 1  Alignof: 1 Offsetof: 16
 d string   // Sizeof: 16 Alignof: 8 Offsetof: 24
 e byte     // Sizeof: 1  Alignof: 1 Offsetof: 40
 f []string // Sizeof: 24 Alignof: 8 Offsetof: 48
}

func main() {
 var m memAlign
 fmt.Println(unsafe.Sizeof(m))
}

初學者往往會認爲 struct 的大小應該等於內部各個字段大小的和,於是得出本例的答案是 51(1+8+1+16+1+24=51),不過實際上答案卻是 72!究其原因是因爲內存對齊的緣故導致各個字段之間可能存在 padding。那麼有沒有簡單的方法來減少 padding 呢?我們不妨把字段按照從大到小的順序排列,再試一試:

package main

import (
 "fmt"
 "unsafe"
)

type memAlign struct {
 f []string // Sizeof: 24 Alignof: 8 Offsetof: 0
 d string   // Sizeof: 16 Alignof: 8 Offsetof: 24
 b int      // Sizeof: 8  Alignof: 8 Offsetof: 40
 a byte     // Sizeof: 1  Alignof: 1 Offsetof: 48
 c byte     // Sizeof: 1  Alignof: 1 Offsetof: 49
 e byte     // Sizeof: 1  Alignof: 1 Offsetof: 50
}

func main() {
 var m memAlign
 fmt.Println(unsafe.Sizeof(m))
}

結果答案變成了 56,比 72 小了很多,不過還是比 51 大,說明還是存在 padding,這是因爲不僅字段要內存對齊,struct 本身也要內存對齊。

另:我剛學 golang 的時候一直有一個疑問:爲什麼切片的大小是 24,字符串的大小是 16 呢?我估計別的初學者也會有類似的問題,一併解釋一下,這是因爲切片和字符串也是 struct,其定義分別對應 SliceHeader[6] 和 StringHeader[7],它們的大小分別是 24 和 16:

type SliceHeader struct {
 Data uintptr
 Len  int
 Cap  int
}

type StringHeader struct {
 Data uintptr
 Len  int
}

因爲 uintptr 的大小等於 int,所以切片的大小等於 3*_8=24,字符串的大小等於 2*_8=16。

工具

只要我們寫點代碼,調用 unsafe 包的 Sizeof、Alignof、Offsetof 等方法,那麼就可以搞清楚 struct 內存對齊的各種細節,不過這畢竟是個沒有技術含量的體力活,有沒有相關工具可以提升我們的工作效率呢?答案是 go-tools[8]:

shell> go install honnef.co/go/tools/cmd/structlayout@latest
shell> go install honnef.co/go/tools/cmd/structlayout-pretty@latest
shell> go install honnef.co/go/tools/cmd/structlayout-optimize@latest

其中,structlayout 是用來分析數據的,pretty 是用來圖形化顯示的,optimize 是用來優化建議的,這裏就用文章開頭優化前的代碼給出一個 structlayout-pretty 的例子:

shell> structlayout -json ./main.go memAlign | structlayout-pretty

structlayout-pretty

雖然 structlayout-pretty 我們可以很直觀的看到在哪裏存在 padding,不過它是 ascii 風格的,有時候不太方便,此時另外一個圖形化工具 structlayout-svg[9] 更爽:

shell> go install github.com/ajstarks/svgo/structlayout-svg@latest

把文章開頭優化前後的代碼分別用 structlayout-svg 生成結果:

shell> structlayout -json ./main.go memAlign | structlayout-svg

優化前:

優化前

優化後:

優化後

效果超讚是不是!不過如果我們要把工具集成到 CI 裏,那麼此類圖形化工具就不合適了,好在我們的工具箱裏還有寶貝,它就是 fieldalignment[10]:

shell> go install golang.org/x/tools/...@latest

把文章開頭優化前後的代碼分別用 fieldalignment 生成結果:

shell> awk '$1 == "module" {print $2}' ./go.mod | xargs fieldalignment

優化前:struct of size 72 could be 56;優化後:struct with 32 pointer bytes could be 24。

如上可見,fieldalignment 準確判斷出優化前代碼的 struct size 存在優化空間;但是優化後代碼的 pointer bytes 是什麼鬼?按照文檔中的說明,pointer bytes 的含義如下:

Pointer bytes is how many bytes of the object that the garbage collector has to potentially scan for pointers, for example:

struct { uint32; string }

have 16 pointer bytes because the garbage collector has to scan up through the string’s inner pointer.

struct { string; *uint32 }

has 24 pointer bytes because it has to scan further through the *uint32.

struct { string; uint32 }

has 8 because it can stop immediately after the string pointer.

看到這裏,不禁讓人產生疑惑:GC 不會這麼傻吧,難道它還要一個字節一個字節的掃描內存麼?讓我們做個實驗測試一下 pointer bytes 有沒有影響,正所謂有病沒病走兩步:

package main

import (
 "runtime"
 "time"
)

// pointer bytes: 8
type foo struct {
 S string
 U uint32
}

// pointer bytes: 16
type bar struct {
 U uint32
 S string
}

// GODEBUG=gctrace=1 go run main.go
func main() {
 v := make([]foo, 1e8)
 // v := make([]bar, 1e8)
 for range time.Tick(time.Second) {
  runtime.GC()
 }
 runtime.KeepAlive(v)
}

代碼裏構造了一個巨大的切片變量,棧必然保存不了,於是變量會逃逸到堆,接着週期性的調用 runtime.GC 來手動觸發 GC,然後執行的時候通過 GODEBUG=gctrace=1 獲取實時的 GC 相關信息。結果顯示,不管是小 pointer bytes 的 foo,還是大 pointer bytes 的 bar,最終 GC 消耗的時間差不多。換句話說,pointer bytes 的大小對 GC 的影響很小很小,在 golang 的相關 issue[11] 的討論中,也能印證此結論,篇幅所限,這裏就不多說了。

另:命令輸出的 gctrace 信息比較多,相關格式說明可以參考 runtime[12] 中的註釋信息。

例子

瞭解了內存對齊的相關知識後,讓我們看看現實世界中的例子,首先是 groupcache[13]:

type Group struct {
 name string
 getter Getter
 peersOnce sync.Once
 peers PeerPicker
 cacheBytes int64
 mainCache cache
 hotCache cache
 loadGroup flightGroup
 _ int32 // force Stats to be 8-byte aligned on 32-bit platforms
 Stats Stats
}

通過註釋我們可以看到,爲了強制讓 Stats 在 32 位平臺上按 8 字節對齊,在 Stats 字段的前面加了一個「_ int32」,換句話說,就是加了 4 個字節,那麼爲什麼要這麼做?

原因是 Stats 字段要參與 atomic 原子運算,關於 atomic[14],文檔最後記錄瞭如下內容:

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. 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 位對齊的,此外,struct 中第一個字段可以被認爲是 64 位對齊的。在本例中,因爲 Stats 字段要參與 atomic 運算,而且不是第一個字段,所以我們必須手動保證它是 64 位對齊的,不過加了 _ int32 就能保證是 64 位對齊的麼?讓我們寫代碼驗證一下:

package main

import (
 "fmt"
 "unsafe"

 "github.com/golang/groupcache"
)

// GOARCH=386 go run main.go
func main() {
 var g groupcache.Group
 fmt.Println(unsafe.Offsetof(g.Stats))
}

結果顯示在 32 位下運行,Stats 的 offset 是 176,是 8 的倍數,滿足 64 位對齊。如果沒有「_ int32」做 padding,那麼 Stats 的 offset 將是 172,就不再是 8 的倍數了。

再看看 sync.WaitGroup 中內存對齊的例子:

type WaitGroup struct {
 noCopy noCopy

 // 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
 // 64-bit atomic operations require 64-bit alignment, but 32-bit
 // compilers do not ensure it. So we allocate 12 bytes and then use
 // the aligned 8 bytes in them as state, and the other 4 as storage
 // for the sema.
 state1 [3]uint32
}

func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
 if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
  return (*uint64)(unsafe.Pointer(&wg.state1))&wg.state1[2]
 } else {
  return (*uint64)(unsafe.Pointer(&wg.state1[1]))&wg.state1[0]
 }
}

首先,noCopy 是什麼鬼,其實它的作用就像名字一樣,它是如何實現的呢,看註釋:

// noCopy may be embedded into structs which must not be copied
// after the first use.
//
// See https://golang.org/issues/8005#issuecomment-190753527
// for details.
type noCopy struct{}

// Lock is a no-op used by -copylocks checker from `go vet`.
func (*noCopy) Lock()   {}
func (*noCopy) Unlock() {}

實際上它只是起到標識的作用,以便 go vet 能夠藉此發現問題,詳細說明在 issue[15] 中有描述,如果你在自己的項目裏有類似 noCopy 的需求,那麼也可以照貓畫虎,

接下來是內存對齊相關的重頭戲了,state1 字段是一個有 3 個元素的 uint32 數組,它會保存兩種數據,分別是 statep 和 semap,其中,statep 要參與 atomic 運算,所以我們要保證它是 64 位對齊的。如果「uintptr(unsafe.Pointer(&wg.state1))%8 == 0」成立,那麼取前兩個 int32 做 statep,否則取後兩個 int32 做 statep。

爲什麼可以這樣做?因爲「uintptr(unsafe.Pointer(&wg.state1))%8 == 0」成立的時候,前兩個 int32 自然滿足 64 位對齊;當「uintptr(unsafe.Pointer(&wg.state1))%8 == 0」不成立的時候, 其運算結果必然等於 4,此時我們正好可以把第一個 int32 當作是一個 4 字節的 padding,於是後兩個字節的 int32 就又滿足 64 位對齊了。

如果你認爲自己理解了,那麼思考一下,在定義 state1 的時候,如果不用 [3]int32,而是換成一個 int64 加上一個 int32,或者是一個 [12]byte,它們都是 12 個字節,是否可以?

參考資料

[1]

golang spec: https://golang.org/ref/spec

[2]

圖解 Go 之內存對齊: http://blog.newbmiao.com/slides/%E5%9B%BE%E8%A7%A3Go%E4%B9%8B%E5%86%85%E5%AD%98%E5%AF%B9%E9%BD%90.pdf

[3]

在 Go 中恰到好處的內存對齊: https://eddycjy.gitbook.io/golang/di-1-ke-za-tan/go-memory-align

[4]

Go 結構體的內存佈局: https://www.liwenzhou.com/posts/Go/struct_memory_layout/

[5]

Golang 是否有必要內存對齊: https://ms2008.github.io/2019/08/01/golang-memory-alignment/

[6]

SliceHeader: https://pkg.go.dev/reflect#SliceHeader

[7]

StringHeader: https://pkg.go.dev/reflect#StringHeader

[8]

go-tools: https://github.com/dominikh/go-tools

[9]

structlayout-svg: https://github.com/ajstarks/svgo/tree/master/structlayout-svg

[10]

fieldalignment: https://github.com/golang/tools/blob/master/gopls/doc/analyzers.md#fieldalignment

[11]

issue: https://github.com/golang/go/issues/44877#issuecomment-794565908

[12]

runtime: https://pkg.go.dev/runtime

[13]

groupcache: https://github.com/golang/groupcache/blob/master/groupcache.go

[14]

atomic: https://pkg.go.dev/sync/atomic

[15]

issue: https://github.com/golang/go/issues/8005#issuecomment-190753527

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/srQ3FodujIE9H6WYgC_kcw