Go語言內幕(6):啟動和記憶體分配初始化

yhx發表於2016-04-27

本文是 Golang 內部機制探索系列部落格的後續。這個系列部落格的目的是探索 Go 啟動過程,這個過程也是理解 Go 執行時(runtime)的關鍵之處。本文中我們將一起去看看啟動過程的第二個部分,分析引數是怎麼被初始化的及其中有哪些函式呼叫等等。

啟動順序

我們從上次結束的地方繼續。在 runtime.r0_to 函式中,我們還有一部分沒有分析:

第一條指令(CLD)清除 FLAGS 暫存器方向標誌。該標誌會影響到 string 處理時的方向。

接下來呼叫 runtime.check 函式,這個函式對我們分析執行時並沒什麼太大的幫助。在該函式中,執行時建立所有內建型別的例項,檢查他們的大小及其它引數等。如果其中出了什麼錯,就會產生 panic 錯誤。請讀者自行閱讀這個函式的程式碼。

引數分析

runtime.check 函式後呼叫 runtime.Args 函式,這個函式更有意思一些。除了將引數(argc 和 argv )儲存到靜態變數中之外,在 Linux 系統上時它還會分析 處理 ELF 輔助向量以及初始化系統系統呼叫的地址。

這裡需要解釋一下。作業系統將程式載入到記憶體中時,它會用一些預定義格式的資料初始化程式的初始棧。在棧頂就儲存著這些引數–指向環境變數的指標。在棧底,我們可以看到 “ELF 輔助向量”。事實上,這個輔助向量是一個記錄陣列,這些記錄儲存著另外一些有用的資訊,比如程式頭的數量和大小等。更多關於 ELF 輔助向量的內容請參考這篇文章

runtime.Args 函式負責處理這個向量。在輔助向量儲存的所有資訊中,執行時只關心 startupRandomData,它主要用來初始化雜湊函式以及指向系統呼叫位置的指標。在這裡初始化了以下這些變數:

它們用於在不同的函式中獲取當前時間。所有這些變數都有其預設值。這允許 Golang 使用 vsyscall 機制呼叫相應的函式。

runtime.osinit 函式

在啟動過程中接下來呼叫的是 runtime.osinit 函式。在 Linux 系統上,這個函式唯 一做的事就是初始化 ncpu 變數,這個變數儲存了當前系統的 CPU 的數量。這是通過一個系統呼叫來實現的。

runtime.schedinit 函式

接下便呼叫了 runtime.schedinit 函式,這個函式比較有意思。首先,它獲得當前 goroutine 的指標,該指標指向一個 g 結構體。在討論 TLS 實現的時候,我們就已經討論過這個指標是如何儲存的。接下來,它會呼叫 runtime.raceinit。這裡我們不會討論 runtime.raceinit 函式,因為正常情況下競爭條件(race condition)被禁止時,這個函式是不會被呼叫的。隨後,runtime.schedinit 函式中還會呼叫另外一些初始化函式。

讓我們依次來看一下。

初始化 traceback

runtime.tracebackinit 負責初始化 traceback。traceback 是一個函式棧。這些函式會在我們到達當前執行點之前被呼叫。舉個例子,每次產生一個 panic 時我們都可以看到它們。 Traceback 是通過呼叫 runtime.gentraceback 函式產生的。要讓這個函式工作, 我們需要知道一些內建函式的地址(例如,因為我們不希望它們被包含到 traceback 中)。runtime.traceback 就負責初始化這些地址。

驗證連結器符號

連結器符號是由連結器產生輸出到可執行目標檔案中的資料。其中大部分資料已經在《Go語言內幕(3):連結器、連結器、重定位》中討論過了。在執行時包中,連結器符號被對映到 moduledata 結構體。 runtime.moduledataverify 函式負責檢查這些資料,以確保所有結構體的正確性。

初始化棧池

要想搞明白接下來這個步驟,你需要了解一點 Go 中棧增長的實現方法。當一個新的 goroutine 被生成時,系統會為其分配一個較小的固定大小的棧。當棧達到某個閾值時,棧的大小會增大一倍並將原來棧中的資料全部拷貝到新的棧中。

還有許多細節,比如如何判斷是否達到閾值,Go 如何調整棧中的指標等。在前面的部落格中介紹 stackguard0 與函式後設資料時,我已經介紹了部分相關的內容。更多的內容,你可以參考這篇文件

