天天看點

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

程式中的資料和變量都會被配置設定到程式所在的虛拟記憶體中,記憶體空間包含兩個重要區域 — 棧區(Stack)和堆區(Heap)。函數調用的參數、傳回值以及局部變量大都會被配置設定到棧上,這部分記憶體會由編譯器進行管理;不同程式設計語言使用不同的方法管理堆區的記憶體,C++ 等程式設計語言會由工程師主動申請和釋放記憶體,Go 以及 Java 等程式設計語言會由工程師和編譯器共同管理,堆中的對象由記憶體配置設定器配置設定并由垃圾收集器回收。

不同的程式設計語言會選擇不同的方式管理記憶體,本節會介紹 Go 語言記憶體配置設定器,詳細分析記憶體配置設定的過程以及其背後的設計與實作原理。

設計原理

記憶體管理一般包含三個不同的元件,分别是使用者程式(Mutator)、配置設定器(Allocator)和收集器(Collector)[^1],當使用者程式申請記憶體時,它會通過記憶體配置設定器申請新的記憶體,而配置設定器會負責從堆中初始化相應的記憶體區域。

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

mutator-allocator-collector

圖 7-1 記憶體管理的元件

Go 語言的記憶體配置設定器實作非常複雜,在分析記憶體配置設定器的實作之前,我們需要了解記憶體配置設定的設計原理,幫助我們更快掌握記憶體的配置設定過程。這裡将要詳細介記憶體配置設定器的配置設定方法以及 Go 語言記憶體配置設定器的分級配置設定方法、虛拟記憶體布局和位址空間。

配置設定方法

程式設計語言的記憶體配置設定器一般包含兩種配置設定方法,一種是線性配置設定器(Sequential Allocator,Bump Allocator),另一種是空閑連結清單配置設定器(Free-List Allocator),這兩種配置設定方法有着不同的實作機制和特性,本節會依次介紹它們的配置設定過程。

線性配置設定器

線性配置設定(Bump Allocator)是一種高效的記憶體配置設定方法,但是有較大的局限性。當我們在程式設計語言中使用線性配置設定器,我們隻需要在記憶體中維護一個指向記憶體特定位置的指針,當使用者程式申請記憶體時,配置設定器隻需要檢查剩餘的空閑記憶體、傳回配置設定的記憶體區域并修改指針在記憶體中的位置,即移動下圖中的指針:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

bump-allocator

圖 7-2 線性配置設定器

根據線性配置設定器的原理,我們可以推測它有較快的執行速度,以及較低的實作複雜度;但是線性配置設定器無法在記憶體被釋放時重用記憶體。如下圖所示,如果已經配置設定的記憶體被回收,線性配置設定器是無法重新利用紅色的這部分記憶體的:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

bump-allocator-reclaim-memory

圖 7-3 線性配置設定器回收記憶體

正是因為線性配置設定器的這種特性,我們需要合适的垃圾回收算法配合使用。标記壓縮(Mark-Compact)、複制回收(Copying GC)和分代回收(Generational GC)等算法可以通過拷貝的方式整理存活對象的碎片,将空閑記憶體定期合并,這樣就能利用線性配置設定器的效率提升記憶體配置設定器的性能了。

因為線性配置設定器的使用需要配合具有拷貝特性的垃圾回收算法,是以 C 和 C++ 等需要直接對外暴露指針的語言就無法使用該政策,我們會在下一節詳細介紹常見垃圾回收算法的設計原理。

空閑連結清單配置設定器

空閑連結清單配置設定器(Free-List Allocator)可以重用已經被釋放的記憶體,它在内部會維護一個類似連結清單的資料結構。當使用者程式申請記憶體時,空閑連結清單配置設定器會依次周遊空閑的記憶體塊,找到足夠大的記憶體,然後申請新的資源并修改連結清單:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

free-list-allocator

圖 7-4 空閑連結清單配置設定器

因為不同的記憶體塊以連結清單的方式連接配接,是以使用這種方式配置設定記憶體的配置設定器可以重新利用回收的資源,但是因為配置設定記憶體時需要周遊連結清單,是以它的時間複雜度就是 。空閑連結清單配置設定器可以選擇不同的政策在連結清單中的記憶體塊中進行選擇,最常見的就是以下四種方式:

  • 首次适應(First-Fit)— 從連結清單頭開始周遊,選擇第一個大小大于申請記憶體的記憶體塊;
  • 循環首次适應(Next-Fit)— 從上次周遊的結束位置開始周遊,選擇第一個大小大于申請記憶體的記憶體塊;
  • 最優适應(Best-Fit)— 從連結清單頭周遊整個連結清單,選擇最合适的記憶體塊;
  • 隔離适應(Segregated-Fit)— 将記憶體分割成多個連結清單,每個連結清單中的記憶體塊大小相同,申請記憶體時先找到滿足條件的連結清單,再從連結清單中選擇合适的記憶體塊;

