Go 結構體的內存對齊
本文介紹了 Go 語言結構體的內存對齊現象和對齊策略,並通過一些具體示例介紹了 Go 語言中結構體內存佈局的特殊場景。
結構體的內存佈局
結構體大小
結構體是佔用一塊連續的內存,一個結構體變量的大小是由結構體中的字段決定。
type Foo struct {
A int8 // 1
B int8 // 1
C int8 // 1
}
var f Foo
fmt.Println(unsafe.Sizeof(f)) // 3
內存對齊
但是結構體的大小又不完全由結構體的字段決定,例如:
type Bar struct {
x int32 // 4
y *Foo // 8
z bool // 1
}
var b1 Bar
fmt.Println(unsafe.Sizeof(b1)) // 24
有的同學可能會認爲結構體變量b1
的內存佈局如下圖所示,那麼問題來了,結構體變量b1
的大小怎麼會是 24 呢?
memory layout of Bar1
很顯然結構體變量b1
的內存佈局和上圖中的並不一致,實際上的佈局應該如下圖所示,灰色虛線的部分就是內存對齊時的填充(padding)部分。
memory layout of Bar1
Go 在編譯的時候會按照一定的規則自動進行內存對齊。之所以這麼設計是爲了減少 CPU 訪問內存的次數,加大 CPU 訪問內存的吞吐量。如果不進行內存對齊的話,很可能就會增加 CPU 訪問內存的次數。例如下圖中 CPU 想要獲取b1.y
字段的值可能就需要兩次總線週期。
word size
因爲 CPU 訪問內存時,並不是逐個字節訪問,而是以字(word)爲單位訪問。比如 64 位 CPU 的字長(word size)爲 8bytes,那麼 CPU 訪問內存的單位也是 8 字節,每次加載的內存數據也是固定的若干字長,如 8words(64bytes)、16words(128bytes)等。
對齊保證
我們上面已經知道了可以通過內置unsafe
包的Sizeof
函數來獲取一個變量的大小,此外我們還可以通過內置unsafe
包的Alignof
函數來獲取一個變量的對齊係數,例如:
// 結構體變量b1的對齊係數
fmt.Println(unsafe.Alignof(b1)) // 8
// b1每一個字段的對齊係數
fmt.Println(unsafe.Alignof(b1.x)) // 4:表示此字段須按4的倍數對齊
fmt.Println(unsafe.Alignof(b1.y)) // 8:表示此字段須按8的倍數對齊
fmt.Println(unsafe.Alignof(b1.z)) // 1:表示此字段須按1的倍數對齊
unsafe.Alignof()
的規則如下:
-
對於任意類型的變量 x ,
unsafe.Alignof(x)
至少爲 1。 -
對於 struct 類型的變量 x,計算 x 每一個字段 f 的
unsafe.Alignof(x.f)
,unsafe.Alignof(x)
等於其中的最大值。 -
對於 array 類型的變量 x,
unsafe.Alignof(x)
等於構成數組的元素類型的對齊倍數。
在瞭解了上面的規則之後,我們就可以通過調整結構體 Bar 中字段的順序來減少其大小:
type Bar2 struct {
x int32 // 4
z bool // 1
y *Foo // 8
}
var b2 Bar2
fmt.Println(unsafe.Sizeof(b2)) // 16
此時結構體 Bar2 變量的內存佈局示意圖如下:
memory layout of Bar2
或者將字段順序調整爲以下順序。
type Bar3 struct {
z bool // 1
x int32 // 4
y *Foo // 8
}
var b3 Bar3
fmt.Println(unsafe.Sizeof(b3)) // 16
此時結構體 Bar3 變量的內存佈局示意圖如下:
memory layout of Bar3
總結一下:在瞭解了 Go 的內存對齊規則之後,我們在日常的編碼過程中,完全可以通過合理地調整結構體的字段順序,從而優化結構體的大小。
結構體內存佈局的特殊場景
除了上述利用內存對齊規則調整字段順序優化結構體內存佈局外,關於 Go 語言中結構體的內存佈局還存在以下幾種相對特殊的場景需要注意。
空結構體字段對齊
首先我們需要了解的一個前提是:如果結構或數組類型不包含大小大於零的字段(或元素),則其大小爲 0。兩個不同的 0 大小變量在內存中可能有相同的地址。
由於空結構體struct{}
的大小爲 0,所以當一個結構體中包含空結構體類型的字段時,通常不需要進行內存對齊。例如:
type Demo1 struct {
m struct{} // 0
n int8 // 1
}
var d1 Demo1
fmt.Println(unsafe.Sizeof(d1)) // 1
但是當空結構體類型作爲結構體的最後一個字段時,如果有指向該字段的指針,那麼就會返回該結構體之外的地址。爲了避免內存泄露會額外進行一次內存對齊。
type Demo2 struct {
n int8 // 1
m struct{} // 0
}
var d2 Demo2
fmt.Println(unsafe.Sizeof(d2)) // 2
示意圖:
empty struct memory layout
在實際編程中通過靈活應用空結構體大小爲 0 的特性能夠幫助我們節省很多不必要的內存開銷。
例如,我們可以使用空結構體作爲 map 的值來實現一個類似 Set 的數據結構。
var set map[int]struct{}
我們還可以使用空結構體作爲通知類 channel 的元素,例如 Go 源碼src/cmd/internal/base/signal.go
中。
// src/cmd/internal/base/signal.go
// Interrupted is closed when the go command receives an interrupt signal.
var Interrupted = make(chan struct{})
以及 src/net/pipe.go
中都有類似的使用示例。
// src/net/pipe.go
// pipeDeadline is an abstraction for handling timeouts.
type pipeDeadline struct {
mu sync.Mutex // Guards timer and cancel
timer *time.Timer
cancel chan struct{} // Must be non-nil
}
原子操作在 32 位平臺要求強制內存對齊
在 x86 平臺上原子操作需要強制內存對齊是因爲在 32bit 平臺下進行 64bit 原子操作要求必須 8 字節對齊,否則程序會 panic,下面是 Go 源碼src/atomic/doc.go
中的說明。
// src/atomic/doc.go
// BUG(rsc): On 386, 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, 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.
這裏可以參照 groupcache 庫中的實際應用,示例代碼如下。
type Group struct {
name string
getter Getter
peersOnce sync.Once
peers PeerPicker
cacheBytes int64 // limit for sum of mainCache and hotCache size
// mainCache is a cache of the keys for which this process
// (amongst its peers) is authoritative. That is, this cache
// contains keys which consistent hash on to this process's
// peer number.
mainCache cache
// hotCache contains keys/values for which this peer is not
// authoritative (otherwise they would be in mainCache), but
// are popular enough to warrant mirroring in this process to
// avoid going over the network to fetch from a peer. Having
// a hotCache avoids network hotspotting, where a peer's
// network card could become the bottleneck on a popular key.
// This cache is used sparingly to maximize the total number
// of key/value pairs that can be stored globally.
hotCache cache
// loadGroup ensures that each key is only fetched once
// (either locally or remotely), regardless of the number of
// concurrent callers.
loadGroup flightGroup
_ int32 // force Stats to be 8-byte aligned on 32-bit platforms
// Stats are statistics on the group.
Stats Stats
}
// ...
// Stats are per-group statistics.
type Stats struct {
Gets AtomicInt // any Get request, including from peers
CacheHits AtomicInt // either cache was good
PeerLoads AtomicInt // either remote load or remote cache hit (not an error)
PeerErrors AtomicInt
Loads AtomicInt // (gets - cacheHits)
LoadsDeduped AtomicInt // after singleflight
LocalLoads AtomicInt // total good local loads
LocalLoadErrs AtomicInt // total bad local loads
ServerRequests AtomicInt // gets that came over the network from peers
}
Group
結構體中通過添加一個int32
字段強制讓Stats
字段在 32bit 平臺也是 8 字節對齊的。
false sharing
結構體內存對齊除了上面的場景外,在一些需要防止 CacheLine 僞共享的時候,也需要進行特殊的字段對齊。例如sync.Pool
中就有這種設計:
type poolLocal struct {
poolLocalInternal
// Prevents false sharing on widespread platforms with
// 128 mod (cache line size) = 0 .
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}
結構體中的pad
字段就是爲了防止 false sharing 而設計的。
當不同的線程同時讀寫同一個 cache line 上不同數據時就可能發生 false sharing。false sharing 會導致多核處理器上嚴重的系統性能下降。具體的可以參考 僞共享 (False Sharing)。
如註釋所說這裏之所以使用 128 字節進行內存對齊是爲了兼容更多的平臺。
hot path
hot path 是指執行非常頻繁的指令序列。
在訪問結構體的第一個字段時,我們可以直接使用結構體的指針來訪問第一個字段(結構體變量的內存地址就是其第一個字段的內存地址)。
如果要訪問結構體的其他字段,除了結構體指針外,還需要計算與第一個值的偏移 (calculate offset)。在機器碼中,偏移量是隨指令傳遞的附加值,CPU 需要做一次偏移值與指針的加法運算,才能獲取要訪問的值的地址。因爲,訪問第一個字段的機器代碼更緊湊,速度更快。
下面的代碼是標準庫sync.Once
中的使用示例,通過將常用字段放置在結構體的第一個位置上減少 CPU 要執行的指令數量,從而達到更快的訪問效果。
// src/sync/once.go
// Once is an object that will perform exactly one action.
//
// A Once must not be copied after first use.
type Once struct {
// done indicates whether the action has been performed.
// It is first in the struct because it is used in the hot path.
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/386),
// and fewer instructions (to calculate offset) on other architectures.
done uint32
m Mutex
}
參考鏈接:https://stackoverflow.com/questions/59174176/what-does-hot-path-mean-in-the-context-of-sync-once
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/gYL9nKIXEKgnZP8BHNADAA