圖解 Go 內(nèi)存管理器的內(nèi)存分配策略
關(guān)于Go的內(nèi)存分配
在Go語(yǔ)言里,從內(nèi)存的分配到不再使用后內(nèi)存的回收等等這些內(nèi)存管理工作都是由Go在底層完成的。雖然開(kāi)發(fā)者在寫(xiě)代碼時(shí)不必過(guò)度關(guān)心內(nèi)存從分配到回收這個(gè)過(guò)程,但是Go的內(nèi)存分配策略里有不少有意思的設(shè)計(jì),通過(guò)了解他們有助于我們自身的提高,也讓我們能寫(xiě)出更高效的Go程序。
Go內(nèi)存管理的設(shè)計(jì)旨在在并發(fā)環(huán)境中快速運(yùn)行,并與垃圾回收器集成在一起。讓我們看一個(gè)簡(jiǎn)單的示例:
package?main
type?smallStruct?struct?{
???a,?b?int64
???c,?d?float64
}
func?main()?{
???smallAllocation()
}
//go:noinline
func?smallAllocation()?*smallStruct?{
???return?&smallStruct{}
}
函數(shù)上面的注釋//go:noinline將禁止Go對(duì)該函數(shù)進(jìn)行內(nèi)聯(lián),這樣main函數(shù)就會(huì)使用smallAllocation函數(shù)返回的指針變量,因?yàn)楸欢鄠€(gè)函數(shù)使用,返回的這個(gè)變量將被分配到堆上。
關(guān)于內(nèi)聯(lián)的概念之前的文章有說(shuō)過(guò):
內(nèi)聯(lián)是一種手動(dòng)或編譯器優(yōu)化,用于將簡(jiǎn)短函數(shù)的調(diào)用替換為函數(shù)體本身。這么做的原因是它可以消除函數(shù)調(diào)用本身的開(kāi)銷(xiāo),也使得編譯器能更高效地執(zhí)行其他的優(yōu)化策略。
所以如果上面的例子不干預(yù)編譯器的話(huà),編譯器通過(guò)內(nèi)聯(lián)將smallAllocation函數(shù)體里的內(nèi)容直接放到main函數(shù)里,這樣就不會(huì)產(chǎn)生smallAllocation這個(gè)函數(shù)的調(diào)用了,所有的變量都是main函數(shù)內(nèi)這個(gè)范圍使用的,也就不在需要將變量往堆上分配了。
繼續(xù)說(shuō)上面那個(gè)例子,通過(guò)逃逸分析命令 go tool compile ?-m main.go 可以確認(rèn)我們上面的分析,&smallStruct{}會(huì)被分配到堆上去。
??go?tool?compile?-m?main.go
main.go:12:6:?can?inline?main
main.go:10:9:?&smallStruct?literal?escapes?to?heap
借助命令go tool compile -S main.go,可以顯示該程序的匯編代碼,也可以明確地向我們展示內(nèi)存的分配:
0x001d?00029?(main.go:10)???????LEAQ????type."".smallStruct(SB),?AX
0x0024?00036?(main.go:10)???????PCDATA??$2,?$0
0x0024?00036?(main.go:10)???????MOVQ????AX,?(SP)
0x0028?00040?(main.go:10)???????CALL????runtime.newobject(SB)
內(nèi)置函數(shù)newobject會(huì)通過(guò)調(diào)用另外一個(gè)內(nèi)置函數(shù)mallocgc在堆上分配新內(nèi)存。在Go里面有兩種內(nèi)存分配策略,一種適用于程序里小內(nèi)存塊的申請(qǐng),另一種適用于大內(nèi)存塊的申請(qǐng),大內(nèi)存塊指的是大于32KB。
下面我們來(lái)細(xì)聊一下這兩種策略。
小于32KB內(nèi)存塊的分配策略
當(dāng)程序里發(fā)生了32kb以下的小塊內(nèi)存申請(qǐng)時(shí),Go會(huì)從一個(gè)叫做的mcache的本地緩存給程序分配內(nèi)存。這個(gè)本地緩存mcache持有一系列的大小為32kb的內(nèi)存塊,這樣的一個(gè)內(nèi)存塊里叫做mspan,它是要給程序分配內(nèi)存時(shí)的分配單元。