上述四種政策的前三種就不過多介紹了,Go 語言使用的記憶體配置設定政策與第四種政策有些相似,我們通過下圖了解一下該政策的原理:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

segregated-list

圖 7-5 隔離适應政策

如上圖所示,該政策會将記憶體分割成由 4、8、16、32 位元組的記憶體塊組成的連結清單,當我們向記憶體配置設定器申請 8 位元組的記憶體時,我們會在上圖中的第二個連結清單找到空閑的記憶體塊并傳回。隔離适應的配置設定政策減少了需要周遊的記憶體塊數量,提高了記憶體配置設定的效率。

分級配置設定

線程緩存配置設定(Thread-Caching Malloc,TCMalloc)是用于配置設定記憶體的的機制,它比 glibc 中的

malloc

函數還要快很多[^2]。Go 語言的記憶體配置設定器就借鑒了 TCMalloc 的設計實作高速的記憶體配置設定,它的核心理念是使用多級緩存根據将對象根據大小分類,并按照類别實施不同的配置設定政策。

對象大小

Go 語言的記憶體配置設定器會根據申請配置設定的記憶體大小選擇不同的處理邏輯,運作時根據對象的大小将對象分成微對象、小對象和大對象三種:

類别 大小
微對象

(0, 16B)

小對象

[16B, 32KB]

大對象

(32KB, +∞)

表 7-1 對象的類别和大小

因為程式中的絕大多數對象的大小都在 32KB 以下,而申請的記憶體大小影響 Go 語言運作時配置設定記憶體的過程和開銷,是以分别處理大對象和小對象有利于提高記憶體配置設定器的性能。

多級緩存

記憶體配置設定器不僅會差別對待大小不同的對象,還會将記憶體分成不同的級别分别管理,TCMalloc 和 Go 運作時配置設定器都會引入線程緩存(Thread Cache)、中心緩存(Central Cache)和頁堆(Page Heap)三個元件分級管理記憶體:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

multi-level-cache

圖 7-6 多級緩存記憶體配置設定

線程緩存屬于每一個獨立的線程,它能夠滿足線程上絕大多數的記憶體配置設定需求,因為不涉及多線程,是以也不需要使用互斥鎖來保護記憶體,這能夠減少鎖競争帶來的性能損耗。當線程緩存不能滿足需求時,就會使用中心緩存作為補充解決小對象的記憶體配置設定問題;在遇到 32KB 以上的對象時,記憶體配置設定器就會選擇頁堆直接配置設定大量的記憶體。

這種多層級的記憶體配置設定設計與計算機作業系統中的多級緩存也有些類似,因為多數的對象都是小對象,我們可以通過線程緩存和中心緩存提供足夠的記憶體空間,發現資源不足時就從上一級元件中擷取更多的記憶體資源。

虛拟記憶體布局

這裡會介紹 Go 語言堆區記憶體位址空間的設計以及演進過程,在 Go 語言 1.10 以前的版本,堆區的記憶體空間都是連續的;但是在 1.11 版本,Go 團隊使用稀疏的堆記憶體空間替代了連續的記憶體,解決了連續記憶體帶來的限制以及在特殊場景下可能出現的問題。

線性記憶體

Go 語言程式的 1.10 版本在啟動時會初始化整片虛拟記憶體區域,如下所示的三個區域

spans

bitmap

arena

分别預留了 512MB、16GB 以及 512GB 的記憶體空間,這些記憶體并不是真正存在的實體記憶體,而是虛拟記憶體:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

heap-before-go-1-11

圖 7-7 堆區的線性記憶體

  • spans

    區域存儲了指向記憶體管理單元

    runtime.mspan

    的指針,每個記憶體單元會管理幾頁的記憶體空間,每頁大小為 8KB;
  • bitmap

    用于辨別

    arena

    區域中的那些位址儲存了對象,位圖中的每個位元組都會表示堆區中的 32 位元組是否包含空閑;
  • arena

    區域是真正的堆區,運作時會将 8KB 看做一頁,這些記憶體頁中存儲了所有在堆上初始化的對象;

對于任意一個位址,我們都可以根據

arena

的基位址計算該位址所在的頁數并通過

spans

數組獲得管理該片記憶體的管理單元

runtime.mspan

spans

數組中多個連續的位置可能對應同一個

runtime.mspan

Go 語言在垃圾回收時會根據指針的位址判斷對象是否在堆中,并通過上一段中介紹的過程找到管理該對象的

runtime.mspan

。這些都建立在堆區的記憶體是連續的這一假設上。這種設計雖然簡單并且友善,但是在 C 和 Go 混合使用時會導緻程式崩潰:

  1. 配置設定的記憶體位址會發生沖突,導緻堆的初始化和擴容失敗[^3];
  2. 沒有被預留的大塊記憶體可能會被配置設定給 C 語言的二進制,導緻擴容後的堆不連續[^4];

