這兒幾個字節,那里幾個字節,我們說的是真正的內存
今天的帖子來自于最近的 Go 語言的一次小測試,觀察下面的測試基礎片段 [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
在原地對輸入進行排序,因此不會分配內存(或至少 43% 回答此問題的 Twitter 用戶是這么認為的)。然而,至少在 Go 的最近版本中,基準測試的每次迭代都會導致一次堆分配。為什么會是這種情況?
正如所有 Go 程序員應該知道的那樣,接口是以 雙詞結構 實現的。每個接口值包含一個字段,其中保存接口內容的類型,以及指向接口內容的指針。[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
可以容納一個機器字(在大多數情況下為 8 個字節),但一個 []string
卻需要 24 個字節:一個字用于指向切片的底層數組;一個字用于存儲切片的長度;另一個字用于存儲底層數組的剩余容量。那么,Go 是如何將 24 個字節裝入個 8 個字節的呢?通過編程中最古老的技巧,即間接引用。一個 []string
,即 s
,需要 24 個字節;但 *[]string
—— 即指向字符串切片的指針,只需要 8 個字節。
逃逸到堆
為了讓示例更加明確,以下是重新編寫的基準測試,不使用 sort.Strings
輔助函數:
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)
}
}
為了讓接口正常運行,編譯器將賦值重寫為 var si sort.Interface = &ss
,即 ss
的地址分配給接口值。[3] 我們現在有這么一種情況:出現一個持有指向 ss
的指針的接口值。它指向哪里?還有 ss
存儲在哪個內存位置?
似乎 ss
被移動到了堆上,這也同時導致了基準測試報告中的分配:
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 . . }
發生這種分配是因為編譯器當前無法確認 ss
比 si
生存期更長。Go 編譯器開發人員對此的普遍態度是,覺得 這個問題改進的余地,不過我們另找時間再議。事實上,ss
就是被分配到了堆上。因此,問題變成了:每次迭代會分配多少個字節?為什么不去詢問 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 字節。[4] 然而,在同一平臺先前的 Go 版本中,每次操作則消耗了 32 字節。
% 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 版本中即將推出的一項便利改進。不過在討論這個內容之前,我需要聊聊 “尺寸類別size class”。
尺寸類別
在解釋什么是 “尺寸類別size class” 之前,我們先考慮個問題,理論上的 Go 語言在運行時是如何在其堆上分配 24 字節的。有一個簡單的方法:追蹤目前為止已分配到的所有內存的動向——利用指向堆上最后分配的字節的指針。分配 24 字節,堆指針就會增加 24,然后將前一個值返回給調用函數。只要寫入的請求 24 字節的代碼不超出該標記的范圍,這種機制就沒有額外開銷。不過,現實情況下,內存分配器不僅要分配內存,有時還得釋放內存。
最終,Go 語言程序在運行時將釋放這些 24 字節,但從運行的視角來看,它只知道它給調用者的開始地址。它不知道從該地址起始之后又分配了多少字節。為了允許釋放內存,我們假設的 Go 語言程序運行時分配器必須記錄堆上每個分配的長度值。那么這些長度值的分配存儲在何處?當然是在堆上。
在我們的設想中,當程序運行需要分配內存的時候,它可以請求稍微多一點,并把它用來存儲請求的數量。而對于我們的切片示例而言,當我們請求 24 字節時,實際上會消耗 24 字節加上存儲數字 24
的一些開銷。這些開銷有多大?事實上,實際上的最小開銷量是一個字。[5]
用來記錄 24 字節分配的開銷將是 8 字節。25% 不是很大,但也不算糟糕,隨著分配的大小增加,開銷將變得微不足道。然而,如果我們只想在堆上存儲一個字節,會發生什么?開銷將是請求數據量的 8 倍!是否有一種更高效的方式在堆上分配少量內存?
與其在每個分配旁邊存儲長度,不如將相同大小的內容存儲在一起,這個主意如何?如果所有的 24 字節的內容都存儲在一起,那么運行時會自動獲取它們的大小。運行時所需要的是一個單一的位,指示 24 字節區域是否在使用中。在 Go 語言中,這些區域被稱為 Size Classes,因為相同大小的所有內容都會存儲在一起(類似學校班級,所有學生都按同一年級分班,而不是 C++ 中的類)。當運行時需要分配少量內存時,它會使用能夠容納該分配的最小的尺寸類別。
無限制的尺寸類別
現在我們知道尺寸類別是如何工作的了,那么問題又來了,它們存儲在哪里?和我們想的一樣,尺寸類別的內存來自堆。為了最小化開銷,運行時會從堆上分配較大的內存塊(通常是系統頁面大小的倍數),然后將該空間用于單個大小的分配。不過,這里存在一個問題————
將大塊區域用于存儲同一大小的事物的模式很好用 [6],如果分配大小的數量是固定的,最好是少數幾個。那么在通用語言中,程序可以要求運行時以任何大小分配內存[7]。
例如,想象一下向運行時請求 9 字節。9 字節是一個不常見的大小,因此可能需要一個新的尺寸類別來存儲 9 字節大小的物品。因為 9 字節大小的物品不常見,所以分配的其余部分(通常為 4KB 或更多)可能會被浪費。由于尺寸類別的集合是固定的,如果沒有精確匹配的 size class 可用,分配將并入到下一個尺寸類別。在我們的示例中,9 字節可能會在 12 字節的尺寸類別中分配。未使用的 3 字節的開銷要比幾乎未使用的整個尺寸類別分配好。
總結一下
這是謎題的最后一塊拼圖。Go 1.15 版本沒有 24 字節的尺寸類別,因此 ss
的堆分配是在 32 字節的尺寸類別中分配的。由于 Martin M?hrmann 的工作,Go 1.16 版本有一個 24 字節的尺寸類別,非常適合分配給接口的切片值。