Go 內存管理 - 上篇

本主題文章講 Go 內存分配管理,分爲上篇和下篇兩篇文章,上篇主要講內存分配相關概念和 tcmalloc 原理,下篇將具體介紹 Go 內存分配原理。這是上篇部分,核心內容在 tcmalloc, 之所以介紹 tcmalloc 是因爲 Go 的內存分配算法來源於 Google 爲 C 語言開發的 tcmalloc(thread-caching malloc)算法。理解了 tcmalloc 算法,也就基本理解了 Go 的內存分配原理。

相關概念

Go 內存管理

Go 語言採用自主管理內存,也就是由 runtime 來管理。應用程序向 runtime 申請內存,runtime 向操作系統申請內存。Go 之所以採用 runtime(運行時)管理內存,有兩大方面的原因,一是可以通過內存池和預分配等技術手段實現更好的內存使用模式,不用每次申請內存都要進行系統調用。二是 Go 是一門自帶 GC 的語言,需要在內存不用時主動釋放,開發者無需關心內存釋放問題,減輕他們的心智負擔,所以需要專門管理內存釋放的組件,這個組件放在 runtime(運行時) 非常合理。Go 內存管理涵蓋內存的申請、分配、回收以及釋放。整體流程如下圖所示

虛擬內存

虛擬內存顧名思義表示內存並不是真實存在的,與之相對的是物理內存。物理內存是真實存在的,對應到電腦上的硬件就是內存芯片。虛擬內存的引入解決了兩方面問題:

操作系統將虛擬內存劃分成整齊的小塊,每個小塊稱爲一個頁(page)。之所以分頁,原因有兩個方面,一是整齊的頁能夠避免內存碎片的問題,二是因爲每個進程使用的數據有高頻和低頻之分,有了分頁,操作系統不在從進程角度去思考哪個進程是高頻的,哪個是低頻的。只需考慮哪些頁被高頻使用、哪些頁被低頻使用。如果是低頻使用,就將它們保存到磁盤上,如果是高頻使用,讓它繼續留在內存中。

堆和棧

我們先來看 linux 下一個進程的虛擬內存空間劃分圖,可以看到棧區和堆區只是虛擬內存上兩個不同功能的內存區域。棧在高地址空間,從高地址向低地址增長,堆在低地址空間,從低地址向高地址增長。

堆區和棧區比較起來,有以下不同:

堆內存管理

結合上面的進程虛擬內存空間劃分圖,內存動態變化的區域在棧區和堆區,棧區由編譯器進行管理,所以我們主要關注堆內存的管理。如下圖所示,堆內存管理的主要內容包括堆內存的分配和回收,以及爲了方便分配和回收如何組織內存塊。

在介紹堆內存分配方法之前,我們先要了解內存對齊的概念。

內存對齊

對於基礎數據類型,如 byte, int, double 等,它們的大小和內存佔用是一致的,對於結構體而言,如果我們獲取它的 sizeof 結果,會發現這個值有可能會大於結構體內所有成員大小的總和,這時由於結構體內部進行了內存對齊。爲什麼要進行內存對齊?有兩方面的原因

下面程序中結構體 A 和 B 成員變量是一樣的,但是通過輸出可以看到他們佔用的內存大小是不一樣的。在我的電腦上輸出的分別是 24 和 16,在你的電腦上跑結果可能與我的不一致,但輸出的兩個值是不相等的。

type A struct {
 a byte
 b int
 c byte
}

type B struct {
 b int
 a byte
 c byte
}

func testMemoryAlign() {
 fmt.Println(unsafe.Sizeof(A{}), unsafe.Sizeof(B{}))
}

假如現在讓我設計一個堆內存分配方法,我該怎麼做呢?也許你會想到,按需分配,堆內存最開始的時候是一個完整的大塊,當發現有內存申請的時候,就從未分配的內存中劃分出一個小內存塊. 核心思想就是用多少分配多少,用一個標識標註已經分配到哪裏了,下一次分配的時候從標記的位置後面開始分配。

但是這裏有一個問題,已經分配的內存被釋放了,下次如何才能夠在使用?所以還要維護每個分配的內存塊信息,稱之爲內存分配的元數據信息,元數據信息需要記錄內存塊 block 的大小(size),從哪個地址開始到哪裏結束,是否在使用中 (used),下一個內存塊的位置 (next)。嗯,我們可用鏈表 LinkedList 將內存塊串聯起來。於是,得到改進後的內存分配方法,如下圖所示。

