1. 程式人生 > 實用技巧 >go記憶體分配器詳解-摘自go語言設計與實現

go記憶體分配器詳解-摘自go語言設計與實現

go設計與實現把go記憶體分配器介紹的很詳細,起始一般情況下程式設計師不怎麼會用到。需要簡單瞭解下即可。如果沒時間看,看看下述內容即可。

棧區堆區概念要理解。分配方法其實就是基於演算法中的陣列和連結串列,優缺點都類似。

go採用的空閒連結串列分配。並採取了隔離適應來規避連結串列的缺陷。

通過將物件大小分成微物件[<16B]小物件[16-32KB] 大物件[>32KB],並且通過多級快取提高分配效率。

通過將物件大小分成微物件[<16B]小物件[16-32KB] 大物件[>32KB],並且通過多級快取提高分配效率。

通過將物件大小分成微物件[<16B]小物件[16-32KB] 大物件[>32KB],並且通過多級快取提高分配效率。(重要的事情說三遍,將物件分類並使用多級快取是go記憶體管理的重要思想)

最重要的記憶體管理元件。記憶體管理單元mspan、執行緒快取mcache、中心快取mcentral、頁堆mheap簡單的瞭解下。

這些元件的原始碼都在runtime包中

go以頁為單位管理記憶體(跟作業系統的頁不同),每個mspan會持有多個頁。

mspan是基礎,mcache是為每個goroutine各自分配的快取,mcentral可以理解成是記憶體池,mheap是更大的池。

當mspan不足,會向mcentral申請,mcentral不足向mheap申請,mheap不足向作業系統申請。

一般瞭解到這些內容就可以了。有時間可以詳細往下看。

一、記憶體管理的基礎概念

記憶體空間分為兩個重要區域。棧區Stack和堆區Heap。函式呼叫的引數,返回值以及區域性遍歷大都分配到棧上,由編譯器管理。不同程式語言使用不同的方法管理堆區的記憶體,C++ 等程式語言會由工程師主動申請和釋放記憶體,Go 以及 Java 等程式語言會由工程師和編譯器共同管理,堆中的物件由記憶體分配器分配並由垃圾收集器回收。

設計原理

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

分配方法

程式語言的記憶體分配器一般包含兩種分配方法,一種是線性分配器(Sequential Allocator,Bump Allocator),另一種是空閒連結串列分配器(Free-List Allocator)

線性分配器

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

優點:執行快,容易實現

缺點:無法重用記憶體,需要搭配合適的垃圾回收演算法

回收中產生的記憶體碎片,需要搭配合適的垃圾回收演算法。標記壓縮(Mark-Compact)、複製回收(Copying GC)和分代回收(Generational GC)等演算法可以通過拷貝的方式整理存活物件的碎片,將空閒記憶體定期合併,這樣就能利用線性分配器的效率提升記憶體分配器的效能了。

空閒連結串列分配器

空閒連結串列分配器(Free-List Allocator)可以重用已經被釋放的記憶體,它在內部會維護一個類似連結串列的資料結構。當用戶程式申請記憶體時,空閒連結串列分配器會依次遍歷空閒的記憶體塊,找到足夠大的記憶體,然後申請新的資源並修改連結串列:

優點:記憶體複用方便

缺點:分配記憶體需要遍歷整個連結串列,耗時長。

空閒連結串列分配器可以選擇不同的策略在連結串列中的記憶體塊中進行選擇,最常見的就是以下四種方式:

首次適應(First-Fit)— 從連結串列頭開始遍歷,選擇第一個大小大於申請記憶體的記憶體塊;

  • 迴圈首次適應(Next-Fit)— 從上次遍歷的結束位置開始遍歷,選擇第一個大小大於申請記憶體的記憶體塊;
  • 最優適應(Best-Fit)— 從連結串列頭遍歷整個連結串列,選擇最合適的記憶體塊;
  • 隔離適應(Segregated-Fit)— 將記憶體分割成多個連結串列,每個連結串列中的記憶體塊大小相同,申請記憶體時先找到滿足條件的連結串列,再從連結串列中選擇合適的記憶體

Go 語言使用的記憶體分配策略與第四種策略有些相似,我們通過下圖瞭解一下該策略的原理:

如上圖所示,該策略會將記憶體分割成由 4、8、16、32 位元組的記憶體塊組成的連結串列,當我們向記憶體分配器申請 8 位元組的記憶體時,我們會在上圖中的第二個連結串列找到空閒的記憶體塊並返回。隔離適應的分配策略減少了需要遍歷的記憶體塊數量,提高了記憶體分配的效率。