線性的堆記憶體需要預留大塊的記憶體空間,但是申請大塊的記憶體空間而不使用是不切實際的,不預留記憶體空間卻會在特殊場景下造成程式崩潰。雖然連續記憶體的實作比較簡單,但是這些問題我們也沒有辦法忽略。

稀疏記憶體

稀疏記憶體是 Go 語言在 1.11 中提出的方案,使用稀疏的記憶體布局不僅能移除堆大小的上限[^5],還能解決 C 和 Go 混合使用時的位址空間沖突問題[^6]。不過因為基于稀疏記憶體的記憶體管理失去了記憶體的連續性這一假設,這也使記憶體管理變得更加複雜:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

heap-after-go-1-11

圖 7-8 二維稀疏記憶體

如上圖所示,運作時使用二維的

runtime.heapArena

數組管理所有的記憶體,每個單元都會管理 64MB 的記憶體空間:

該結構體中的

bitmap

spans

與線性記憶體中的

bitmap

spans

區域一一對應,

zeroedBase

字段指向了該結構體管理的記憶體的基位址。這種設計将原有的連續大記憶體切分成稀疏的小記憶體,而用于管理這些記憶體的元資訊也被切分成了小塊。

不同平台和架構的二維數組大小可能完全不同,如果我們的 Go 語言服務在 Linux 的 x86-64 架構上運作,二維數組的一維大小會是 1,而二維大小是 4,194,304,因為每一個指針占用 8 位元組的記憶體空間,是以元資訊的總大小為 32MB。由于每個

runtime.heapArena

都會管理 64MB 的記憶體,整個堆區最多可以管理 256TB 的記憶體,這比之前的 512GB 多好幾個數量級。

Go 語言團隊在 1.11 版本中通過以下幾個送出将線性記憶體變成稀疏記憶體,移除了 512GB 的記憶體上限以及堆區記憶體連續性的假設:

  • runtime: use sparse mappings for the heap
  • runtime: fix various contiguous bitmap assumptions
  • runtime: make the heap bitmap sparse
  • runtime: abstract remaining mheap.spans access
  • runtime: make span map sparse
  • runtime: eliminate most uses of mheap_.arena_*
  • runtime: remove non-reserved heap logic
  • runtime: move comment about address space sizes to malloc.go

由于記憶體的管理變得更加複雜,上述改動對垃圾回收稍有影響,大約會增加 1% 的垃圾回收開銷,不過這也是我們為了解決已有問題必須付出的成本[^7]。

位址空間

因為所有的記憶體最終都是要從作業系統中申請的,是以 Go 語言的運作時建構了作業系統的記憶體管理抽象層,該抽象層将運作時管理的位址空間分成以下的四種狀态[^8]:

狀态 解釋

None

記憶體沒有被保留或者映射,是位址空間的預設狀态

Reserved

運作時持有該位址空間,但是通路該記憶體會導緻錯誤

Prepared

記憶體被保留,一般沒有對應的實體記憶體

通路該片記憶體的行為是未定義的

可以快速轉換到

Ready

狀态

Ready

可以被安全通路

表 7-2 位址空間的狀态

每一個不同的作業系統都會包含一組特定的方法,這些方法可以讓記憶體位址空間在不同的狀态之間做出轉換,我們可以通過下圖了解不同狀态之間的轉換過程:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

memory-regions-states-and-transitions

圖 7-9 位址空間的狀态轉換

運作時中包含多個作業系統對狀态轉換方法的實作,所有的實作都包含在以

mem_

開頭的檔案中,本節将介紹 Linux 作業系統對上圖中方法的實作:

  • runtime.sysAlloc

    會從作業系統中擷取一大塊可用的記憶體空間,可能為幾百 KB 或者幾 MB;
  • runtime.sysFree

    會在程式發生記憶體不足(Out-of Memory,OOM)時調用并無條件地傳回記憶體;
  • runtime.sysReserve

    會保留作業系統中的一片記憶體區域,對這片記憶體的通路會觸發異常;
  • runtime.sysMap

    保證記憶體區域可以快速轉換至準備就緒;
  • runtime.sysUsed

    通知作業系統應用程式需要使用該記憶體區域,需要保證記憶體區域可以安全通路;
  • runtime.sysUnused

    通知作業系統虛拟記憶體對應的實體記憶體已經不再需要了,它可以重用實體記憶體;
  • runtime.sysFault

    将記憶體區域轉換成保留狀态,主要用于運作時的調試;

運作時使用 Linux 提供的

mmap

munmap

madvise

等系統調用實作了作業系統的記憶體管理抽象層,抹平了不同作業系統的差異,為運作時提供了更加友善的接口,除了 Linux 之外,運作時還實作了 BSD、Darwin、Plan9 以及 Windows 等平台上抽象層。

記憶體管理元件

Go 語言的記憶體配置設定器包含記憶體管理單元、線程緩存、中心緩存和頁堆幾個重要元件,本節将介紹這幾種最重要元件對應的資料結構

runtime.mspan

runtime.mcache

runtime.mcentral

runtime.mheap