一個內存塊包含了 3 類信息,元數據信息(meta data)、對齊字段 (align) 和用戶數據(data),前面也講了內存對齊的問題。內存釋放就是把標記爲 used 從 1 改爲 0,即視爲此塊內存未使用,當再次分配內存塊的時候,可以從未使用的內存塊中查找到大小相近的內存塊。如果找不到,再從未分配的內存中分配內存。

上述簡單分配內存的方法稱之爲 FreeeList. 爲什麼說它簡單,因爲沒有考慮到內存碎片的問題。隨着內存不斷的申請和釋放,內存上會存在大量的碎片,降低了內存的使用率。根據碎片產生的原因,把碎片分爲內部碎片和外部碎片兩種類型:

tcmalloc

page

我們知道操作系統對內存管理以 page(頁) 爲單位,tcmalloc 管理內存也是以 page 爲單位。不過需要留意的是 tcmalloc 裏的 page 大小與操作系統裏的大小並不一定相等,tcmalloc 裏的 page 大小一般是操作系統的數倍。在 X64 系統下,tcmalloc 裏 page 的默認大小爲 8KB. 多數 linux 系統中一頁大小爲 4KB,也就是說 tcmalloc 中的一頁對應 linux 中兩頁。整個內存空間可以看成是一個一個 page 連接起來的,tcmalloc 將並不是只將堆內存劃分成 page 的集合,而是將整個虛擬內存空間都看做是 page 的集合。從內存地址 0x0 開始到最大內存地址,每 8KB 劃分爲一個 page, 每一個 page 都有唯一的 pageID,從低地址向高地址,pageID 是遞增的。下圖是以 64 位系統 page 劃分的示意圖。

span

span 是 tcmalloc 管理內存的基本單位,內存分配組件基本都是圍繞 span 展開。我們先來看 span 的定義,一個或多個連續的 page 組成一個 span. tcmalloc 以 span 爲單位向系統操作系統申請內存。注意 span 定義中的 2 個要素:1 個或多個,連續的 page.

如上圖所示,span1 佔用了 2 個 page,span2 佔用了 3 個 page,span3 和 span4 分別佔用了 1 個 page.

下面是 span 的結構體定義

struct Span {
  PageID        start;          // Span描述的內存的起始地址
  Length        length;         // Span頁面數量
  Span*         next;           // Span由雙向鏈表組成,PageHeap和CentralFreeList中都用的到
  Span*         prev;           
  void*         objects;        // Span會在CentralFreeList中拆分成由object組成的free list
  unsigned int  refcount : 16;  // Span的object被引用次數,當refcount=0時,表示此Span沒有被使用
  unsigned int  sizeclass : 8;  // Span屬於的size class
  unsigned int  location : 2;   // Span在的位置是IN_USE還是normal還是returned
  unsigned int  sample : 1;    
  enum { IN_USE, ON_NORMAL_FREELIST, ON_RETURNED_FREELIST };
};

通過上面 Span 的結構體定義可以看到,它有一個 start 和 length 字段,start 記錄了該 span 是從哪個 pageID 開始,length 記錄該 span 含有幾個 page, 所以 start 和 length 可以確定一個 span 對應的 pages. 一個 span 要麼被拆分成多個相同 size class 的小對象用於小對象分配,要麼作爲一個整體用於中對象或大對象分配。當用作小對象分配時,span 中的 sizeclass 字段記錄了其對應的 size class. 具體小對象、中對象和大對象的劃分見下文的說明。在 span 結構中,還有兩個 span 類型的指針變量(prev,next),分別指向前一個 span 和下一個 span, 將多個 span 以鏈表形式串起來。span 結構體中的 location 標識該 span 處於什麼狀態。span 一共有三種狀態: IN_USE,ON_NORMAL_FREELIST,ON_RETURNED_FREELIST. 怎麼理解這些狀態呢?站在 pageHeap 的角度,pageHeap 具體概念見下文。這裏先記住 pageHeap 是管理 span 的。IN_USE 顧名思義就是正在使用中,就是說拆分的小對象已分配給 centralCache 或者 threadCache 或者是已分配給應用程序了。因爲從 pageHeap 角度看,span 不管是被 mcentralCache 還是被 threadCache,還是被應用程序使用,反正都是被使用了。ON_NORMAL_FREELIST 和 ON_RETURNED_FREELIST 都可以看做爲空閒狀態,兩者的區別是:ON_RETURNED_FREELIST 是指 span 對應的內存已被 pageHeap 釋放給操作系統了。在 linux 下,對於 MAP_PRIVATE|MAP_ANONYMOUS 的內存採用 madvise 來回收。注意這裏的內存即使返回給操作系統了,這片地址還是可以訪問的,在下一次訪問的時候會導致 page fault.

