【深度知識(shí)】Go語(yǔ)言:?jiǎn)?dòng)和內(nèi)存分配初始化

本文是 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)存空間。它的工作流程如下所示:

  1. 如果分配的塊大于 64 KB, 則它直接從 OS 內(nèi)存中分配。
  2. 否則,找到一個(gè)永久分配器(persistent allocator)。
    • 每個(gè)永久分配器與一個(gè)進(jìn)程對(duì)應(yīng)。其主要是為了在永久分配器中使用鎖。因此,我們使用永久分配器時(shí)都是使用的當(dāng)前進(jìn)程的永久分配器。
    • 如果不能獲得當(dāng)前進(jìn)程的信息,則使用全局的分配器。
  3. 如果分配器已經(jīng)沒(méi)有足夠多的空閑內(nèi)存,則從 OS 申請(qǐng)更多的內(nèi)存。
  4. 從分配器的緩存中返回所請(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)存分配初始化

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Go語(yǔ)言內(nèi)置運(yùn)行時(shí)(就是runtime),拋棄了傳統(tǒng)的內(nèi)存分配方式,改為自主管理。這樣可以自主地實(shí)現(xiàn)更好的內(nèi)存使用...
    ddu_sw閱讀 1,547評(píng)論 0 5
  • 原文地址:C語(yǔ)言函數(shù)調(diào)用棧(一)C語(yǔ)言函數(shù)調(diào)用棧(二) 0 引言 程序的執(zhí)行過(guò)程可看作連續(xù)的函數(shù)調(diào)用。當(dāng)一個(gè)函數(shù)執(zhí)...
    小豬啊嗚閱讀 4,957評(píng)論 1 19
  • 官方文檔 初始化 Initialization是為準(zhǔn)備使用類,結(jié)構(gòu)體或者枚舉實(shí)例的一個(gè)過(guò)程。這個(gè)過(guò)程涉及了在實(shí)例里...
    hrscy閱讀 1,199評(píng)論 0 1
  • 一、溫故而知新 1. 內(nèi)存不夠怎么辦 內(nèi)存簡(jiǎn)單分配策略的問(wèn)題地址空間不隔離內(nèi)存使用效率低程序運(yùn)行的地址不確定 關(guān)于...
    SeanCST閱讀 8,116評(píng)論 0 27
  • 一、搜索引擎的算法經(jīng)歷了以下四大階段的變化: 1、人工目錄 主要用在2001年前,最早的雅虎模式,那時(shí)候SEO非常...
    王通專欄閱讀 350評(píng)論 0 0

友情鏈接更多精彩內(nèi)容