,我們會詳細介紹它們在記憶體配置設定器中的作用以及實作。

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

go-memory-layout

圖 7-10 Go 程式的記憶體布局

所有的 Go 語言程式都會在啟動時初始化如上圖所示的記憶體布局,每一個處理器都會被配置設定一個線程緩存

runtime.mcache

用于處理微對象和小對象的配置設定,它們會持有記憶體管理單元

runtime.mspan

每個類型的記憶體管理單元都會管理特定大小的對象,當記憶體管理單元中不存在空閑對象時,它們會從

runtime.mheap

持有的 134 個中心緩存

runtime.mcentral

中擷取新的記憶體單元,中心緩存屬于全局的堆結構體

runtime.mheap

,它會從作業系統中申請記憶體。

在 amd64 的 Linux 作業系統上,

runtime.mheap

會持有 4,194,304

runtime.heapArena

,每一個

runtime.heapArena

都會管理 64MB 的記憶體,單個 Go 語言程式的記憶體上限也就是 256TB。

記憶體管理單元

runtime.mspan

是 Go 語言記憶體管理的基本單元,該結構體中包含

next

prev

兩個字段,它們分别指向了前一個和後一個

runtime.mspan

串聯後的上述結構體會構成如下雙向連結清單,運作時會使用

runtime.mSpanList

存儲雙向連結清單的頭結點和尾節點并線上程緩存以及中心緩存中使用。

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

mspan-and-linked-list

圖 7-11 記憶體管理單元與雙向連結清單

因為相鄰的管理單元會互相引用,是以我們可以從任意一個結構體通路雙向連結清單中的其他節點。

頁和記憶體

每個

runtime.mspan

都管理

npages

個大小為 8KB 的頁,這裡的頁不是作業系統中的記憶體頁,它們是作業系統記憶體頁的整數倍,該結構體會使用下面的這些字段來管理記憶體頁的配置設定和回收:

  • startAddr

    npages

     — 确定該結構體管理的多個頁所在的記憶體,每個頁的大小都是 8KB;
  • freeindex

    — 掃描頁中空閑對象的初始索引;
  • allocBits

    gcmarkBits

    — 分别用于标記記憶體的占用和回收情況;
  • allocCache

    allocBits

    的補碼,可以用于快速查找記憶體中未被使用的記憶體;

runtime.mspan

會以兩種不同的視角看待管理的記憶體,當結構體管理的記憶體不足時,運作時會以頁為機關向堆申請記憶體:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

mspan-and-pages

圖 7-12 記憶體管理單元與頁

當使用者程式或者線程向

runtime.mspan

申請記憶體時,該結構會使用

allocCache

字段以對象為機關在管理的記憶體中快速查找待配置設定的空間:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

mspan-and-objects

圖 7-13 記憶體管理單元與對象

如果我們能在記憶體中找到空閑的記憶體單元,就會直接傳回,當記憶體中不包含空閑的記憶體時,上一級的元件

runtime.mcache

可能會為該結構體添加更多的記憶體頁以滿足為更多對象配置設定記憶體的需求。

狀态

運作時會使用

runtime.mSpanStateBox

結構體存儲記憶體管理單元的狀态

runtime.mSpanState

該狀态可能處于

mSpanDead

mSpanInUse

mSpanManual

mSpanFree

四種情況。當

runtime.mspan

在空閑堆中,它會處于

mSpanFree

狀态;當

runtime.mspan

已經被配置設定時,它會處于

mSpanInUse

mSpanManual

狀态,這些狀态會在遵循以下規則發生轉換:

  • 在垃圾回收的任意階段,可能從

    mSpanFree

    轉換到

    mSpanInUse

    mSpanManual

  • 在垃圾回收的清除階段,可能從

    mSpanInUse

    mSpanManual

    轉換到

    mSpanFree

  • 在垃圾回收的标記階段,不能從

    mSpanInUse

    mSpanManual

    轉換到

    mSpanFree

設定

runtime.mspan

結構體狀态的讀寫操作必須是原子性的避免垃圾回收造成的線程競争問題。

跨度類

runtime.spanClass

runtime.mspan

結構體的跨度類,它決定了記憶體管理單元中存儲的對象大小和個數:

Go 語言的記憶體管理子產品中一共包含 67 種跨度類,每一個跨度類都會存儲特定大小的對象并且包含特定數量的頁數以及對象,所有的資料都會被預選計算好并存儲在

runtime.class_to_size

runtime.class_to_allocnpages

等變量中:

class bytes/obj bytes/span objects tail waste max waste
1 8 8192 1024 87.50%
2 16 8192 512 43.75%
3 32 8192 256 46.88%
4 48 8192 170 32 31.52%
5 64 8192 128 23.44%
6 80 8192 102 32 19.07%
... ... ... ... ... ...
66 32768 32768 1 12.50%

表 7-3 跨度類的資料