span 結構體中的 objects、refcount、sizeclass 字段屬於 centralFreeList 管理的內容。這裏先不細說。

pageMap

通過前面 span 結構的介紹,可以看到知道了 span,就可以知道它維護的 page. 但反過來,現在有一個 page, 怎麼才能知道它屬於哪個 span? 對,這就是 pageMap 要維護的,pageMap 維護了 page 到 span 的關係。pageMap 緩存了 pageID 到 span 的對應關係,下面以 32 系統進行說明,採用兩級 pageMap. 結構如下圖所示

root 數組大小爲 512,每個數組中的元素又是 1024 個 void 的數組,數組索引爲 pageID, 數組元素爲 page 所屬的 span 的指針,所以總的數組元素個數爲 5121024=2^19, 也就是能夠維護 2^19 個 page. 使用兩級 map 可以減少 tcmalloc 元素數據的內存佔用,因爲初始化只會給第一層 root_數組分配空間,佔用的大小爲 5124B=2048B=2KB. 第二層只有在實際使用到的時候纔會實際分配內存。如果不採用兩級使用一級,初始化的時候就需要分配 2^19*4B=2MB 的內存。

小對象、中對象和大對象

在申請對象的時候,需要給對象分配空間。不同的對象的大小往往是不同的,爲了方便內存分配管理,tcmalloc 將對象佔用的內存大小劃分爲三種類型:小對象、中對象和大對象。

上面介紹完了內存分配的幾個基本概念,下面將分別介紹小對象、中對象和大對象是如何分配的,以及涉及到的三大分配組件:threadCache、centralCache 和 pageHeap.

小對象分配

size class

對於小對象的分配,即對象的大小在 256KB(含) 以內的分配,tcmalloc 定義了 86 個 size class(從 class0 到 class85),每個 size class 都維護了一個可以分配的空閒列表 (FreeList). FreeList 中的每一項稱爲一個 object,同一個 class 的空閒列表中的每個 object 大小都是相同的。在申請小對象內存時,tcmalloc 會根據大小映射到某個 class 中。比如說,在申請 1 到 8 個 Byte 的大小時,會被映射到 class1 中,分配 8 個字節的大小。申請 9 到 16 字節大小時,會被映射到 class2 中,實際分配 16 個字節的大小。依次類推,直到 class85.

SizeMap

tcmalloc 通過 SizeMap 類維護了上面小對象分配具體的映射關係,摘錄的部分映射關係如下:

nUzFOS

threadCache

對每個線程,tcmalloc 都爲它保存了一份單獨的緩存,稱之爲 threadCache. 每個 threadCache 中有一組 FreeList, 每個 size class 都有一個單獨的 FreeList, 所以這一組 FreeList 爲 87 個,FreeList 緩存了還未被應用程序使用的空閒對象。threadCache 結構的核心字段如下

class FreeList {
private:
 void*    list_;
 uint32_t length_;
 uint32_t lowater_;
 uint32_t max_length_;
};

class ThreadCache {
private:
     FreeList      list_[kNumClasses];    
};

threadCache 結構體中 list_字段即爲 FreeList 數組,在實現 FreeList 的時候 tcmalloc 採用一種小技巧,沒有使用 next 指針指向下一個位置,而是直接使用了 void *list,將每個 object 的前 8 個字節存儲下一個 object 地址。小對象的分配直接從待分配對象映射到的 FreeList 中返回一個空閒對象,同理,在小對象回收的時候也是將其重新放回 threadCache 中的 FreeList 中。由於每個線程一個 threadCache, 不存在數據競爭,因此從 ThreadCache 中申請或回收內存是不需加鎖的,速度很快。爲了方便統計數據,各線程的 threadCache 構成了一個雙向鏈表。得到 threadCache 的結構圖如下

通過 threadCache 分配小內存的流程爲:

  1. 通過 SizeMap 查找要分配的內存對應的 size class 以及 object size 大小

  2. 查看當前 threadCache 的 free list 是否爲空,如果 free list 不爲空,直接從列表中移除第一個 object 並返回,這個過程不需要加鎖,所以速度很快

  3. 如果 free list 爲空,從 centralFreeList 中獲取若干個 object 到 threadCache 對應的 size class 列表中,並取出其中一個 object 返回, 具體獲取的 object 個數由慢啓動算法決定,爲了防止空間浪費。

  4. 如果 centralFreeList 中 object 也不夠,則 centralFreeList 會向 pageHeap 申請一連串頁面, 具體爲 class_to_pages 個,並將申請的頁面切割成一系列的 object,之後再將部分 object 轉移給 threadCache.

