淺談Golang內(nèi)存對齊
如果你在 golang spec[1] 里以「alignment」為關(guān)鍵字搜索的話,那么會發(fā)現(xiàn)與此相關(guān)的內(nèi)容并不多,只是在結(jié)尾介紹 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.
當然,如果你以前沒有接觸過內(nèi)存對齊的話,那么對你來說上面的內(nèi)容可能過于言簡意賅,在繼續(xù)學(xué)習(xí)之前我建議你閱讀以下資料,有助于消化理解:
圖解 Go 之內(nèi)存對齊[2] 在 Go 中恰到好處的內(nèi)存對齊[3] Go 結(jié)構(gòu)體的內(nèi)存布局[4] Golang 是否有必要內(nèi)存對齊[5]
測試
我構(gòu)造了一個 struct,它有一個特征:字段按照一小一大的順序排列,如果不看注釋中的 Sizeof、Alignof、Offsetof 信息(通過 unsafe 獲取),你能否說出它占用多少個字節(jié)?
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))
}
初學(xué)者往往會認為 struct 的大小應(yīng)該等于內(nèi)部各個字段大小的和,于是得出本例的答案是 51(1+8+1+16+1+24=51),不過實際上答案卻是 72!究其原因是因為內(nèi)存對齊的緣故導(dǎo)致各個字段之間可能存在 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))
}
結(jié)果答案變成了 56,比 72 小了很多,不過還是比 51 大,說明還是存在 padding,這是因為不僅字段要內(nèi)存對齊,struct 本身也要內(nèi)存對齊。
另:我剛學(xué) golang 的時候一直有一個疑問:為什么切片的大小是 24,字符串的大小是 16 呢?我估計別的初學(xué)者也會有類似的問題,一并解釋一下,這是因為切片和字符串也是 struct,其定義分別對應(yīng) 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。
工具
只要我們寫點代碼,調(diào)用 unsafe 包的 Sizeof、Alignof、Offsetof 等方法,那么就可以搞清楚 struct 內(nèi)存對齊的各種細節(jié),不過這畢竟是個沒有技術(shù)含量的體力活,有沒有相關(guān)工具可以提升我們的工作效率呢?答案是 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 是用來分析數(shù)據(jù)的,pretty 是用來圖形化顯示的,optimize 是用來優(yōu)化建議的,這里就用文章開頭優(yōu)化前的代碼給出一個 structlayout-pretty 的例子:
shell>?structlayout?-json?./main.go?memAlign?|?structlayout-pretty

雖然 structlayout-pretty 我們可以很直觀的看到在哪里存在 padding,不過它是 ascii 風(fēng)格的,有時候不太方便,此時另外一個圖形化工具 structlayout-svg[9] 更爽:
shell>?go?install?github.com/ajstarks/svgo/structlayout-svg@latest
把文章開頭優(yōu)化前后的代碼分別用 structlayout-svg 生成結(jié)果:
shell>?structlayout?-json?./main.go?memAlign?|?structlayout-svg
優(yōu)化前:

優(yōu)化后:

效果超贊是不是!不過如果我們要把工具集成到 CI 里,那么此類圖形化工具就不合適了,好在我們的工具箱里還有寶貝,它就是 fieldalignment[10]:
shell>?go?install?golang.org/x/tools/...@latest
把文章開頭優(yōu)化前后的代碼分別用 fieldalignment 生成結(jié)果:
shell>?awk?'$1?==?"module"?{print?$2}'?./go.mod?|?xargs?fieldalignment
優(yōu)化前:struct of size 72 could be 56;優(yōu)化后:struct with 32 pointer bytes could be 24。
如上可見,fieldalignment 準確判斷出優(yōu)化前代碼的 struct size 存在優(yōu)化空間;但是優(yōu)化后代碼的 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.
看到這里,不禁讓人產(chǎn)生疑惑:GC 不會這么傻吧,難道它還要一個字節(jié)一個字節(jié)的掃描內(nèi)存么?讓我們做個實驗測試一下 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)
}
代碼里構(gòu)造了一個巨大的切片變量,棧必然保存不了,于是變量會逃逸到堆,接著周期性的調(diào)用 runtime.GC 來手動觸發(fā) GC,然后執(zhí)行的時候通過 GODEBUG=gctrace=1 獲取實時的 GC 相關(guān)信息。結(jié)果顯示,不管是小 pointer bytes 的 foo,還是大 pointer bytes 的 bar,最終 GC 消耗的時間差不多。換句話說,pointer bytes 的大小對 GC 的影響很小很小,在 golang 的相關(guān) issue[11] 的討論中,也能印證此結(jié)論,篇幅所限,這里就不多說了。
另:命令輸出的 gctrace 信息比較多,相關(guān)格式說明可以參考 runtime[12] 中的注釋信息。
例子
了解了內(nèi)存對齊的相關(guān)知識后,讓我們看看現(xiàn)實世界中的例子,首先是 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 字節(jié)對齊,在 Stats 字段的前面加了一個「_ int32」,換句話說,就是加了 4 個字節(jié),那么為什么要這么做?
原因是 Stats 字段要參與 atomic 原子運算,關(guān)于 atomic[14],文檔最后記錄了如下內(nèi)容:
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 位平臺,調(diào)用者有責(zé)任自己保證原子操作是 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))
}
結(jié)果顯示在 32 位下運行,Stats 的 offset 是 176,是 8 的倍數(shù),滿足 64 位對齊。如果沒有「_ int32」做 padding,那么 Stats 的 offset 將是 172,就不再是 8 的倍數(shù)了。
…
再看看 sync.WaitGroup 中內(nèi)存對齊的例子:
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 是什么鬼,其實它的作用就像名字一樣,它是如何實現(xiàn)的呢,看注釋:
//?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 能夠借此發(fā)現(xiàn)問題,詳細說明在 issue[15] 中有描述,如果你在自己的項目里有類似 noCopy 的需求,那么也可以照貓畫虎,
接下來是內(nèi)存對齊相關(guān)的重頭戲了,state1 字段是一個有 3 個元素的 uint32 數(shù)組,它會保存兩種數(shù)據(jù),分別是 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」不成立的時候, 其運算結(jié)果必然等于 4,此時我們正好可以把第一個 int32 當作是一個 4 字節(jié)的 padding,于是后兩個字節(jié)的 int32 就又滿足 64 位對齊了。
如果你認為自己理解了,那么思考一下,在定義 state1 的時候,如果不用 [3]int32,而是換成一個 int64 加上一個 int32,或者是一個 [12]byte,它們都是 12 個字節(jié),是否可以?
參考資料
golang spec: https://golang.org/ref/spec
[2]圖解 Go 之內(nèi)存對齊: 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 中恰到好處的內(nèi)存對齊: https://eddycjy.gitbook.io/golang/di-1-ke-za-tan/go-memory-align
[4]Go 結(jié)構(gòu)體的內(nèi)存布局: https://www.liwenzhou.com/posts/Go/struct_memory_layout/
[5]Golang 是否有必要內(nèi)存對齊: 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
推薦閱讀