在Go的調(diào)度器模型里,每個(gè)線(xiàn)程M會(huì)綁定給一個(gè)處理器P,在單一粒度的時(shí)間里只能做多處理運(yùn)行一個(gè)goroutine,每個(gè)P都會(huì)綁定一個(gè)上面說(shuō)的本地緩存mcache。當(dāng)需要進(jìn)行內(nèi)存分配時(shí),當(dāng)前運(yùn)行的goroutine會(huì)從mcache中查找可用的mspan。從本地mcache里分配內(nèi)存時(shí)不需要加鎖,這種分配策略效率更高。
那么有人就會(huì)問(wèn)了,有的變量很小就是數(shù)字,有的卻是一個(gè)復(fù)雜的結(jié)構(gòu)體,申請(qǐng)內(nèi)存時(shí)都分給他們一個(gè)mspan這樣的單元會(huì)不會(huì)產(chǎn)生浪費(fèi)。其實(shí)mcache持有的這一系列的mspan并不都是統(tǒng)一大小的,而是按照大小,從8字節(jié)到32KB分了大概70類(lèi)的msapn。

就文章開(kāi)始的那個(gè)例子來(lái)說(shuō),那個(gè)結(jié)構(gòu)體的大小是32字節(jié),正好32字節(jié)的這種mspan能滿(mǎn)足需求,那么分配內(nèi)存的時(shí)候就會(huì)給它分配一個(gè)32字節(jié)大小的mspan。

現(xiàn)在,我們可能會(huì)好奇,如果分配內(nèi)存時(shí)mcachce里沒(méi)有空閑的32字節(jié)的mspan了該怎么辦?Go里還為每種類(lèi)別的mspan維護(hù)著一個(gè)mcentral。
mcentral的作用是為所有mcache提供切分好的mspan資源。每個(gè)central會(huì)持有一種特定大小的全局mspan列表,包括已分配出去的和未分配出去的。每個(gè)mcentral對(duì)應(yīng)一種mspan,當(dāng)工作線(xiàn)程的mcache中沒(méi)有合適(也就是特定大小的)的mspan時(shí)就會(huì)從mcentral 去獲取。mcentral被所有的工作線(xiàn)程共同享有,存在多個(gè)goroutine競(jìng)爭(zhēng)的情況,因此從mcentral獲取資源時(shí)需要加鎖。
mcentral的定義如下:
//runtime/mcentral.go
type?mcentral?struct?{
????//?互斥鎖
????lock?mutex?
????
????//?規(guī)格
????sizeclass?int32?
????
????//?尚有空閑object的mspan鏈表
????nonempty?mSpanList?
????
????//?沒(méi)有空閑object的mspan鏈表,或者是已被mcache取走的msapn鏈表
????empty?mSpanList?
????
????//?已累計(jì)分配的對(duì)象個(gè)數(shù)
????nmalloc?uint64?
}
mcentral里維護(hù)著兩個(gè)雙向鏈表,nonempty表示鏈表里還有空閑的mspan待分配。empty表示這條鏈表里的mspan都被分配了object。

如果上面我們那個(gè)程序申請(qǐng)內(nèi)存的時(shí)候,mcache里已經(jīng)沒(méi)有合適的空閑mspan了,那么工作線(xiàn)程就會(huì)像下圖這樣去mcentral里去申請(qǐng)。
簡(jiǎn)單說(shuō)下mcache從mcentral獲取和歸還mspan的流程:
獲取 加鎖;從 nonempty鏈表找到一個(gè)可用的mspan;并將其從nonempty鏈表刪除;將取出的mspan加入到empty鏈表;將mspan返回給工作線(xiàn)程;解鎖。歸還 加鎖;將 mspan從empty鏈表刪除;將mspan加入到nonempty鏈表;解鎖。

