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()的規則如下:

在瞭解了上面的規則之後,我們就可以通過調整結構體 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