centralCache

centralCache 可以理解爲中間人角色,它邏輯上位於 threadCache 和 pageHeap 之間,它本質上是一個 CentralFreeListPadded 類型的數組,數組大小爲 87(size class 的數量),每個 size class 對應數組中的一個元素,數組中的元素是 CentralFreeListPadded 類型,CentralFreeListPadded 是 CentralFreeList 的子類,不同在於它是字節對齊的。下面我們來看它的結構體定義

static CentralFreeListPadded central_cache_[kNumClasses];
  class CentralFreeList {
  private:
      SpinLock lock_;
      size_t size_class_;
      Span empty_;       
      Span nonempty_;
  };

threadCache 之間共享這些 centralFreeList. 所以,使用 centralCache 時需要加鎖。通過上面 CentralFreeList 的結構體定義可以看到,它包含 3 個核心字段,size_class_表示 size class, 另外兩個都是 Span 類型,因爲 CentralFreeList 真正管理的就是 span. empty_鏈表保存了已經沒有空閒對象可用的 span, 也就是 span 中的 object 對象都已劃分出去了。nonempty_鏈表保存了還有空閒對象可用的 span.

centralFreeList 的功能之一就是從 pageHeap 中取出部分 span 並按照預定大小(在 SizeMap 中有定義)將其拆分成大小固定的 object 供 threadCache 共享,CentralFreeList 從 PageHeap 拿個一個 span 後會進行如下處理:

  1. 通過調用 PageHeap:RegisterSizeClass() 將 span 中的 location 賦值爲 IN_USE, 並將 sizeclass 填充爲指定的值

  2. 通過 SizeMap 獲取 size class 對應的 object 大小,然後將 span 切片,通過 void *objects 保存爲 object 的 free list

  3. 將 span 掛載到 nonempty_鏈表中

當 threadCache 從 centralFreeList 獲取 object 時:

  1. 從 nonempty_鏈表中獲取第一個 span, 並從 span 中的 objects 鏈表中獲取可用 object 返回,每分配一個 object,span 的 refcount 加 1

  2. 當 span 無可用 object 時,將此 span 從 nonempty_鏈表摘除,掛載到 empty_鏈表,當 object 回收歸還給此 span 時會重新將其掛載到 nonempty_鏈表

當 threadCache 歸還 object 給 centralFreeList 時:

  1. 找到此 object 對應的 span, 掛載到 objects 鏈表表頭,如果 span 在 empty_鏈表,則重新掛接到 nonempty_鏈表

  2. span 的 refcount 減 1,如果 refcount 變爲 0,表示此 span 所有的 object 都已經歸還,將此 span 從 centralFreeList 鏈表中摘除,並將它退還給 pageHeap.

pageHeap

pageHeap 主要功能是向操作系統申請內存,然後管理劃分後的內存。pageHeap 主要結構爲

struct SpanList {
   Span        normal;
   Span        returned;
};

SpanList free_[kMaxPages]; // kMaxPages = 128

SpanSet large_normal_;
SpanSet large_returned_;

可以看到 pageHeap 中有一個大小爲 128 的 free_數組,數組的元素爲 SpanList 類型,SpanList 是一個含有兩個 Span 的結構體。128page 以內的 span 稱爲小 span,128page 以上的 span 稱爲大 span. 小 span 的管理在 free_數組,大 span 的管理由 large_normal_和 large_returned_維護。對於小 span, 每個大小的 span 都用一個單獨的鏈表來管理,大 span 用 std::set. pageHeap 是分開管理 ON_NORMAL_FREELIST 和 ON_RETURNED_FREELIST 狀態的 span 的。

SpanList 中的 normal span 是頁明確映射到進程地址空間的 span,returned span 是 tcmalloc 已經通過 madvise 歸還給操作系統空間,調用 madvise 相當於取消了虛擬內存與物理內存的映射,之所以保留 returned 鏈表,是因爲雖然通過 madvise 歸還給了操作系統,但是操作系統有可能還沒有收回這部分內存空間,可以直接使用,即使操作系統已經回收了這部分內存,重新使用這部分空間時內核會引發 page fault 並將其映射到一塊全零的內存空間,不影響使用。

下面看 pageHeap 是如何申請內存和釋放內存的。調用 Span *New(Length n) 申請內存時:

  1. free_[KMaxPages] 中大於等於 n 的 free list 會被遍歷一遍,查找是否有合適大小的 span, 如果有,則將此 span 從 free list 中移除,如果 span 大小比 n 大,則會將 span 進行 Carve, 將剩餘的 span 重新放入到 free list 中。例如,n 爲 2,但是在遍歷時發現 free_[2] 對應的鏈表中已經沒有空閒的 span 了,但是在 free_[3] 中找到了空閒的 span,這時候會將此 span 切分成兩份,一份對應 2 個 page, 返回給調用方,另一份對應 1 個 page,掛載到 free_[1] 中,供下次使用

  2. 如果 free_中的 normal 和 returned 鏈表中找不到合適的 span, 則從 spanSet 中查大小最合適的 span. 這時候需要遍歷 large_normal_和 large_returned_列表

  3. 如果 large_normal_和 large_returned_也沒有可用的 span, 則通過 tcmalloc_SystemAlloc() 向操作系統申請,並返回指定大小 span. 每次都會嘗試申請至少 128 頁,以供下次使用

調用 Delete(Span *span) 進行內存回收:

  1. 將 span 重新放入 pageHeap 的 free list 中,如果 span 的左右相鄰的 span 也是空閒的,則將它們從 free list 中去除,然後合併爲同一個 span 再掛載到 free list

  2. 檢查是否需要釋放內存給操作系統,如果需要,則釋放

中對象分配

佔用內存大小在 (256kB,1MB] 範圍內的對象被稱爲中對象,中等對象的分配與小對象的分配方式不同。通過前面小對象的分配流程,小對象的分配過程是 threadCache<->centralCache<->pageHeap. 中等對象分配是直接從 pageHeap 分配的。256KB 大小的內存佔用 32 個 page(每個 page 8K),1MB 的內存佔用 128 個 page. tcmalloc 會將應用程序申請的內存大小向上取整到整數個 page, 然後向 pageHeap 申請一個指定 page 數量的 span 並返回起始地址。假設要申請的 100 個 page 的內存,具體分配流程爲:

  1. 在 pageHeap 中從 100 個 page 的 span 鏈表開始,直到 128 個 page 的 span 鏈表,按順序找到第一個非空的鏈表

  2. 取出此非空鏈表中的一個 span, 假設有 102 個 page,將這個切分成兩個 span, 一個 span 的大小爲 100 個 page, 用作結果返回,另一個 span 大小爲 2 個 page, 重新插入到 2 個 page 的 span 鏈表中

  3. 如果找不到非空鏈表,則將此次分配看做大對象分配對待,大對象的分配見下面的介紹

大對象分配

超過 128 個 page 的內存分配視爲大對象分配,大對象的分配也是直接從 pageHeap 分配的。從前面 pageHeap 結構字段可以看到,free_中最大的 page 是 128 個。所以大對象的分配不能走 free_中的鏈表了,而是一個按 span 大小排序的有序 set, 方便按大小搜索。假設現在要分配一個 130 個 page 的大對象,具體的分配流程如下:

  1. 從 pageHeap 的 span set 中取一個大小爲 130 個 page 的 span. 如果恰好有 130 個 page 的 span,就分配出去。如果 set 中找不到 130 個 page 的 span, 則從大於 130 個 page 的 span 中挑選 pages 最小的那個,假設爲 135 個 page。

  2. 對挑選出來的 span 進行切分成兩個 span. 一個 span 大小爲 130 個 page, 作爲返回結果,另一個 span 大小爲 (135-130)=5 個 page, 因爲 5<128, 所以將其插入到小 span 鏈表中,如果切分後剩餘的 span 中的 page 大於 128,則插入到大 span 的 set 中

  3. 如果找不到合適的 span, 則會使用 sbrk 或 mmap 向操作系統申請新的內存以生產新的 span, 並重新執行中對象或大對象的分配算法

總結

tcmalloc 算法總結起來,不外乎兩點,將所有分配的對象的大小劃分成了小對象、中對象和大對象,小對象的分配需要經過 threadCache,centralCache,pageHeap 三層緩存,中對象和大對象的分配直接從 pageHeap 分配,相當於只有一層緩存。增高分配流程概覽如下圖。

圖解 TCMalloc[1] 詳解 Go 語言的內存模型及堆的分配管理 [2]TCMalloc 解密 [3]

Reference

[1] 圖解 TCMalloc: https://zhuanlan.zhihu.com/p/29216091

[2] 詳解 Go 語言的內存模型及堆的分配管理: https://zhuanlan.zhihu.com/p/76802887

[3] TCMalloc 解密: https://wallenwang.com/2018/11/tcmalloc/#ftoc-heading-26

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/bVOLsfLE3mgmglLaWawiRQ