上表展示了對象大小從 8B 到 32KB,總共 66 種跨度類的大小、存儲的對象數以及浪費的記憶體空間,以表中的第四個跨度類為例,跨度類為 4 的

runtime.mspan

中對象的大小上限為 48 位元組、管理 1 個頁、最多可以存儲 170 個對象。因為記憶體需要按照頁進行管理,是以在尾部會浪費 32 位元組的記憶體,當頁中存儲的對象都是 33 位元組時,最多會浪費 31.52% 的資源:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

mspan-max-waste-memory

圖 7-14 跨度類浪費的記憶體

除了上述 66 個跨度類之外,運作時中還包含 ID 為 0 的特殊跨度類,它能夠管理大于 32KB 的特殊對象,我們會在後面詳細介紹大對象的配置設定過程,在這裡就不展開說明了。

跨度類中除了存儲類别的 ID 之外,它還會存儲一個

noscan

标記位,該标記位表示對象是否包含指針,垃圾回收會對包含指針的

runtime.mspan

結構體進行掃描。我們可以通過下面的幾個函數和方法了解 ID 和标記位的底層存儲方式:

runtime.spanClass

是一個

uint8

類型的整數,它的前 7 位存儲着跨度類的 ID,最後一位表示是否包含指針,該類型提供的兩個方法能夠幫我們快速擷取對應的字段。

線程緩存

runtime.mcache

是 Go 語言中的線程緩存,它會與線程上的處理器一一綁定,主要用來緩存使用者程式申請的微小對象。每一個線程緩存都持有 67 * 2 個

runtime.mspan

,這些記憶體管理單元都存儲在結構體的

alloc

字段中:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

mcache-and-mspans

圖 7-15 線程緩存與記憶體管理單元

線程緩存在剛剛被初始化時是不包含

runtime.mspan

的,隻有當使用者程式申請記憶體時才會從上一級元件擷取新的

runtime.mspan

滿足記憶體配置設定的需求。

初始化

運作時在初始化處理器時會調用

runtime.allocmcache

初始化線程緩存,該函數會在系統棧中使用

runtime.mheap

中的線程緩存配置設定器初始化新的

runtime.mcache

結構體:

就像我們在上面提到的,初始化後的

runtime.mcache

中的所有

runtime.mspan

都是空的占位符

emptymspan

替換

runtime.mcache.refill

方法會為線程緩存擷取一個指定跨度類的記憶體管理單元,被替換的單元不能包含空閑的記憶體空間,而擷取的單元中需要至少包含一個空閑對象用于配置設定記憶體:

如上述代碼所示,該函數會從中心緩存中申請新的

runtime.mspan

存儲到線程緩存中,這也是向線程緩存中插入記憶體管理單元的唯一方法。

微配置設定器

線程緩存中還包含幾個用于配置設定微對象的字段,下面的這三個字段組成了微對象配置設定器,專門為 16 位元組以下的對象申請和管理記憶體:

微配置設定器隻會用于配置設定非指針類型的記憶體,上述三個字段中

tiny

會指向堆中的一篇記憶體,

tinyOffset

是下一個空閑記憶體所在的偏移量,最後的

local_tinyallocs

會記錄記憶體配置設定器中配置設定的對象個數。

中心緩存

runtime.mcentral

是記憶體配置設定器的中心緩存,與線程緩存不同,通路中心緩存中的記憶體管理單元需要使用互斥鎖:

每一個中心緩存都會管理某個跨度類的記憶體管理單元,它會同時持有兩個

runtime.mSpanList

,分别存儲包含空閑對象的清單和不包含空閑對象的連結清單:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

mcentral-and-mspans

圖 7-16 中心緩存和記憶體管理單元

該結構體在初始化時,兩個連結清單都不包含任何記憶體,程式運作時會擴容結構體持有的兩個連結清單,

nmalloc

字段也記錄了該結構體中配置設定的對象個數。

記憶體管理單元

線程緩存會通過中心緩存的

runtime.mcentral.cacheSpan

方法擷取新的記憶體管理單元,該方法的實作比較複雜,我們可以将其分成以下幾個部分:

  1. 從有空閑對象的

    runtime.mspan

    連結清單中查找可以使用的記憶體管理單元;
  2. 從沒有空閑對象的

    runtime.mspan

    連結清單中查找可以使用的記憶體管理單元;
  3. 調用

    runtime.mcentral.grow

    從堆中申請新的記憶體管理單元;
  4. 更新記憶體管理單元的

    allocCache

    等字段幫助快速配置設定記憶體;

首先我們會在中心緩存的非空連結清單中查找可用的

runtime.mspan

,根據

sweepgen

字段分别進行不同的處理:

  1. 當記憶體單元等待回收時,将其插入

    empty

    連結清單、調用

    runtime.mspan.sweep

    清理該單元并傳回;
  2. 當記憶體單元正在被背景回收時,跳過該記憶體單元;
  3. 當記憶體單元已經被回收時,将記憶體單元插入

    empty

    連結清單并傳回;

如果中心緩存沒有在

