淺談 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:
-
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.
當然,如果你以前沒有接觸過內存對齊的話,那麼對你來說上面的內容可能過於言簡意賅,在繼續學習之前我建議你閱讀以下資料,有助於消化理解:
-
圖解 Go 之內存對齊 [2]
-
在 Go 中恰到好處的內存對齊 [3]
-
Go 結構體的內存佈局 [4]
-
Golang 是否有必要內存對齊 [5]
測試
我構造了一個 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