Go 用棧池來快取暫時不用的棧。這個棧池實際上就是一個由 runtime.stackinit 函式初始化的陣列。這個陣列中的每一項是一個包含相同大小棧的連結串列。

這一步還初始化了另外一個變數 runtime.stackFreeQueue。這個變數也儲存了一個棧的連結串列,但是這些棧都是在垃圾回收時加入的,並且回收結束時會被清空。注意,只有大小為 2 KB,4 KB,8 KB,以及 16 KB 的棧才能會被快取。更大的棧則會直接分配。

初始化記憶體分配器

記憶體分配的過程在這篇原始碼註解有詳細的介紹。如果你想搞明白 Go 記憶體分配是如何工作的話,我強烈建議你去閱讀該文件。關於記憶體分配的內容,我會在後面的部落格中詳細分析。記憶體分配器的初始化在 runtime.mallocinit 函式中完成的,所以讓我們仔細看一下這個函式。

初始化大小類

我們可以看到 runtime.mallocinit 函式做的第一件事就是呼叫另外一個函式– initSizes。這個函式用於計算大小類。但是,每一個類應該多大呢?分配小物件(小於 32 KB)時,Go 執行時先將大小調整為執行時既定義的類的大小。因此分配的記憶體塊的大小隻可能是既定義的幾個大小之一。通常情況下,分配的記憶體會比請求的記憶體大小更大。這會導致小部分記憶體的浪費,但是這可以讓我們更好地複用這些記憶體塊。

initSizes 函式負責計算這些類的大小。在這個函式開始處,我們可以以看到如下的程式碼:

我們可以看到最小的兩個類的大小分別是 8 位元組與 16 位元組。隨後每遞增 16 位元組為一個新的類一直到 128 位元組。從 128 位元組到 2048 位元組,類的大小每次增加 size/8 位元組。2048 位元組後,每遞增 256 位元組為一個新類。

initSize 方法會初始化 class_to_size 陣列,該陣列用於將類(這裡指其在全域性類列表中的索引值)對映為其所佔記憶體空間的大小。initSize 方法還會初始化 class_to_allocnpages。這個陣列儲存對於指定類的物件需要多大的儲存空間。除此之外,size_to_class8 與 size_to_class128 兩個陣列也是在這個方法中初始化的。這兩個陣列用於根據物件的大小得出相應的類的索引。前者用於大小小於 1 KB 的物件,後者用於 1 – 32 KB 大小的物件。

虛擬記憶體的預約

下面,我們會一起看看虛擬記憶體預約函式 mallocinit,此函式會提前從作業系統分配一部分記憶體用於未來的記憶體分配。讓我們看一下它在 x64 架構下是如何工作的。首先,我們需要初始化下面的變數:

  • bitmapSize 對應於垃圾收集器點陣圖所需的記憶體的大小。垃圾收集器的點陣圖是一塊特殊的記憶體,該記憶體標明瞭記憶體中哪些位置是指標哪些位置是物件,以方便垃圾收集器釋放。這塊空間由垃圾收集器管理。對於每個分配的位元組,我們需要兩個位元儲存資訊,這也就是為什麼點陣圖所需記憶體大小的計算式為:arenaSize / (ptrSize * 8 / 4)
  • spanSize 表示儲存指向 memory span 的指標陣列所需記憶體空間大小。所謂 memory span 是指一種將記憶體塊封裝以便分配給物件的陣列結構。

上述所有變數計算出來後,就可以完成真正的資源預留的工作了:

最後,我們初始化全域性變數 mheap。這個變數用於集中儲存記憶體相關的物件。

注意,初始始 mheap_.arena_used 的值與 mheap_.arena_start 相等,這是因為還沒有為任何物件分配空間。

初始化堆

接下來,呼叫 mHeap_Init 函式來初始化堆。該函式所做的第一件事就是初始化分配器。

為了更好的理解分配器,讓我們先看一看是如何使用它的。每當我們希望分配新的 mspan、mcache、specialfinalizer 或者 specialprofile 結構體時,都可以通過 fixAlloc_Alloc 函式來呼叫分配器。 此函式的主要部分如下:

它會分配一塊記憶體,但是它並不是按結構體的實際大小(f.size)進行分配,而是直接留出 _FixAllocChunk (目前是 16 KB)大小的空間。多餘的儲存空間儲存在分配器中。當下一次再為相同的結構體分配空間時,就勿需再呼叫耗時的 persistentcalloc 操作。