nonempty

中找到可用的記憶體管理單元,就會繼續周遊其持有的

empty

連結清單,我們在這裡的處理與包含空閑對象的連結清單幾乎完全相同。當找到需要回收的記憶體單元時,我們也會觸發

runtime.mspan.sweep

進行清理,如果清理後的記憶體單元仍然不包含空閑對象,就會重新執行相應的代碼:

如果

runtime.mcentral

在兩個連結清單中都沒有找到可用的記憶體單元,它會調用

runtime.mcentral.grow

觸發擴容操作從堆中申請新的記憶體:

無論通過哪種方法擷取到了記憶體單元,該方法的最後都會對記憶體單元的

allocBits

allocCache

等字段進行更新,讓運作時在配置設定記憶體時能夠快速找到空閑的對象。

擴容

中心緩存的擴容方法

runtime.mcentral.grow

會根據預先計算的

class_to_allocnpages

class_to_size

擷取待配置設定的頁數以及跨度類并調用

runtime.mheap.alloc

擷取新的

runtime.mspan

結構:

擷取了

runtime.mspan

之後,我們會在上述方法中初始化

limit

字段并清除該結構在堆上對應的位圖。

頁堆

runtime.mheap

是記憶體配置設定的核心結構體,Go 語言程式隻會存在一個全局的結構,而堆上初始化的所有對象都由該結構體統一管理,該結構體中包含兩組非常重要的字段,其中一個是全局的中心緩存清單

central

,另一個是管理堆區記憶體區域的

arenas

以及相關字段。

頁堆中包含一個長度為 134 的

runtime.mcentral

數組,其中 67 個為跨度類需要

scan

的中心緩存,另外的 67 個是

noscan

的中心緩存:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

mheap-and-mcentrals

圖 7-17 頁堆與中心緩存清單

我們在設計原理一節中已經介紹過 Go 語言所有的記憶體空間都由如下所示的二維矩陣

runtime.heapArena

管理的,這個二維矩陣管理的記憶體可以是不連續的:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

mheap-and-memories

圖 7-18 頁堆管理的記憶體區域

在除了 Windows 以外的 64 位作業系統中,每一個

runtime.heapArena

都會管理 64MB 的記憶體空間,如下所示的表格展示了不同平台上 Go 語言程式管理的堆區大小以及

runtime.heapArena

占用的記憶體空間:

平台 位址位數 Arena 大小 一維大小 二維大小
*/64-bit 48 64MB 1 4M (32MB)
windows/64-bit 48 4MB 64 1M  (8MB)
*/32-bit 32 4MB 1 1024  (4KB)
*/mips(le) 31 4MB 1 512  (2KB)

表 7-3 平台與頁堆大小的關系

本節将介紹頁堆的初始化、記憶體配置設定以及記憶體管理單元配置設定的過程,這些過程能夠幫助我們了解全局變量頁堆與其他元件的關系以及它管理記憶體的方式。

初始化

堆區的初始化會使用

runtime.mheap.init

方法,我們能看到該方法初始化了非常多的結構體和字段,不過其中初始化的兩類變量比較重要:

  1. spanalloc

    cachealloc

    以及

    arenaHintAlloc

    runtime.fixalloc

    類型的空閑連結清單配置設定器;
  2. central

    切片中

    runtime.mcentral

    類型的中心緩存;

堆中初始化的多個空閑連結清單配置設定器與我們在設計原理一節中提到的配置設定器沒有太多差別,當我們調用

runtime.fixalloc.init

初始化配置設定器時,需要傳入帶初始化的結構體大小等資訊,這會幫助配置設定器分割待配置設定的記憶體,該配置設定器提供了以下兩個用于配置設定和釋放記憶體的方法:

  1. runtime.fixalloc.alloc

    — 擷取下一個空閑的記憶體空間;
  2. runtime.fixalloc.free

    — 釋放指針指向的記憶體空間;

除了這些空閑連結清單配置設定器之外,我們還會在該方法中初始化所有的中心緩存,這些中心緩存會維護全局的記憶體管理單元,各個線程會通過中心緩存擷取新的記憶體單元。

記憶體管理單元

runtime.mheap

是記憶體配置設定器中的核心元件,運作時會通過它的

runtime.mheap.alloc

方法在系統棧中擷取新的

runtime.mspan

為了阻止記憶體的大量占用和堆的增長,我們在配置設定對應頁數的記憶體前需要先調用

runtime.mheap.reclaim

方法回收一部分記憶體,接下來我們将通過

runtime.mheap.allocSpan

配置設定新的記憶體管理單元,我們會将該方法的執行過程拆分成兩個部分:

  1. 從堆上配置設定新的記憶體頁和記憶體管理單元

    runtime.mspan

  2. 初始化記憶體管理單元并将其加入

    runtime.mheap

    持有記憶體單元清單;

首先我們需要在堆上申請

npages

數量的記憶體頁并初始化

runtime.mspan