當(dāng)mcentral沒(méi)有空閑的mspan時(shí),會(huì)向mheap申請(qǐng)。而mheap沒(méi)有資源時(shí),會(huì)向操作系統(tǒng)申請(qǐng)新內(nèi)存。mheap主要用于大對(duì)象的內(nèi)存分配,以及管理未切割的mspan,用于給mcentral切割成小對(duì)象。

同時(shí)我們也看到,mheap中含有所有規(guī)格的mcentral,所以,當(dāng)一個(gè)mcache從mcentral申請(qǐng)mspan時(shí),只需要在獨(dú)立的mcentral中使用鎖,并不會(huì)影響申請(qǐng)其他規(guī)格的mspan。
上面說(shuō)了每種尺寸的mspan都有一個(gè)全局的列表存放在mcentral里供所有線(xiàn)程使用,所有mcentral的集合則是存放于mheap中的。mheap里的arena 區(qū)域是真正的堆區(qū),運(yùn)行時(shí)會(huì)將 8KB 看做一頁(yè),這些內(nèi)存頁(yè)中存儲(chǔ)了所有在堆上初始化的對(duì)象。運(yùn)行時(shí)使用二維的 runtime.heapArena 數(shù)組管理所有的內(nèi)存,每個(gè) runtime.heapArena 都會(huì)管理 64MB 的內(nèi)存。

如果 arena 區(qū)域沒(méi)有足夠的空間,會(huì)調(diào)用 runtime.mheap.sysAlloc 從操作系統(tǒng)中申請(qǐng)更多的內(nèi)存。
大于32KB內(nèi)存塊的分配策略
Go沒(méi)法使用工作線(xiàn)程的本地緩存mcache和全局中心緩存mcentral上管理超過(guò)32KB的內(nèi)存分配,所以對(duì)于那些超過(guò)32KB的內(nèi)存申請(qǐng),會(huì)直接從堆上(mheap)上分配對(duì)應(yīng)的數(shù)量的內(nèi)存頁(yè)(每頁(yè)大小是8KB)給程序。

總結(jié)
我們把內(nèi)存分配管理涉及的所有概念串起來(lái),可以勾畫(huà)出Go內(nèi)存管理的一個(gè)全局視圖:

Go語(yǔ)言的內(nèi)存分配非常復(fù)雜,這個(gè)文章從一個(gè)比較粗的角度來(lái)看Go的內(nèi)存分配,并沒(méi)有深入細(xì)節(jié)。一般而言,了解它的原理,到這個(gè)程度也就可以了(應(yīng)付面試)。
總結(jié)起來(lái)關(guān)于Go內(nèi)存分配管理的策略有如下幾點(diǎn):
Go在程序啟動(dòng)時(shí),會(huì)向操作系統(tǒng)申請(qǐng)一大塊內(nèi)存,由 mheap結(jié)構(gòu)全局管理。Go內(nèi)存管理的基本單元是 mspan,每種mspan可以分配特定大小的object。mcache,mcentral,mheap是Go內(nèi)存管理的三大組件,mcache管理線(xiàn)程在本地緩存的mspan;mcentral管理全局的mspan供所有線(xiàn)程使用;mheap管理Go的所有動(dòng)態(tài)分配內(nèi)存。一般小對(duì)象通過(guò) mspan分配內(nèi)存;大對(duì)象則直接由mheap分配內(nèi)存。
相關(guān)閱讀
上周并發(fā)題的解題思路以及介紹Go語(yǔ)言調(diào)度器
參考鏈接
Memory Management and Allocation[1]
圖解Go語(yǔ)言?xún)?nèi)存分配[2]
內(nèi)存分配器[3]
參考資料
Memory Management and Allocation: https://medium.com/a-journey-with-go/go-memory-management-and-allocation-a7396d430f44
[2]圖解Go語(yǔ)言?xún)?nèi)存分配: https://juejin.im/post/6844903795739082760#heading-7
[3]內(nèi)存分配器: https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator/
關(guān)注公眾號(hào),獲取更多精選技術(shù)原創(chuàng)文章