分級分配

執行緒快取分配(Thread-Caching Malloc,TCMalloc)是用於分配記憶體的的機制,它比 glibc 中的malloc函式還要快很多。Go 語言的記憶體分配器就借鑑了 TCMalloc 的設計實現高速的記憶體分配,它的核心理念是使用多級快取根據將物件根據大小分類,並按照類別實施不同的分配策略。

物件大小

Go 語言的記憶體分配器會根據申請分配的記憶體大小選擇不同的處理邏輯,執行時根據物件的大小將物件分成微物件、小物件和大物件三種:

類別大小
微物件 (0, 16B)
小物件 [16B, 32KB]
大物件 (32KB, +∞)

多級快取

記憶體分配器不僅會區別對待大小不同的物件,還會將記憶體分成不同的級別分別管理,TCMalloc 和 Go 執行時分配器都會引入執行緒快取(Thread Cache)、中心快取(Central Cache)和頁堆(Page Heap)三個元件分級管理記憶體:

執行緒快取屬於每一個獨立的執行緒,它能夠滿足執行緒上絕大多數的記憶體分配需求,因為不涉及多執行緒,所以也不需要使用互斥鎖來保護記憶體,這能夠減少鎖競爭帶來的效能損耗。當執行緒快取不能滿足需求時,就會使用中心快取作為補充解決小物件的記憶體分配問題;在遇到 32KB 以上的物件時,記憶體分配器就會選擇頁堆直接分配大量的記憶體。

這種多層級的記憶體分配設計與計算機作業系統中的多級快取也有些類似,因為多數的物件都是小物件,我們可以通過執行緒快取和中心快取提供足夠的記憶體空間,發現資源不足時就從上一級元件中獲取更多的記憶體資源。

記憶體管理元件

Go 語言的記憶體分配器包含記憶體管理單元、執行緒快取、中心快取和頁堆幾個重要元件,這幾種最重要元件對應的資料結構runtime.mspanruntime.mcacheruntime.mcentralruntime.mheap

所有的 Go 語言程式都會在啟動時初始化如上圖所示的記憶體佈局,每一個處理器都會被分配一個執行緒快取runtime.mcache用於處理微物件和小物件的分配,它們會持有記憶體管理單元runtime.mspan

每個型別的記憶體管理單元都會管理特定大小的物件,當記憶體管理單元中不存在空閒物件時,它們會從runtime.mheap持有的 134 箇中心快取runtime.mcentral中獲取新的記憶體單元,中心快取屬於全域性的堆結構體runtime.mheap,它會從作業系統中申請記憶體。

在 amd64 的 Linux 作業系統上,runtime.mheap會持有 4,194,304=4*1024*1024runtime.heapArena,每一個runtime.heapArena都會管理 64MB 的記憶體,單個 Go 語言程式的記憶體上限也就是 256TB。

記憶體管理單元

runtime.mspan是 Go 語言記憶體管理的基本單元,該結構體中包含nextprev兩個欄位,它們分別指向了前一個和後一個runtime.mspan

type mspan struct {
    next *mspan
    prev *mspan
    ...
}

串聯後的上述結構體會構成如下雙向連結串列,執行時會使用runtime.mSpanList儲存雙向連結串列的頭結點和尾節點並在執行緒快取以及中心快取中使用。

頁和記憶體

每個runtime.mspan都管理npages個大小為 8KB 的頁,這裡的頁不是作業系統中的記憶體頁,它們是作業系統記憶體頁的整數倍,該結構體會使用下面的這些欄位來管理記憶體頁的分配和回收:

type mspan struct {
    startAddr uintptr // 起始地址
    npages    uintptr // 頁數
    freeindex uintptr
    allocBits  *gcBits
    gcmarkBits *gcBits
    allocCache uint64
    ...
}
  • startAddrnpages— 確定該結構體管理的多個頁所在的記憶體,每個頁的大小都是 8KB;
  • freeindex— 掃描頁中空閒物件的初始索引;
  • allocBitsgcmarkBits— 分別用於標記記憶體的佔用和回收情況;
  • allocCacheallocBits的補碼,可以用於快速查詢記憶體中未被使用的記憶體;

runtime.mspan會以兩種不同的視角看待管理的記憶體,當結構體管理的記憶體不足時,執行時會以頁為單位向堆申請記憶體:

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

當用戶程式或者執行緒向runtime.mspan申請記憶體時,該結構會使用allocCache欄位以物件為單位在管理的記憶體中快速查詢待分配的空間:

如果我們能在記憶體中找到空閒的記憶體單元,就會直接返回,當記憶體中不包含空閒的記憶體時,上一級的元件runtime.mcache可能會為該結構體新增更多的記憶體頁以滿足為更多物件分配記憶體的需求。

跨度類

runtime.spanClassruntime.mspan結構體的跨度類,它決定了記憶體管理單元中儲存的物件大小和個數:

type mspan struct {
    ...
    spanclass   spanClass
    ...
}

Go 語言的記憶體管理模組中一共包含 67 種跨度類,每一個跨度類都會儲存特定大小的物件並且包含特定數量的頁數以及物件,所有的資料都會被預選計算好並存儲在runtime.class_to_sizeruntime.class_to_allocnpages等變數中:

classbytes/objbytes/spanobjectstail wastemax waste
1 8 8192 1024 0 87.50%
2 16 8192 512 0 43.75%
3 32 8192 256 0 46.88%
4 48 8192 170 32 31.52%
5 64 8192 128 0 23.44%
6 80 8192 102 32 19.07%
66 32768 32768 1 0 12.50%

跨度類的資料

上表展示了物件大小從 8B 到 32KB,總共 66 種跨度類的大小、儲存的物件數以及浪費的記憶體空間

除了上述 66 個跨度類之外,執行時中還包含 ID 為 0 的特殊跨度類,它能夠管理大於 32KB 的特殊物件

執行緒快取

runtime.mcache是 Go 語言中的執行緒快取,它會與執行緒上的處理器一一繫結,主要用來快取使用者程式申請的微小物件。每一個執行緒快取都持有 67 * 2 個runtime.mspan,這些記憶體管理單元都儲存在結構體的alloc欄位中

執行緒快取與記憶體管理單元

執行緒快取在剛剛被初始化時是不包含runtime.mspan的,只有當用戶程式申請記憶體時才會從上一級元件獲取新的runtime.mspan滿足記憶體分配的需求。

微分配器

執行緒快取中還包含幾個用於分配微物件的欄位,下面的這三個欄位組成了微物件分配器,專門為 16 位元組以下的物件申請和管理記憶體:

type mcache struct {
    tiny             uintptr
    tinyoffset       uintptr
    local_tinyallocs uintptr
}

微分配器只會用於分配非指標型別的記憶體,上述三個欄位中tiny會指向堆中的一篇記憶體,tinyOffset是下一個空閒記憶體所在的偏移量,最後的local_tinyallocs會記錄記憶體分配器中分配的物件個數。

中心快取

runtime.mcentral是記憶體分配器的中心快取,與執行緒快取不同,訪問中心快取中的記憶體管理單元需要使用互斥鎖:

type mcentral struct {
    lock      mutex
    spanclass spanClass
    nonempty  mSpanList
    empty     mSpanList
    nmalloc uint64
}

每一箇中心快取都會管理某個跨度類的記憶體管理單元,它會同時持有兩個runtime.mSpanList,分別儲存包含空閒物件的列表和不包含空閒物件的連結串列:

中心快取和記憶體管理單元

該結構體在初始化時,兩個連結串列都不包含任何記憶體,程式執行時會擴容結構體持有的兩個連結串列,nmalloc欄位也記錄了該結構體中分配的物件個數。

記憶體管理單元

執行緒快取會通過中心快取的runtime.mcentral.cacheSpan方法獲取新的記憶體管理單元,該方法的實現比較複雜,我們可以將其分成以下幾個部分:

  1. 從有空閒物件的runtime.mspan連結串列中查詢可以使用的記憶體管理單元;
  2. 從沒有空閒物件的runtime.mspan連結串列中查詢可以使用的記憶體管理單元;
  3. 呼叫runtime.mcentral.grow從堆中申請新的記憶體管理單元;
  4. 更新記憶體管理單元的allocCache等欄位幫助快速分配記憶體;

頁堆

runtime.mheap是記憶體分配的核心結構體,Go 語言程式只會存在一個全域性的結構,而堆上初始化的所有物件都由該結構體統一管理,該結構體中包含兩組非常重要的欄位,其中一個是全域性的中心快取列表central,另一個是管理堆區記憶體區域的arenas以及相關欄位。

頁堆中包含一個長度為 134 的runtime.mcentral陣列,其中 67 個為跨度類需要scan的中心快取,另外的 67 個是noscan的中心快取:

go語言設計與實現書中提到的東西遠不止這些。還有很多更詳細更深奧的東西。如果有興趣可以買書或者看電子書。

問題:為什麼分成67類物件?微物件,小物件,大物件分類的標準是什麼,為什麼這麼分?