上述方法會通過處理器的頁緩存

runtime.pageCache

或者全局的頁配置設定器

runtime.pageAlloc

兩種途徑從堆中申請記憶體:

  1. 如果申請的記憶體比較小,擷取申請記憶體的處理器并嘗試調用

    runtime.pageCache.alloc

    擷取記憶體區域的基位址和大小;
  2. 如果申請的記憶體比較大或者線程的頁緩存中記憶體不足,會通過

    runtime.pageAlloc.alloc

    在頁堆上申請記憶體;
  3. 如果發現頁堆上的記憶體不足,會嘗試通過

    runtime.mheap.grow

    進行擴容并重新調用

    runtime.pageAlloc.alloc

    申請記憶體;
    1. 如果申請到記憶體,意味着擴容成功;
    2. 如果沒有申請到記憶體,意味着擴容失敗,主控端可能不存在空閑記憶體,運作時會直接中止目前程式;

無論通過哪種方式獲得記憶體頁,我們都會在該函數中配置設定新的

runtime.mspan

結構體;該方法的剩餘部分會通過頁數、記憶體空間以及跨度類等參數初始化它的多個字段:

在上述代碼中,我們通過調用

runtime.mspan.init

方法以及設定參數初始化剛剛配置設定的

runtime.mspan

結構并通過

runtime.mheaps.setSpans

方法建立頁堆與記憶體單元的聯系。

擴容

runtime.mheap.grow

方法會向作業系統申請更多的記憶體空間,傳入的頁數經過對齊可以得到期望的記憶體大小,我們可以将該方法的執行過程分成以下幾個部分:

  1. 通過傳入的頁數擷取期望配置設定的記憶體空間大小以及記憶體的基位址;
  2. 如果

    arena

    區域沒有足夠的空間,調用

    runtime.mheap.sysAlloc

    從作業系統中申請更多的記憶體;
  3. 擴容

    runtime.mheap

    持有的

    arena

    區域并更新頁配置設定器的元資訊;
  4. 在某些場景下,調用

    runtime.pageAlloc.scavenge

    回收不再使用的空閑記憶體頁;

在頁堆擴容的過程中,

runtime.mheap.sysAlloc

是頁堆用來申請虛拟記憶體的方法,我們會分幾部分介紹該方法的實作。首先,該方法會嘗試在預保留的區域申請記憶體:

上述代碼會調用線性配置設定器的

runtime.linearAlloc.alloc

方法在預先保留的記憶體中申請一塊可以使用的空間。如果沒有可用的空間,我們會根據頁堆的

arenaHints

在目标位址上嘗試擴容:

runtime.sysReserve

runtime.sysMap

是上述代碼的核心部分,它們會從作業系統中申請記憶體并将記憶體轉換至

Prepared

狀态。

runtime.mheap.sysAlloc

方法在最後會初始化一個新的

runtime.heapArena

結構體來管理剛剛申請的記憶體空間,該結構體會被加入頁堆的二維矩陣中。

記憶體配置設定

堆上所有的對象都會通過調用

runtime.newobject

函數配置設定記憶體,該函數會調用

runtime.mallocgc

配置設定指定大小的記憶體空間,這也是使用者程式向堆上申請記憶體空間的必經函數:

上述代碼使用

runtime.gomcache

擷取了線程緩存并通過類型判斷類型是否為指針類型。我們從這個代碼片段可以看出

runtime.mallocgc

會根據對象的大小執行不同的配置設定邏輯,在前面的章節也曾經介紹過運作時根據對象大小将它們分成微對象、小對象和大對象,這裡會根據大小選擇不同的配置設定邏輯:

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

allocator-and-memory-size

圖 7-19 三種對象

  • 微對象

    (0, 16B)

    — 先使用微型配置設定器,再依次嘗試線程緩存、中心緩存和堆配置設定記憶體;
  • 小對象

    [16B, 32KB]

    — 依次嘗試使用線程緩存、中心緩存和堆配置設定記憶體;
  • 大對象

    (32KB, +∞)

    — 直接在堆上配置設定記憶體;

我們會依次介紹運作時配置設定微對象、小對象和大對象的過程,梳理記憶體配置設定的核心執行流程。

微對象

Go 語言運作時将小于 16 位元組的對象劃分為微對象,它會使用線程緩存上的微配置設定器提高微對象配置設定的性能,我們主要使用它來配置設定較小的字元串以及逃逸的臨時變量。微配置設定器可以将多個較小的記憶體配置設定請求合入同一個記憶體塊中,隻有當記憶體塊中的所有對象都需要被回收時,整片記憶體才可能被回收。

微配置設定器管理的對象不可以是指針類型,管理多個對象的記憶體塊大小

maxTinySize

是可以調整的,在預設情況下,記憶體塊的大小為 16 位元組。

maxTinySize

的值越大,組合多個對象的可能性就越高,記憶體浪費也就越嚴重;

maxTinySize

越小,記憶體浪費就會越少,不過無論如何調整,8 的倍數都是一個很好的選擇。

exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

tiny-allocator

圖 7-20 微配置設定器的工作原理

如上圖所示,微配置設定器已經在 16 位元組的記憶體塊中配置設定了 12 位元組的對象,如果下一個待配置設定的對象小于 4 位元組,它就會直接使用上述記憶體塊的剩餘部分,減少記憶體碎片,不過該記憶體塊隻有在 3 個對象都被标記為垃圾時才會被回收。

線程緩存

runtime.mcache

中的

tiny

字段指向了

maxTinySize

大小的塊,如果目前塊中還包含大小合适的空閑記憶體,運作時會通過基位址和偏移量擷取并傳回這塊記憶體:

當記憶體塊中不包含空閑的記憶體時,下面的這段代碼會從先線程緩存找到跨度類對應的記憶體管理單元

runtime.mspan

,調用

runtime.nextFreeFast

擷取空閑的記憶體;當不存在空閑記憶體時,我們會調用

runtime.mcache.nextFree

從中心緩存或者頁堆中擷取可配置設定的記憶體塊:

擷取新的空閑記憶體塊之後,上述代碼會清空空閑記憶體中的資料、更新構成微對象配置設定器的幾個字段

tiny

tinyoffset

并傳回新的空閑記憶體。

小對象

小對象是指大小為 16 位元組到 32,768 位元組的對象以及所有小于 16 位元組的指針類型的對象,小對象的配置設定可以被分成以下的三個步驟:

  1. 确定配置設定對象的大小以及跨度類

    runtime.spanClass

  2. 從線程緩存、中心緩存或者堆中擷取記憶體管理單元并從記憶體管理單元找到空閑的記憶體空間;
  3. 調用

    runtime.memclrNoHeapPointers

    清空空閑記憶體中的所有資料;

确定待配置設定的對象大小以及跨度類需要使用預先計算好的

size_to_class8

size_to_class128

以及

class_to_size

字典,這些字典能夠幫助我們快速擷取對應的值并建構

runtime.spanClass

在上述代碼片段中,我們會重點分析兩個函數和方法的實作原理,它們分别是

runtime.nextFreeFast

runtime.mcache.nextFree

,這兩個函數會幫助我們擷取空閑的記憶體空間。

runtime.nextFreeFast

會利用記憶體管理單元中的

allocCache

字段,快速找到該字段中位 1 的位數,我們在上面介紹過 1 表示該位對應的記憶體空間是空閑的:

找到了空閑的對象後,我們就可以更新記憶體管理單元的

allocCache

freeindex

等字段并傳回該片記憶體了;如果我們沒有找到空閑的記憶體,運作時會通過

runtime.mcache.nextFree

找到新的記憶體管理單元:

在上述方法中,如果我們線上程緩存中沒有找到可用的記憶體管理單元,會通過前面介紹的

runtime.mcache.refill

使用中心緩存中的記憶體管理單元替換已經不存在可用對象的結構體,該方法會調用新結構體的

runtime.mspan.nextFreeIndex

擷取空閑的記憶體并傳回。

大對象

運作時對于大于 32KB 的大對象會單獨處理,我們不會從線程緩存或者中心緩存中擷取記憶體管理單元,而是直接在系統的棧中調用

runtime.largeAlloc

函數配置設定大片的記憶體:

runtime.largeAlloc

函數會計算配置設定該對象所需要的頁數,它會按照 8KB 的倍數為對象在堆上申請記憶體:

申請記憶體時會建立一個跨度類為 0 的

runtime.spanClass

并調用

runtime.mheap.alloc

配置設定一個管理對應記憶體的管理單元。

小結

記憶體配置設定是 Go 語言運作時記憶體管理的核心邏輯,運作時的記憶體配置設定器使用類似 TCMalloc  的配置設定政策将對象根據大小分類,并設計多層級的元件提高記憶體配置設定器的性能。本節不僅介紹了 Go 語言記憶體配置設定器的設計與實作原理,同時也介紹了記憶體配置設定器的常見設計,幫助我們了解不同程式設計語言在設計記憶體配置設定器時做出的不同選擇。

記憶體配置設定器雖然非常重要,但是它隻解決了如何配置設定記憶體的問題,我們在本節中省略了很多與垃圾回收相關的代碼,沒有分析運作時垃圾回收的實作原理,在下一節中我們将詳細分析 Go 語言垃圾回收的設計與實作原理。

延伸閱讀

  • The Go Memory Model
  • A visual guide to Go Memory Allocator from scratch (Golang)
  • TCMalloc : Thread-Caching Malloc
  • Getting to Go: The Journey of Go's Garbage Collecton
  • Go: Memory Management and Allocation

圖是怎麼畫的

  • 技術文章配圖指南
exfat 配置設定單元大小_Go 記憶體配置設定器的設計與實作

長按二維碼↑

關注『真沒什麼邏輯』公衆号

覺得有用就點一下  ???

繼續閱讀