Go語言記憶體管理簡述

2020-07-16 10:05:23
記憶體管理是非常重要的一個話題。關於程式語言是否應該支援垃圾回收就有個搞笑的爭論,一派人認為,記憶體管理太重要了,而手動管理麻煩且容易出錯,所以我們應該交給機器去管理。另一派人則認為,記憶體管理太重要了!所以如果交給機器管理我不能放心。爭論歸爭論,但不管哪一派,大家對記憶體管理重要性的認同都是勿庸質疑的。

Go語言是一門帶垃圾回收的語言,Go語言中有指標,卻沒有C語言中那麼靈活的指標操作。大多數情況下是不需要使用者自己去管理記憶體的,但是理解Go語言是如何做記憶體管理對於寫出優秀的程式是大有幫助的。

記憶體池概述

Go語言的記憶體分配器採用了跟 tcmalloc 庫相同的實現,是一個帶記憶體池的分配器,底層直接呼叫作業系統的 mmap 等函數。

作為一個記憶體池,它的基本部分包括以下幾部分:
  • 首先,它會向作業系統申請大塊記憶體,自己管理這部分記憶體。
  • 然後,它是一個池子,當上層釋放記憶體時它不實際歸還給作業系統,而是放回池子重複利用。
  • 接著,記憶體管理中必然會考慮的就是記憶體碎片問題,如果盡量避免記憶體碎片,提高記憶體利用率,像作業系統中的首次適應,最佳適應,最差適應,夥伴演算法都是一些相關的背景知識。
  • 另外,Go語言是一個支援 goroutine 這種多執行緒的語言,所以它的記憶體管理系統必須也要考慮在多執行緒下的穩定性和效率問題。

在多執行緒方面,很自然的做法就是每條執行緒都有自己的原生的記憶體,然後有一個全域性的分配鏈,當某個執行緒中記憶體不足後就向全域性分配鏈中申請記憶體。這樣就避免了多執行緒同時存取共用變數時的加鎖。

在避免記憶體碎片方面,大塊記憶體直接按頁為單位分配,小塊記憶體會切成各種不同的固定大小的塊,申請做任意位元組記憶體時會向上取整到最接近的塊,將整塊分配給申請者以避免隨意切割。

Go語言中為每個系統執行緒分配一個原生的 MCache(前面介紹的結構體 M 中的 MCache 域),少量的地址分配就直接從 MCache 中分配,並且定期做垃圾回收,將執行緒的 MCache 中的空閒記憶體返回給全域性控制堆。小於 32K 為小物件,大物件直接從全域性控制堆上以頁(4k)為單位進行分配,也就是說大物件總是以頁對齊的。一個頁可以存入一些相同大小的小物件,小物件從本地記憶體連結串列中分配,大物件從中心記憶體堆中分配。

大約有 100 種記憶體塊類別,每一個類別都有自己物件的空閒連結串列。小於 32kB 的記憶體分配被向上取整到對應的尺寸類別,從相應的空閒連結串列中分配。一頁記憶體只可以被分裂成同一種尺寸類別的物件,然後由空閒連結串列分配器管理。

分配器的資料結構包括:
  • FixAlloc:固定大小(128kB)的物件的空閒鏈分配器,被分配器用於管理儲存;
  • MHeap:分配堆,按頁的粒度進行管理(4kB);
  • MSpan:一些由 MHeap 管理的頁;
  • MCentral:對於給定尺寸類別的共用的 free list;
  • MCache:用於小物件的每 M 一個的 cache。

我們可以將Go語言的記憶體管理看成一個兩級的記憶體管理結構 MHeap 和 MCache。上面一級管理的基本單位是頁,用於分配大物件,每次分配都是若干連續的頁,也就是若干個 4KB 的大小。使用的資料結構是 MHeap 和 MSpan,用 BestFit 演算法做分配,用位示圖做回收。下面一級管理的基本單位是不同型別的固定大小的物件,更像一個物件池而不是記憶體池,用參照計數做回收。下面這一級使用的資料結構是 MCache。

MHeap

Go語言的程式在啟動之初,會一次性從作業系統那裡申請一大塊記憶體作為記憶體池。這塊記憶體空間會放在一個叫 Mheap 的 struct 中管理,Mheap 負責將這一整塊記憶體切割成不同的區域,並將其中一部分的記憶體切割成合適的大小,分配給使用者使用。

MHeap 層次用於直接分配較大(>32kB)的記憶體空間,以及給 MCentral 和 MCache 等下層提供空間。它管理的基本單位是 MSpan。MSpan 是一個表示若干連續記憶體頁的資料結構,簡化後如下:
struct MSpan
{
    PageID     start;          // starting page number
    uintptr     npages;        // number of pages in span
};
通過一個基地址 +(頁號*頁大小),就可以定位到這個 MSpan 的實際的地址空間了,基地址是在 MHeap 中儲存了的,MHeap 負責將 MSpan 組織和管理起來。

free 是一個分配池,從 free[i] 出去的 MSpan 每個大小都是 i 頁的,總共 256 個槽位。再大了之後,大小就不固定了,由 large 鏈起來。

Go語言在這裡使用的類似於位示圖,可以看到 MHeap 中有一個。

MSpan  *map[1<<MHeapMap_Bits];

這個陣列是一個用於將記憶體地址對映成 MSpan 結構體的表,每個記憶體頁都會對應到 map 中的一個 MSpan 指標,通過 map 就能夠將地址對映到相應的 MSpan。

具體做法是,給定一個地址,通過 (地址-基地址) / 頁大小得到頁號,再通過 map[頁號] 就得到了相應的 MSpan 結構體。

前面說過 MSpan 就是若干連續的頁。那麼,一個多頁的 MSpan 會佔用 map 陣列中的多項,有多少頁就會佔用多少項。比如,map[502] 到 map[505] 可能都指向同一個 MSpan,這個 MSpan 的 PageId 為 502,npages 為 4。

回收一個 MSpan 時,首先會查詢它相鄰的頁的址址,再通過 map 對映得到該頁對應的 MSpan,如果 MSpan 的 state 是未使用,則可以將兩者進行合併。最後會將這頁或者合併後的頁歸還到 free[] 分配池或者是 large 中。

MCache

MCache 層次跟 MHeap 層次非常像,也是一個分配池,對每個尺寸的類別都有一個空閒物件的單連結串列。Go 的記憶體管理可以看成一個兩級的層次,上面一級是 MHeap 層次,而 MCache 則是下面一級。

每個 M 都有一個自己的區域性記憶體快取 MCache,這樣分配小物件的時候直接從 MCache 中分配,就不用加鎖了,這是Go語言能夠在多執行緒環境中高效地進行記憶體分配的重要原因。MCache 就是用於小物件的分配。

分配一個小物件(<32kB)的過程:
  • 將小物件大小向上取整到一個對應的尺寸類別,查詢相應的 MCache 的空閒連結串列。如果連結串列不空,直接從上面分配一個物件。這個過程可以不必加鎖。
  • 如果 MCache 自由鏈是空的,通過從 MCentral 自由鏈拿一些物件進行補充。
  • 如果 MCentral 自由鏈是空的,則通過 MHeap 中拿一些頁對 MCentral 進行補充,然後將這些記憶體截斷成規定的大小。
  • 如果 MHeap 是空的,或者沒有足夠大小的頁了,從作業系統分配一組新的頁(至少 1MB)。分配一大批的頁分攤了從作業系統分配的開銷。

注意上面表述中的用詞“一些”。從 MCentral 中拿“一些“自由鏈物件補充 MCache 分攤了存取 MCentral 加鎖的開銷。從 MHeap 中分配“一些“的頁補充 MCentral 分攤了對 MHeap 加鎖的開銷。

釋放一個小物件也是類似的過程:
  • 查詢物件所屬的尺寸類別,將它新增到 MCache 的自由鏈。
  • 如果 MCache 自由鏈太長或者 MCache 記憶體大多了,則返還一些到 MCentral 自由鏈。
  • 如果在某個範圍的所有的物件都歸還到 MCentral 鏈了,則將它們歸還到頁堆。

歸還到 MHeap 就結束了,目前還是沒有歸還到作業系統。

MCache 層次僅用於分配小物件,分配和釋放大的物件則是直接使用 MHeap 的,跳過 MCache 和 MCentral 自由鏈。MCache 和 MCentral 中自由鏈的小物件可能是也可能不是清 0 了的。物件的第 2 個位元組作為標記,當它是 0 時,此物件是清 0 了的。頁堆中的總是清零的,當一定範圍的物件歸還到頁堆時,需要先清零。這樣才符合Go語言規範:分配一個物件不進行初始化,它的預設值是該型別的零值。

MCentral

MCentral 層次是作為 MCache 和 MHeap 的連線。對上,它從 MHeap 中申請 MSpan;對下,它將 MSpan 劃分成各種小尺寸物件,提供給 MCache 使用。
struct  MCentral
{
    Lock;
    int32  sizeclass;
    MSpan  nonempty;
    MSpan  empty;
    int32  nfree;
};
注意,每個 MSpan 只會分割成同種大小的物件。每個 MCentral 也是只含同種大小的物件。MCentral 結構中,有一個 nonempty 的 MSpan 鏈和一個 empty 的 MSpan 鏈,分別表示還有空間的 MSpan 和裝滿了物件的 MSpan。

分配還是很簡單,直接從 MCentral->nonempty->freelist 分配。如果發現 freelist 空了,則說明這一塊 MSpan 滿了,將它移到 MCentral->empty。

前面說過,回收比分配複雜,因為涉及到合併。這裡的合併是通過參照計數實現的。從 MSpan 中每劃出一個物件,則參照計數加一,每回收一個物件,則參照計數減一。如果減完之後參照計數為零了,則說明這整塊的 MSpan 已經沒被使用了,可以將它歸還給 MHeap。