這兒幾個字節(jié),那里幾個字節(jié),我們說的是真正的內(nèi)存
今天的帖子來自于最近的 Go 語言的一次小測試,觀察下面的測試基礎(chǔ)片段 [1]:
func BenchmarkSortStrings(b *testing.B) {
s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sort.Strings(s)
}
}
sort.Strings
是 sort.StringSlice(s)
的便捷包裝器,sort.Strings
在原地對輸入進(jìn)行排序,因此不會分配內(nèi)存(或至少 43% 回答此問題的 Twitter 用戶是這么認(rèn)為的)。然而,至少在 Go 的最近版本中,基準(zhǔn)測試的每次迭代都會導(dǎo)致一次堆分配。為什么會是這種情況?
正如所有 Go 程序員應(yīng)該知道的那樣,接口是以 雙詞結(jié)構(gòu) 實(shí)現(xiàn)的。每個接口值包含一個字段,其中保存接口內(nèi)容的類型,以及指向接口內(nèi)容的指針。[2]
在 Go 語言偽代碼中,一個接口可能是這樣的:
type interface struct {
// the ordinal number for the type of the value
// assigned to the interface
type uintptr
// (usually) a pointer to the value assigned to
// the interface
data uintptr
}
interface.data
可以容納一個機(jī)器字(在大多數(shù)情況下為 8 個字節(jié)),但一個 []string
卻需要 24 個字節(jié):一個字用于指向切片的底層數(shù)組;一個字用于存儲切片的長度;另一個字用于存儲底層數(shù)組的剩余容量。那么,Go 是如何將 24 個字節(jié)裝入個 8 個字節(jié)的呢?通過編程中最古老的技巧,即間接引用。一個 []string
,即 s
,需要 24 個字節(jié);但 *[]string
—— 即指向字符串切片的指針,只需要 8 個字節(jié)。
逃逸到堆
為了讓示例更加明確,以下是重新編寫的基準(zhǔn)測試,不使用 sort.Strings
輔助函數(shù):
func BenchmarkSortStrings(b *testing.B) {
s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}
b.ReportAllocs()
for i := 0; i < b.N; i++ {
var ss sort.StringSlice = s
var si sort.Interface = ss // allocation
sort.Sort(si)
}
}
為了讓接口正常運(yùn)行,編譯器將賦值重寫為 var si sort.Interface = &ss
,即 ss
的地址分配給接口值。[3] 我們現(xiàn)在有這么一種情況:出現(xiàn)一個持有指向 ss
的指針的接口值。它指向哪里?還有 ss
存儲在哪個內(nèi)存位置?
似乎 ss
被移動到了堆上,這也同時(shí)導(dǎo)致了基準(zhǔn)測試報(bào)告中的分配:
Total: 296.01MB 296.01MB (flat, cum) 99.66%
8 . . func BenchmarkSortStrings(b *testing.B) {
9 . . s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}
10 . . b.ReportAllocs()
11 . . for i := 0; i < b.N; i++ {
12 . . var ss sort.StringSlice = s
13 296.01MB 296.01MB var si sort.Interface = ss // allocation
14 . . sort.Sort(si)
15 . . }
16 . . }
發(fā)生這種分配是因?yàn)榫幾g器當(dāng)前無法確認(rèn) ss
比 si
生存期更長。Go 編譯器開發(fā)人員對此的普遍態(tài)度是,覺得 這個問題改進(jìn)的余地,不過我們另找時(shí)間再議。事實(shí)上,ss
就是被分配到了堆上。因此,問題變成了:每次迭代會分配多少個字節(jié)?為什么不去詢問 testing
包呢?
% go test -bench=. sort_test.go
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-5650U CPU @ 2.20GHz
BenchmarkSortStrings-4 12591951 91.36 ns/op 24 B/op 1 allocs/op
PASS
ok command-line-arguments 1.260s
可以看到,在 amd 64 平臺的 Go 1.16 beta1 版本上,每次操作會分配 24 字節(jié)。[4] 然而,在同一平臺先前的 Go 版本中,每次操作則消耗了 32 字節(jié)。
% go1.15 test -bench=. sort_test.go
goos: darwin
goarch: amd64
BenchmarkSortStrings-4 11453016 96.4 ns/op 32 B/op 1 allocs/op
PASS
ok command-line-arguments 1.225s
這引出了本文的主題,即 Go 1.16 版本中即將推出的一項(xiàng)便利改進(jìn)。不過在討論這個內(nèi)容之前,我需要聊聊 “尺寸類別size class”。
尺寸類別
在解釋什么是 “尺寸類別size class” 之前,我們先考慮個問題,理論上的 Go 語言在運(yùn)行時(shí)是如何在其堆上分配 24 字節(jié)的。有一個簡單的方法:追蹤目前為止已分配到的所有內(nèi)存的動向——利用指向堆上最后分配的字節(jié)的指針。分配 24 字節(jié),堆指針就會增加 24,然后將前一個值返回給調(diào)用函數(shù)。只要寫入的請求 24 字節(jié)的代碼不超出該標(biāo)記的范圍,這種機(jī)制就沒有額外開銷。不過,現(xiàn)實(shí)情況下,內(nèi)存分配器不僅要分配內(nèi)存,有時(shí)還得釋放內(nèi)存。
最終,Go 語言程序在運(yùn)行時(shí)將釋放這些 24 字節(jié),但從運(yùn)行的視角來看,它只知道它給調(diào)用者的開始地址。它不知道從該地址起始之后又分配了多少字節(jié)。為了允許釋放內(nèi)存,我們假設(shè)的 Go 語言程序運(yùn)行時(shí)分配器必須記錄堆上每個分配的長度值。那么這些長度值的分配存儲在何處?當(dāng)然是在堆上。
在我們的設(shè)想中,當(dāng)程序運(yùn)行需要分配內(nèi)存的時(shí)候,它可以請求稍微多一點(diǎn),并把它用來存儲請求的數(shù)量。而對于我們的切片示例而言,當(dāng)我們請求 24 字節(jié)時(shí),實(shí)際上會消耗 24 字節(jié)加上存儲數(shù)字 24
的一些開銷。這些開銷有多大?事實(shí)上,實(shí)際上的最小開銷量是一個字。[5]
用來記錄 24 字節(jié)分配的開銷將是 8 字節(jié)。25% 不是很大,但也不算糟糕,隨著分配的大小增加,開銷將變得微不足道。然而,如果我們只想在堆上存儲一個字節(jié),會發(fā)生什么?開銷將是請求數(shù)據(jù)量的 8 倍!是否有一種更高效的方式在堆上分配少量內(nèi)存?
與其在每個分配旁邊存儲長度,不如將相同大小的內(nèi)容存儲在一起,這個主意如何?如果所有的 24 字節(jié)的內(nèi)容都存儲在一起,那么運(yùn)行時(shí)會自動獲取它們的大小。運(yùn)行時(shí)所需要的是一個單一的位,指示 24 字節(jié)區(qū)域是否在使用中。在 Go 語言中,這些區(qū)域被稱為 Size Classes,因?yàn)橄嗤笮〉乃袃?nèi)容都會存儲在一起(類似學(xué)校班級,所有學(xué)生都按同一年級分班,而不是 C++ 中的類)。當(dāng)運(yùn)行時(shí)需要分配少量內(nèi)存時(shí),它會使用能夠容納該分配的最小的尺寸類別。
無限制的尺寸類別
現(xiàn)在我們知道尺寸類別是如何工作的了,那么問題又來了,它們存儲在哪里?和我們想的一樣,尺寸類別的內(nèi)存來自堆。為了最小化開銷,運(yùn)行時(shí)會從堆上分配較大的內(nèi)存塊(通常是系統(tǒng)頁面大小的倍數(shù)),然后將該空間用于單個大小的分配。不過,這里存在一個問題————
將大塊區(qū)域用于存儲同一大小的事物的模式很好用 [6],如果分配大小的數(shù)量是固定的,最好是少數(shù)幾個。那么在通用語言中,程序可以要求運(yùn)行時(shí)以任何大小分配內(nèi)存[7]。
例如,想象一下向運(yùn)行時(shí)請求 9 字節(jié)。9 字節(jié)是一個不常見的大小,因此可能需要一個新的尺寸類別來存儲 9 字節(jié)大小的物品。因?yàn)?9 字節(jié)大小的物品不常見,所以分配的其余部分(通常為 4KB 或更多)可能會被浪費(fèi)。由于尺寸類別的集合是固定的,如果沒有精確匹配的 size class 可用,分配將并入到下一個尺寸類別。在我們的示例中,9 字節(jié)可能會在 12 字節(jié)的尺寸類別中分配。未使用的 3 字節(jié)的開銷要比幾乎未使用的整個尺寸類別分配好。
總結(jié)一下
這是謎題的最后一塊拼圖。Go 1.15 版本沒有 24 字節(jié)的尺寸類別,因此 ss
的堆分配是在 32 字節(jié)的尺寸類別中分配的。由于 Martin M?hrmann 的工作,Go 1.16 版本有一個 24 字節(jié)的尺寸類別,非常適合分配給接口的切片值。