本文將介紹Go中的各種類型的尺寸和對齊保證。 知曉這些保證對于估計結(jié)構(gòu)體值的尺寸和正確使用64位整數(shù)原子操作函數(shù)是必要的。
Go是一門屬于C語言家族的編程語言,所以本文談及的很多概念和C語言是相通的。
為了充分利用CPU指令來達(dá)到最佳程序性能,為一個特定類型的值開辟的內(nèi)存塊的起始地址必須為某個整數(shù)N的倍數(shù)。 N被稱為此類型的值地址對齊保證,或者簡單地稱為此類型的對齊保證。 我們也可以說此類型的值的地址保證為N字節(jié)對齊的。
事實上,每個類型有兩個對齊保證。當(dāng)它被用做結(jié)構(gòu)體類型的字段類型時的對齊保證稱為此類型的字段對齊保證,其它情形的對齊保證稱為此類型的一般對齊保證。
對于一個類型T
,我們可以調(diào)用unsafe.Alignof(t)
來獲得它的一般對齊保證,其中t
為一個T
類型的非字段值, 也可以調(diào)用unsafe.Alignof(x.t)
來獲得T
的字段對齊保證,其中x
為一個結(jié)構(gòu)體值并且t
為一個類型為T
的結(jié)構(gòu)體字段值。
unsafe
標(biāo)準(zhǔn)庫包中的函數(shù)的調(diào)用都是在編譯時刻估值的。
在運行時刻,對于類型為T
的一個值t
,我們可以調(diào)用reflect.TypeOf(t).Align()
來獲得類型T
的一般對齊保證, 也可以調(diào)用reflect.TypeOf(t).FieldAlign()
來獲得T
的字段對齊保證。
對于當(dāng)前的官方Go標(biāo)準(zhǔn)編譯器(1.19版本),一個類型的一般對齊保證和字段對齊保證總是相等的。對于gccgo編譯器,這兩者可能不相等。
Go白皮書僅列出了些許類型對齊保證要求。
一個合格的Go編譯器必須保證:
1. 對于任何類型的變量x
,unsafe.Alignof(x)
的結(jié)果最小為1
。
2. 對于一個結(jié)構(gòu)體類型的變量x
,unsafe.Alignof(x)
的結(jié)果為x
的所有字段的對齊保證unsafe.Alignof(x.f)
中的最大值(但是最小為1
)。
3. 對于一個數(shù)組類型的變量x
,unsafe.Alignof(x)
的結(jié)果和此數(shù)組的元素類型的一個變量的對齊保證相等。
從這些要求可以看出,Go白皮書并未為任何類型指定了確定的對齊保證要求,它只是指定了一些最基本的要求。
即使對于同一個編譯器,具體類型的對齊保證在不同的架構(gòu)上也是不相同的。 同一個編譯器的不同版本做出的具體類型的對齊保證也有可能是不相同的。 當(dāng)前版本(1.19)的標(biāo)準(zhǔn)編譯器做出的對齊保證列在了下面:
類型種類 對齊保證(字節(jié)數(shù))
------ ------
bool, uint8, int8 1
uint16, int16 2
uint32, int32 4
float32, complex64 4
數(shù)組 取決于元素類型
結(jié)構(gòu)體類型 取決于各個字段類型
其它類型 一個自然字的尺寸
這里,一個自然字(native word)的尺寸在32位的架構(gòu)上為4字節(jié),在64位的架構(gòu)上為8字節(jié)。
這意味著,對于當(dāng)前版本的標(biāo)準(zhǔn)編譯器,其它類型的對齊保證為4
或者8
,具體取決于程序編譯時選擇的目標(biāo)架構(gòu)。 此結(jié)論對另一個流行Go編譯器gccgo也成立。
一般情況下,在Go編程中,我們不必關(guān)心值地址的對齊保證。 除非有時候我們打算優(yōu)化一下內(nèi)存消耗,或者編寫跨平臺移植性良好的Go代碼。 請閱讀下兩節(jié)以獲得詳情。
Go白皮書只對以下種類的類型的尺寸進(jìn)行了明確規(guī)定。
類型種類 尺寸(字節(jié)數(shù))
------ ------
uint8, int8 1
uint16, int16 2
uint32, int32, float32 4
uint64, int64 8
float64, complex64 8
complex128 16
uint, int 取決于編譯器實現(xiàn)。通常在
32位架構(gòu)上為4,在64位
架構(gòu)上為8。
uintptr 取決于編譯器實現(xiàn)。但必須
能夠存下任一個內(nèi)存地址。
Go白皮書沒有對其它種類的類型的尺寸做出明確規(guī)定。 請閱讀值復(fù)制成本一文來獲取標(biāo)準(zhǔn)編譯器使用的各種其它類型的尺寸。
標(biāo)準(zhǔn)編譯器(和gccgo編譯器)將確保一個類型的尺寸為此類型的對齊保證的倍數(shù)。
為了滿足前面提到的各條地址對齊保證要求規(guī)則,Go編譯器可能會在結(jié)構(gòu)體的相鄰字段之間填充一些字節(jié)。 這使得一個結(jié)構(gòu)體類型的尺寸并非等于它的各個字段類型尺寸的簡單相加之和。
下面是一個展示了一些字節(jié)是如何填充到一個結(jié)構(gòu)體中的例子。 首先,從上面的描述中,我們已得知(對于標(biāo)準(zhǔn)編譯器來說):
int8
的對齊保證和尺寸均為1個字節(jié);
內(nèi)置類型int16
的對齊保證和尺寸均為2個字節(jié);
內(nèi)置類型int64
的尺寸為8個字節(jié),但它的對齊保證在32位架構(gòu)上為4個字節(jié),在64位架構(gòu)上為8個字節(jié)。
T1
和T2
的對齊保證均為它們的各個字段的最大對齊保證。
所以它們的對齊保證和內(nèi)置類型int64
相同,即在32位架構(gòu)上為4個字節(jié),在64位架構(gòu)上為8個字節(jié)。
T1
和T2
尺寸需為它們的對齊保證的倍數(shù),即在32位架構(gòu)上為4n個字節(jié),在64位架構(gòu)上為8n個字節(jié)。
type T1 struct {
a int8
// 在64位架構(gòu)上,為了讓字段b的地址為8字節(jié)對齊,
// 需在這里填充7個字節(jié)。在32位架構(gòu)上,為了讓
// 字段b的地址為4字節(jié)對齊,需在這里填充3個字節(jié)。
b int64
c int16
// 為了讓類型T1的尺寸為T1的對齊保證的倍數(shù),
// 在64位架構(gòu)上需在這里填充6個字節(jié),在32架構(gòu)
// 上需在這里填充2個字節(jié)。
}
// 類型T1的尺寸在64位架構(gòu)上為24個字節(jié)(1+7+8+2+6),
// 在32位架構(gòu)上為16個字節(jié)(1+3+8+2+2)。
type T2 struct {
a int8
// 為了讓字段c的地址為2字節(jié)對齊,
// 需在這里填充1個字節(jié)。
c int16
// 在64位架構(gòu)上,為了讓字段b的地址為8字節(jié)對齊,
// 需在這里填充4個字節(jié)。在32位架構(gòu)上,不需填充
// 字節(jié)即可保證字段b的地址為4字節(jié)對齊的。
b int64
}
// 類型T2的尺寸在64位架構(gòu)上位16個字節(jié)(1+1+2+4+8),
// 在32位架構(gòu)上為12個字節(jié)(1+1+2+8)。
從這個例子可以看出,盡管類型T1
和T2
擁有相同的字段集,但是它們的尺寸并不相等。
一個有趣的事實是有時候一個結(jié)構(gòu)體類型中零尺寸類型的字段可能會影響到此結(jié)構(gòu)體類型的尺寸。 請閱讀此問答獲取詳情。
在此文中,64位字是指類型為內(nèi)置類型int64
或uint64
的值。
原子操作一文提到了一個事實:一個64位字的原子操作要求此64位字的地址必須是8字節(jié)對齊的。 這對于標(biāo)準(zhǔn)編譯器目前支持的64位架構(gòu)來說并不是一個問題,因為標(biāo)準(zhǔn)編譯器保證任何一個64位字的地址在64位架構(gòu)上都是8字節(jié)對齊的。
然而,在32位架構(gòu)上,標(biāo)準(zhǔn)編譯器為64位字做出的地址對齊保證僅為4個字節(jié)。 對一個不是8字節(jié)對齊的64位字進(jìn)行64位原子操作將在運行時刻產(chǎn)生一個恐慌。 更糟的是,一些非常老舊的架構(gòu)并不支持64位原子操作需要的基本指令。
sync/atomic
標(biāo)準(zhǔn)庫包文檔的末尾提到:
On x86-32, 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 both ARM and x86-32, 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.
所以,情況并非無可挽救。
這些途徑被描述為開辟的結(jié)構(gòu)體、數(shù)組和切片值中的第一個(64位)字可以被認(rèn)為是8字節(jié)對齊的。 這里的開辟的應(yīng)該如何解讀? 我們可以認(rèn)為一個開辟的值為一個聲明的變量、內(nèi)置函數(shù)make
的調(diào)用返回值,或者內(nèi)置函數(shù)new
的調(diào)用返回值所引用的值。 如果一個切片是從一個開辟的數(shù)組派生出來的并且此切片和此數(shù)組共享第一個元素,則我們也可以將此切片看作是一個開辟的值。
此對哪些64位字可以在32位架構(gòu)上被安全地原子訪問的描述是有些保守的。 有很多此描述并未包括的64位字在32位架構(gòu)上也是可以被安全地原子訪問的。 比如,如果一個元素類型為64位字的數(shù)組或者切片的第一個元素可以被安全地進(jìn)行64位原子訪問,則此數(shù)組或切片中的所有元素都可以被安全地進(jìn)行64位原子訪問。 只是因為很難用三言兩語將所有在32位架構(gòu)上可以被安全地原子訪問的64位字都羅列出來,所以官方文檔采取了一種保守的描述。
下面是一個展示了哪些64位字在32位架構(gòu)上可以和哪些不可以被安全地原子訪問的例子。
type (
T1 struct {
v uint64
}
T2 struct {
_ int16
x T1
y *T1
}
T3 struct {
_ int16
x [6]int64
y *[6]int64
}
)
var a int64 // a可以安全地被原子訪問
var b T1 // b.v可以安全地被原子訪問
var c [6]int64 // c[0]可以安全地被原子訪問
var d T2 // d.x.v不能被安全地被原子訪問
var e T3 // e.x[0]不能被安全地被原子訪問
func f() {
var f int64 // f可以安全地被原子訪問
var g = []int64{5: 0} // g[0]可以安全地被原子訪問
var h = e.x[:] // h[0]可以安全地被原子訪問
// 這里,d.y.v和e.y[0]都可以安全地被原子訪問,
// 因為*d.y和*e.y都是開辟出來的。
d.y = new(T1)
e.y = &[6]int64{}
_, _, _ = f, g, h
}
// 事實上,c、g和e.y.v的所有以元素都可以被安全地原子訪問。
// 只不過官方文檔沒有明確地做出保證。
如果一個結(jié)構(gòu)體類型的某個64位字的字段(通常為第一個字段)在代碼中需要被原子訪問,為了保證此字段值在各種架構(gòu)上都可以被原子訪問,我們應(yīng)該總是使用此結(jié)構(gòu)體的開辟值。 當(dāng)此結(jié)構(gòu)體類型被用做另一個結(jié)構(gòu)體類型的一個字段的類型時,此字段應(yīng)該(盡量)被安排為另一個結(jié)構(gòu)體類型的第一個字段,并且總是使用另一個結(jié)構(gòu)體類型的開辟值。
如果一個結(jié)構(gòu)體含有需要一個被原子訪問的字段,并且我們希望此結(jié)構(gòu)體可以自由地用做其它結(jié)構(gòu)體的任何字段(可能非第一個字段)的類型,則我們可以用一個[15]byte
值來模擬此64位值,并在運行時刻動態(tài)地決定此64位值的地址。 比如:
package mylib
import (
"unsafe"
"sync/atomic"
)
type Counter struct {
x [15]byte // 模擬:x uint64
}
func (c *Counter) xAddr() *uint64 {
// 此返回結(jié)果總是8字節(jié)對齊的。
return (*uint64)(unsafe.Pointer(
(uintptr(unsafe.Pointer(&c.x)) + 7)/8*8))
}
func (c *Counter) Add(delta uint64) {
p := c.xAddr()
atomic.AddUint64(p, delta)
}
func (c *Counter) Value() uint64 {
return atomic.LoadUint64(c.xAddr())
}
通過采用此方法,Counter
類型可以自由地用做其它結(jié)構(gòu)體的任何字段的類型,而無需擔(dān)心此類型中維護(hù)的64位字段值可能不是8字節(jié)對齊的。 此方法的缺點是,對于每個Counter
類型的值,都有7個字節(jié)浪費了。而且此方法使用了非類型安全指針。
Go 1.19引入了一種更為優(yōu)雅的方法來保證一些值的地址對齊保證為8字節(jié)。 Go 1.19在sync/atomic
標(biāo)準(zhǔn)庫包中加入了幾個原子類型。 這些類型包括atomic.Int64
和atomic.Uint64
。 這兩個類型的值在內(nèi)存中總是8字節(jié)對齊的,即使在32位架構(gòu)上也是如此。 我們可以利用這個事實來確保一些64位字在32位架構(gòu)上總是8字節(jié)對齊的。 比如,無論在32位架構(gòu)還是64位架構(gòu)上,下面的代碼所示的T
類型的x
字段在任何情形下總是8字節(jié)對齊的。
type T struct {
_ [0]atomic.Int64
x int64
}
更多建議: