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

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。本文由 伯樂線上 - yhx 翻譯,黃利民 校稿。未經許可,禁止轉載!
英文出處:Siarhei Matsiukevich。歡迎加入翻譯組。
  • 《Go語言內幕(1):主要概念與項目結構》
  • 《Go語言內幕(2):深入 Go 編譯器》
  • 《Go語言內幕(3):連結器、連結器、重定位》
  • 《Go語言內幕(4):目標檔案和函數中繼資料》
  • 《Go語言內幕(5):運行時啟動過程》

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

啟動順序

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

        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 寄存器方向標誌。該標誌會影響到 string 處理時的方向。

接下來調用 runtime.check 函數,這個函數對我們分析運行時並沒什麼太大的協助。在該函數中,運行時建立所有內建類型的執行個體,檢查他們的大小及其它參數等。如果其中出了什麼錯,就會產生 panic 錯誤。請讀者自行閱讀這個函數的代碼。

參數分析

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

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

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

__vdso_time_sym __vdso_gettimeofday_sym __vdso_clock_gettime_sym

它們用於在不同的函數中擷取目前時間。所有這些變數都有其預設值。這允許 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 的棧才能會被緩衝。更大的棧則會直接分配。

初始化記憶體 Clerk

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

初始化大小類

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

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

    align := 8for 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 …}}

我們可以看到最小的兩個類的大小分別是 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 架構下是如何工作的。首先,我們需要初始化下面的變數:

pSize = bitmapSize + spansSize + arenaSize + _PageSize  p = uintptr(sysReserve(unsafe.Pointer(p), pSize, &reserved))
  • bitmapSize 對應於垃圾收集器位元影像所需的記憶體的大小。垃圾收集器的位元影像是一塊特殊的記憶體,該記憶體標明了記憶體中哪些位置是指標哪些位置是對象,以方便垃圾收集器釋放。這塊空間由垃圾收集器管理。對於每個分配的位元組,我們需要兩個位元儲存資訊,這也就是為什麼位元影像所需記憶體大小的計算式為:arenaSize / (ptrSize * 8 / 4)
  • spanSize 表示儲存指向 memory span 的指標數組所需記憶體空間大小。所謂 memory span 是指一種將記憶體塊封裝以便分配給對象的數組結構。

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

pSize = bitmapSize + spansSize + arenaSize + _PageSize  p = uintptr(sysReserve(unsafe.Pointer(p), pSize, &reserved))

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

p1 := round(p, _PageSize)mheap_.spans = (**mspan)(unsafe.Pointer(p1))mheap_.bitmap = p1 + spansSizemheap_.arena_start = p1 + (spansSize + bitmapSize)mheap_.arena_used = mheap_.arena_startmheap_.arena_end = p + pSizemheap_.arena_reserved = reserved

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

初始化堆

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

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)

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

   if uintptr(f.nchunk) < f.size {f.chunk = (*uint8)(persistentalloc(_FixAllocChunk, 0, f.stat))f.nchunk = _FixAllocChunk}

它會分配一塊記憶體,但是它並不是按結構體的實際大小(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 是在記憶體分析器中使用的一個結構體。

初始化記憶體 Clerk後,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 中,鏈表根據其管理記憶體塊的大小進行分組。初始化過程與前面看到的非常類似,初始化過程中只是將所有空閑鏈表進行初始化。

初始化緩衝

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

_g_ := getg()_g_.m.mcache = allocmcache()

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

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

更多關於 Go 啟動過程

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

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.