
本文是 Golang 內(nèi)部機(jī)制探索系列博客的后續(xù)。這個(gè)系列博客的目的是探索 Go 啟動(dòng)過(guò)程,這個(gè)過(guò)程也是理解 Go 運(yùn)行時(shí)(runtime)的關(guān)鍵之處。本文中我們將一起去看看啟動(dòng)過(guò)程的第二個(gè)部分,分析參數(shù)是怎么被初始化的及其中有哪些函數(shù)調(diào)用等等。
啟動(dòng)順序
我們從上次結(jié)束的地方繼續(xù)。在 runtime.r0_to 函數(shù)中,我們還有一部分沒(méi)有分析:
CLD // convention is D is always left cleared
CALL runtime·check(SB)
MOVL 16(SP), AX // copy argc
MOVL AX, 0(SP)
MOVQ 24(SP), AX // copy argv
MOVQ AX, 8(SP)
CALL runtime·args(SB)
CALL runtime·osinit(SB)
CALL runtime·schedinit(SB)
第一條指令(CLD)清除 FLAGS 寄存器方向標(biāo)志。該標(biāo)志會(huì)影響到 string 處理時(shí)的方向。
接下來(lái)調(diào)用 runtime.check 函數(shù),這個(gè)函數(shù)對(duì)我們分析運(yùn)行時(shí)并沒(méi)什么太大的幫助。在該函數(shù)中,運(yùn)行時(shí)創(chuàng)建所有內(nèi)置類型的實(shí)例,檢查他們的大小及其它參數(shù)等。如果其中出了什么錯(cuò),就會(huì)產(chǎn)生 panic 錯(cuò)誤。請(qǐng)讀者自行閱讀這個(gè)函數(shù)的代碼。
參數(shù)分析
runtime.check 函數(shù)后調(diào)用 runtime.Args 函數(shù),這個(gè)函數(shù)更有意思一些。除了將參數(shù)(argc 和 argv )存儲(chǔ)到靜態(tài)變量中之外,在 Linux 系統(tǒng)上時(shí)它還會(huì)分析 處理 ELF 輔助向量以及初始化系統(tǒng)系統(tǒng)調(diào)用的地址。
這里需要解釋一下。操作系統(tǒng)將程序加載到內(nèi)存中時(shí),它會(huì)用一些預(yù)定義格式的數(shù)據(jù)初始化程序的初始棧。在棧頂就存儲(chǔ)著這些參數(shù)–指向環(huán)境變量的指針。在棧底,我們可以看到 “ELF 輔助向量”。事實(shí)上,這個(gè)輔助向量是一個(gè)記錄數(shù)組,這些記錄存儲(chǔ)著另外一些有用的信息,比如程序頭的數(shù)量和大小等。更多關(guān)于 ELF 輔助向量的內(nèi)容請(qǐng)參考這篇文章。
runtime.Args 函數(shù)負(fù)責(zé)處理這個(gè)向量。在輔助向量存儲(chǔ)的所有信息中,運(yùn)行時(shí)只關(guān)心 startupRandomData,它主要用來(lái)初始化哈希函數(shù)以及指向系統(tǒng)調(diào)用位置的指針。在這里初始化了以下這些變量:
__vdso_time_sym
__vdso_gettimeofday_sym
__vdso_clock_gettime_sym
它們用于在不同的函數(shù)中獲取當(dāng)前時(shí)間。所有這些變量都有其默認(rèn)值。這允許 Golang 使用 vsyscall 機(jī)制調(diào)用相應(yīng)的函數(shù)。
runtime.osinit 函數(shù)
在啟動(dòng)過(guò)程中接下來(lái)調(diào)用的是 runtime.osinit 函數(shù)。在 Linux 系統(tǒng)上,這個(gè)函數(shù)唯 一做的事就是初始化 ncpu 變量,這個(gè)變量存儲(chǔ)了當(dāng)前系統(tǒng)的 CPU 的數(shù)量。這是通過(guò)一個(gè)系統(tǒng)調(diào)用來(lái)實(shí)現(xiàn)的。
runtime.schedinit 函數(shù)
接下便調(diào)用了 runtime.schedinit 函數(shù),這個(gè)函數(shù)比較有意思。首先,它獲得當(dāng)前 goroutine 的指針,該指針指向一個(gè) g 結(jié)構(gòu)體。在討論 TLS 實(shí)現(xiàn)的時(shí)候,我們就已經(jīng)討論過(guò)這個(gè)指針是如何存儲(chǔ)的。接下來(lái),它會(huì)調(diào)用 runtime.raceinit。這里我們不會(huì)討論 runtime.raceinit 函數(shù),因?yàn)檎G闆r下競(jìng)爭(zhēng)條件(race condition)被禁止時(shí),這個(gè)函數(shù)是不會(huì)被調(diào)用的。隨后,runtime.schedinit 函數(shù)中還會(huì)調(diào)用另外一些初始化函數(shù)。
讓我們依次來(lái)看一下。
初始化 traceback
runtime.tracebackinit 負(fù)責(zé)初始化 traceback。traceback 是一個(gè)函數(shù)棧。這些函數(shù)會(huì)在我們到達(dá)當(dāng)前執(zhí)行點(diǎn)之前被調(diào)用。舉個(gè)例子,每次產(chǎn)生一個(gè) panic 時(shí)我們都可以看到它們。 Traceback 是通過(guò)調(diào)用 runtime.gentraceback 函數(shù)產(chǎn)生的。要讓這個(gè)函數(shù)工作, 我們需要知道一些內(nèi)置函數(shù)的地址(例如,因?yàn)槲覀儾幌M鼈儽话?traceback 中)。runtime.traceback 就負(fù)責(zé)初始化這些地址。
驗(yàn)證鏈接器符號(hào)
鏈接器符號(hào)是由鏈接器產(chǎn)生輸出到可執(zhí)行目標(biāo)文件中的數(shù)據(jù)。其中大部分?jǐn)?shù)據(jù)已經(jīng)在《Go語(yǔ)言內(nèi)幕(3):鏈接器、鏈接器、重定位》中討論過(guò)了。在運(yùn)行時(shí)包中,鏈接器符號(hào)被映射到 moduledata 結(jié)構(gòu)體。 runtime.moduledataverify 函數(shù)負(fù)責(zé)檢查這些數(shù)據(jù),以確保所有結(jié)構(gòu)體的正確性。
初始化棧池
要想搞明白接下來(lái)這個(gè)步驟,你需要了解一點(diǎn) Go 中棧增長(zhǎng)的實(shí)現(xiàn)方法。當(dāng)一個(gè)新的 goroutine 被生成時(shí),系統(tǒng)會(huì)為其分配一個(gè)較小的固定大小的棧。當(dāng)棧達(dá)到某個(gè)閾值時(shí),棧的大小會(huì)增大一倍并將原來(lái)?xiàng)V械臄?shù)據(jù)全部拷貝到新的棧中。
還有許多細(xì)節(jié),比如如何判斷是否達(dá)到閾值,Go 如何調(diào)整棧中的指針等。在前面的博客中介紹 stackguard0 與函數(shù)元數(shù)據(jù)時(shí),我已經(jīng)介紹了部分相關(guān)的內(nèi)容。更多的內(nèi)容,你可以參考這篇文檔。
Go 用棧池來(lái)緩存暫時(shí)不用的棧。這個(gè)棧池實(shí)際上就是一個(gè)由 runtime.stackinit 函數(shù)初始化的數(shù)組。這個(gè)數(shù)組中的每一項(xiàng)是一個(gè)包含相同大小棧的鏈表。
這一步還初始化了另外一個(gè)變量 runtime.stackFreeQueue。這個(gè)變量也存儲(chǔ)了一個(gè)棧的鏈表,但是這些棧都是在垃圾回收時(shí)加入的,并且回收結(jié)束時(shí)會(huì)被清空。注意,只有大小為 2 KB,4 KB,8 KB,以及 16 KB 的棧才能會(huì)被緩存。更大的棧則會(huì)直接分配。
初始化內(nèi)存分配器
內(nèi)存分配的過(guò)程在這篇源代碼注解有詳細(xì)的介紹。如果你想搞明白 Go 內(nèi)存分配是如何工作的話,我強(qiáng)烈建議你去閱讀該文檔。關(guān)于內(nèi)存分配的內(nèi)容,我會(huì)在后面的博客中詳細(xì)分析。內(nèi)存分配器的初始化在 runtime.mallocinit 函數(shù)中完成的,所以讓我們仔細(xì)看一下這個(gè)函數(shù)。
初始化大小類
我們可以看到 runtime.mallocinit 函數(shù)做的第一件事就是調(diào)用另外一個(gè)函數(shù)– initSizes。這個(gè)函數(shù)用于計(jì)算大小類。但是,每一個(gè)類應(yīng)該多大呢?分配小對(duì)象(小于 32 KB)時(shí),Go 運(yùn)行時(shí)先將大小調(diào)整為運(yùn)行時(shí)既定義的類的大小。因此分配的內(nèi)存塊的大小只可能是既定義的幾個(gè)大小之一。通常情況下,分配的內(nèi)存會(huì)比請(qǐng)求的內(nèi)存大小更大。這會(huì)導(dǎo)致小部分內(nèi)存的浪費(fèi),但是這可以讓我們更好地復(fù)用這些內(nèi)存塊。
initSizes 函數(shù)負(fù)責(zé)計(jì)算這些類的大小。在這個(gè)函數(shù)開(kāi)始處,我們可以以看到如下的代碼:
align := 8
for size := align; size <= _MaxSmallSize; size += align {
if size&(size-1) == 0 {
if size >= 2048 {
align = 256
} else if size >= 128 {
align = size / 8
} else if size >= 16 {
align = 16
…
}
}
我們可以看到最小的兩個(gè)類的大小分別是 8 字節(jié)與 16 字節(jié)。隨后每遞增 16 字節(jié)為一個(gè)新的類一直到 128 字節(jié)。從 128 字節(jié)到 2048 字節(jié),類的大小每次增加 size/8 字節(jié)。2048 字節(jié)后,每遞增 256 字節(jié)為一個(gè)新類。
initSize 方法會(huì)初始化 class_to_size 數(shù)組,該數(shù)組用于將類(這里指其在全局類列表中的索引值)映射為其所占內(nèi)存空間的大小。initSize 方法還會(huì)初始化 class_to_allocnpages。這個(gè)數(shù)組存儲(chǔ)對(duì)于指定類的對(duì)象需要多大的存儲(chǔ)空間。除此之外,size_to_class8 與 size_to_class128 兩個(gè)數(shù)組也是在這個(gè)方法中初始化的。這兩個(gè)數(shù)組用于根據(jù)對(duì)象的大小得出相應(yīng)的類的索引。前者用于大小小于 1 KB 的對(duì)象,后者用于 1 – 32 KB 大小的對(duì)象。
虛擬內(nèi)存的預(yù)約
下面,我們會(huì)一起看看虛擬內(nèi)存預(yù)約函數(shù) mallocinit,此函數(shù)會(huì)提前從操作系統(tǒng)分配一部分內(nèi)存用于未來(lái)的內(nèi)存分配。讓我們看一下它在 x64 架構(gòu)下是如何工作的。首先,我們需要初始化下面的變量:
pSize = bitmapSize + spansSize + arenaSize + _PageSize
p = uintptr(sysReserve(unsafe.Pointer(p), pSize, &reserved))
- bitmapSize 對(duì)應(yīng)于垃圾收集器位圖所需的內(nèi)存的大小。垃圾收集器的位圖是一塊特殊的內(nèi)存,該內(nèi)存標(biāo)明了內(nèi)存中哪些位置是指針哪些位置是對(duì)象,以方便垃圾收集器釋放。這塊空間由垃圾收集器管理。對(duì)于每個(gè)分配的字節(jié),我們需要兩個(gè)比特存儲(chǔ)信息,這也就是為什么位圖所需內(nèi)存大小的計(jì)算式為:arenaSize / (ptrSize * 8 / 4)
- spanSize 表示存儲(chǔ)指向 memory span 的指針數(shù)組所需內(nèi)存空間大小。所謂 memory span 是指一種將內(nèi)存塊封裝以便分配給對(duì)象的數(shù)組結(jié)構(gòu)。
上述所有變量計(jì)算出來(lái)后,就可以完成真正的資源預(yù)留的工作了:
pSize = bitmapSize + spansSize + arenaSize + _PageSize
p = uintptr(sysReserve(unsafe.Pointer(p), pSize, &reserved))
最后,我們初始化全局變量 mheap。這個(gè)變量用于集中存儲(chǔ)內(nèi)存相關(guān)的對(duì)象。
p1 := round(p, _PageSize)
mheap_.spans = (**mspan)(unsafe.Pointer(p1))
mheap_.bitmap = p1 + spansSize
mheap_.arena_start = p1 + (spansSize + bitmapSize)
mheap_.arena_used = mheap_.arena_start
mheap_.arena_end = p + pSize
mheap_.arena_reserved = reserved
注意,初始始 mheap_.arena_used 的值與 mheap_.arena_start 相等,這是因?yàn)檫€沒(méi)有為任何對(duì)象分配空間。
初始化堆
接下來(lái),調(diào)用 mHeap_Init 函數(shù)來(lái)初始化堆。該函數(shù)所做的第一件事就是初始化分配器。
fixAlloc_Init(&h.spanalloc, unsafe.Sizeof(mspan{}), recordspan, unsafe.Pointer(h), &memstats.mspan_sys)
fixAlloc_Init(&h.cachealloc, unsafe.Sizeof(mcache{}), nil, nil, &memstats.mcache_sys)
fixAlloc_Init(&h.specialfinalizeralloc, unsafe.Sizeof(specialfinalizer{}), nil, nil, &memstats.other_sys)
fixAlloc_Init(&h.specialprofilealloc, unsafe.Sizeof(specialprofile{}), nil, nil, &memstats.other_sys)
為了更好的理解分配器,讓我們先看一看是如何使用它的。每當(dāng)我們希望分配新的 mspan、mcache、specialfinalizer 或者 specialprofile 結(jié)構(gòu)體時(shí),都可以通過(guò) fixAlloc_Alloc 函數(shù)來(lái)調(diào)用分配器。 此函數(shù)的主要部分如下:
if uintptr(f.nchunk) < f.size {
f.chunk = (*uint8)(persistentalloc(_FixAllocChunk, 0, f.stat))
f.nchunk = _FixAllocChunk
}
它會(huì)分配一塊內(nèi)存,但是它并不是按結(jié)構(gòu)體的實(shí)際大小(f.size)進(jìn)行分配,而是直接留出 _FixAllocChunk (目前是 16 KB)大小的空間。多余的存儲(chǔ)空間存儲(chǔ)在分配器中。當(dāng)下一次再為相同的結(jié)構(gòu)體分配空間時(shí),就勿需再調(diào)用耗時(shí)的 persistentcalloc 操作。
persistentalloc 函數(shù)用于分配不會(huì)被垃圾回收的內(nèi)存空間。它的工作流程如下所示:
- 如果分配的塊大于 64 KB, 則它直接從 OS 內(nèi)存中分配。
- 否則,找到一個(gè)永久分配器(persistent allocator)。
- 每個(gè)永久分配器與一個(gè)進(jìn)程對(duì)應(yīng)。其主要是為了在永久分配器中使用鎖。因此,我們使用永久分配器時(shí)都是使用的當(dāng)前進(jìn)程的永久分配器。
- 如果不能獲得當(dāng)前進(jìn)程的信息,則使用全局的分配器。
- 如果分配器已經(jīng)沒(méi)有足夠多的空閑內(nèi)存,則從 OS 申請(qǐng)更多的內(nèi)存。
- 從分配器的緩存中返回所請(qǐng)求大小的內(nèi)存。
persistentalloc 與 fixAlloc_Alloc 函數(shù)的工作機(jī)制是非常相似的??梢哉f(shuō),這些函數(shù)實(shí)現(xiàn)了一個(gè)兩級(jí)的緩存機(jī)制。你應(yīng)該可以意識(shí)到 persitentalloc 函數(shù)不僅僅只在 fixAlloc_Alloc 函數(shù)中使用,在其它很多使用永久內(nèi)存的地方都會(huì)用到它。
讓我們?cè)倩氐?mHeap_Init 函數(shù)中。一個(gè)亟需回答的問(wèn)題是在函數(shù)開(kāi)始時(shí)初始化的四個(gè)結(jié)構(gòu)體到底有什么用:
- mspan 只是那些應(yīng)該被垃圾回收的內(nèi)存塊的一個(gè)包裝。在前面討論內(nèi)存大小分類時(shí),我們已討論過(guò)它了。當(dāng)創(chuàng)建一個(gè)特定大小類別的對(duì)象時(shí)就會(huì)創(chuàng)建一個(gè) mspan。
- mcache 是每個(gè)進(jìn)程相關(guān)的結(jié)構(gòu)體。它負(fù)責(zé)緩存擴(kuò)展。每外進(jìn)程擁有獨(dú)立的 mcache 主要是為了避免使用鎖。
- specialfinalizeralloc 是在 runtime.SetFinalizer 函數(shù)調(diào)用時(shí)分配的結(jié)構(gòu)體,而這個(gè)函數(shù)是在我們希望系統(tǒng)在對(duì)象結(jié)束時(shí)執(zhí)行某些清理代碼的時(shí)候調(diào)用的。例如,os.NewFile 函數(shù)就會(huì)為每個(gè)新文件關(guān)聯(lián)一個(gè) finalizer。而這個(gè) finalizer 負(fù)責(zé)關(guān)閉系統(tǒng)的文件描述符。
- specialprofilealloc 是在內(nèi)存分析器中使用的一個(gè)結(jié)構(gòu)體。
初始化內(nèi)存分配器后,mHeap_Initfunction 會(huì)調(diào)用 mSpanList_Init 函數(shù)初始化鏈表。這個(gè)過(guò)程非常的簡(jiǎn)單,它所做的所有初始化工作僅僅是初始化鏈表的入口結(jié)點(diǎn)。mheap 結(jié)構(gòu)體包含多個(gè)這樣的鏈表。
- mheap.free 與 mheap.busy 數(shù)組用于存儲(chǔ)大對(duì)象的空閑鏈表(大對(duì)象指大于 32 KB 而小于 1 MB 的對(duì)象)。每個(gè)可能的大小都在數(shù)組中都有一個(gè)對(duì)應(yīng)的項(xiàng)。在這里,大小是用頁(yè)來(lái)衡量的,每個(gè)頁(yè)的大小為 32 KB。也就是說(shuō),數(shù)組中的第一項(xiàng)鏈表管理大小為 32 KB 的內(nèi)存塊,第二個(gè)項(xiàng)的管理 64 KB 的內(nèi)存塊,依次類推。
- mheap.freelarge 與 mheap.busylarge 是大小于 1 MB 對(duì)象空間的空閑與忙鏈表。
接下來(lái)就是初始化 mheap.central,該變量管理所有存儲(chǔ)小對(duì)象(小于 32 KB)的內(nèi)存塊。mheap.central 中,鏈表根據(jù)其管理內(nèi)存塊的大小進(jìn)行分組。初始化過(guò)程與前面看到的非常類似,初始化過(guò)程中只是將所有空閑鏈表進(jìn)行初始化。
初始化緩存
現(xiàn)在,我們幾乎已完成了所有內(nèi)存分配器的初始化。mallocinit 函數(shù)中剩下的最后一件事就是 mcache 的初始化了:
g := getg()
g.m.mcache = allocmcache()
首先獲得當(dāng)前的協(xié)程。每個(gè) goroutine 都包含一個(gè)指向 m 結(jié)構(gòu)體的指針。該結(jié)構(gòu)體對(duì)操作系統(tǒng)線程進(jìn)行了包裝。在這個(gè)結(jié)構(gòu)體的 mcache 域就是在這幾行代碼中初始化的。 allomcache 函數(shù)調(diào)用 fixAlloc_Alloc 初始化新的 mcache 結(jié)構(gòu)體。我們已經(jīng)討論過(guò)了該結(jié)構(gòu)體的分配以及其含義了。
細(xì)心的讀者可能注意到我前面說(shuō)每個(gè) mcache 與一個(gè)進(jìn)程關(guān)聯(lián),但是我們現(xiàn)在又說(shuō)它與 m 結(jié)構(gòu)體關(guān)聯(lián),而 m 結(jié)構(gòu)體是與 OS 進(jìn)程相關(guān)聯(lián),而非一個(gè)處理器。這并不是一個(gè)錯(cuò)誤,mcache 只有在進(jìn)程正在執(zhí)行時(shí)才會(huì)初始化,而每當(dāng)進(jìn)程切換后它也重新切換為另外一個(gè)線程 m 結(jié)構(gòu)體。
更多關(guān)于 Go 啟動(dòng)過(guò)程
再接下來(lái)的博客中,我們會(huì)繼續(xù)討論啟動(dòng)過(guò)程中的垃圾收集器的初始化過(guò)程以及主 goroutine 是如何啟動(dòng)的。同時(shí),歡迎大家積極在博客中評(píng)論。
本文來(lái)自:伯樂(lè)在線
感謝作者:伯樂(lè)在線
查看原文:Go語(yǔ)言內(nèi)幕(6):?jiǎn)?dòng)和內(nèi)存分配初始化