persistentalloc 函式用於分配不會被垃圾回收的記憶體空間。它的工作流程如下所示:

  1. 如果分配的塊大於 64 KB, 則它直接從 OS 記憶體中分配。
  2. 否則,找到一個永久分配器(persistent allocator)。
    • 每個永久分配器與一個程式對應。其主要是為了在永久分配器中使用鎖。因此,我們使用永久分配器時都是使用的當前程式的永久分配器。
    • 如果不能獲得當前程式的資訊,則使用全域性的分配器。
  3. 如果分配器已經沒有足夠多的空閒記憶體,則從 OS 申請更多的記憶體。
  4. 從分配器的快取中返回所請求大小的記憶體。

persistentalloc 與 fixAlloc_Alloc 函式的工作機制是非常相似的。可以說,這些函式實現了一個兩級的快取機制。你應該可以意識到 persitentalloc 函式不僅僅只在 fixAlloc_Alloc 函式中使用,在其它很多使用永久記憶體的地方都會用到它。

讓我們再回到 mHeap_Init 函式中。一個亟需回答的問題是在函式開始時初始化的四個結構體到底有什麼用:

  • mspan 只是那些應該被垃圾回收的記憶體塊的一個包裝。在前面討論記憶體大小分類時,我們已討論過它了。當建立一個特定大小類別的物件時就會建立一個 mspan。
  • mcache 是每個程式相關的結構體。它負責快取擴充套件。每外程式擁有獨立的 mcache 主要是為了避免使用鎖。
  • specialfinalizeralloc 是在 runtime.SetFinalizer 函式呼叫時分配的結構體,而這個函式是在我們希望系統在物件結束時執行某些清理程式碼的時候呼叫的。例如,os.NewFile 函式就會為每個新檔案關聯一個 finalizer。而這個 finalizer 負責關閉系統的檔案描述符。
  • specialprofilealloc 是在記憶體分析器中使用的一個結構體。

初始化記憶體分配器後,mHeap_Initfunction 會呼叫 mSpanList_Init 函式初始化連結串列。這個過程非常的簡單,它所做的所有初始化工作僅僅是初始化連結串列的入口結點。mheap 結構體包含多個這樣的連結串列。

  • mheap.free 與 mheap.busy 陣列用於儲存大物件的空閒連結串列(大物件指大於 32 KB 而小於 1 MB 的物件)。每個可能的大小都在陣列中都有一個對應的項。在這裡,大小是用頁來衡量的,每個頁的大小為 32 KB。也就是說,陣列中的第一項鍊表管理大小為 32 KB 的記憶體塊,第二個項的管理 64 KB 的記憶體塊,依次類推。
  •  mheap.freelarge 與 mheap.busylarge 是大小於 1 MB 物件空間的空閒與忙連結串列。

接下來就是初始化 mheap.central,該變數管理所有儲存小物件(小於 32 KB)的記憶體塊。mheap.central 中,連結串列根據其管理記憶體塊的大小進行分組。初始化過程與前面看到的非常類似,初始化過程中只是將所有空閒連結串列進行初始化。

初始化快取

現在,我們幾乎已完成了所有記憶體分配器的初始化。mallocinit 函式中剩下的最後一件事就是 mcache 的初始化了:

首先獲得當前的協程。每個 goroutine 都包含一個指向 m 結構體的指標。該結構體對作業系統執行緒進行了包裝。在這個結構體的 mcache 域就是在這幾行程式碼中初始化的。 allomcache 函式呼叫 fixAlloc_Alloc 初始化新的 mcache 結構體。我們已經討論過了該結構體的分配以及其含義了。

細心的讀者可能注意到我前面說每個 mcache 與一個程式關聯,但是我們現在又說它與 m 結構體關聯,而 m 結構體是與 OS 程式相關聯,而非一個處理器。這並不是一個錯誤,mcache 只有在程式正在執行時才會初始化,而每當程式切換後它也重新切換為另外一個執行緒 m 結構體。

更多關於 Go 啟動過程

再接下來的部落格中,我們會繼續討論啟動過程中的垃圾收集器的初始化過程以及主 goroutine 是如何啟動的。同時,歡迎大家積極在部落格中評論。

相關文章