1. 程式人生 > 實用技巧 >Go語言記憶體分配(詳述 轉)

Go語言記憶體分配(詳述 轉)

一、記憶體管理簡介

1.1 虛擬記憶體

虛擬記憶體是當代作業系統必備的一項重要功能,對於程序而言虛擬記憶體遮蔽了底層了RAM和磁碟,並向程序提供了遠超實體記憶體大小的記憶體空間。我們看一下虛擬記憶體的分層設計。

上圖展示了某程序訪問資料,當Cache沒有命中的時候,訪問虛擬記憶體獲取資料的過程。在訪問記憶體,實際訪問的是虛擬記憶體,虛擬記憶體通過頁表檢視,當前要訪問的虛擬記憶體地址,是否已經載入到了實體記憶體。如果已經在實體記憶體,則取實體記憶體資料,如果沒有對應的實體記憶體,則從磁碟載入資料到實體記憶體,並把實體記憶體地址和虛擬記憶體地址更新到頁表。

實體記憶體就是磁碟儲存快取層,在沒有虛擬記憶體的時代,實體記憶體對所有程序是共享的,多程序同時訪問同一個實體記憶體會存在併發問題。而引入虛擬記憶體後,每個程序都有各自的虛擬記憶體,記憶體的併發訪問問題的粒度從多程序級別,可以降低到多執行緒級別。

1.2 棧和堆

我們現在從虛擬記憶體,再進一層,看虛擬記憶體中的棧和堆,也就是程序對記憶體的管理。

上圖展示了一個程序的虛擬記憶體劃分,程式碼中使用的記憶體地址都是虛擬記憶體地址,而不是實際的實體記憶體地址。棧和堆只是虛擬記憶體上2塊不同功能的記憶體區域:

  • 棧在高地址,從高地址向低地址增長
  • 堆在低地址,從低地址向高地址增長

棧和堆相比有這麼幾個好處:

  • 棧的記憶體管理簡單,分配比堆上快。
  • 棧的記憶體不需要回收,而堆需要進行回收,無論是主動free,還是被動的垃圾回收,這都需要花費額外的CPU。
  • 棧上的記憶體有更好的區域性性,堆上記憶體訪問就不那麼友好了,CPU訪問的2塊資料可能在不同的頁上,CPU訪問資料的時間可能就上去了。

1.3 堆記憶體管理

我們再進一層,當我們說記憶體管理的時候,主要是指堆記憶體的管理,因為棧的記憶體管理不需要程式去操心,這小節看下堆記憶體管理到底完成了什麼。如上圖所示主要是3部分,分別是分配記憶體塊,回收記憶體塊和組織記憶體塊。

在一個最簡單的記憶體管理中,堆記憶體最初會是一個完整的大塊,即未分配任何記憶體。當發現記憶體申請的時候,堆記憶體就會從未分配記憶體分割出一個小記憶體塊(block),然後用連結串列把所有記憶體塊連線起來。需要一些資訊描述每個記憶體塊的基本資訊,比如大小(size)、是否使用中(used)和下一個記憶體塊的地址(next),記憶體塊實際資料儲存在data中。

一個記憶體塊包含了3類資訊,如下圖所示,元資料、使用者資料和對齊欄位,記憶體對齊是為了提高訪問效率。下圖申請5Byte記憶體的時候,就需要進行記憶體對齊。

釋放記憶體實質是把使用的記憶體塊從連結串列中取出來,然後標記為未使用,當分配記憶體塊的時候,可以從未使用記憶體塊中優先查詢大小相近的記憶體塊,如果找不到,再從未分配的記憶體中分配記憶體。

上面這個簡單的設計中還沒考慮記憶體碎片的問題,因為隨著記憶體不斷的申請和釋放,記憶體上會存在大量的碎片,降低記憶體的使用率。為了解決記憶體碎片,可以將2個連續的未使用的記憶體塊合併,減少碎片。

以上就是記憶體管理的基本思路,關於基本的記憶體管理,想了解更多,可以閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這篇文章。

二. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go記憶體管理的起源,Go的記憶體管理是借鑑了TCMalloc,隨著Go的迭代,Go的記憶體管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,如果跳過TCMalloc直接去看Go的記憶體管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的原始碼細節,就可以為掌握Go的記憶體管理打好基礎,基礎打好了,後面知識才紮實。

在Linux作業系統中,其實有不少的記憶體管理庫,比如glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,為何會出現這麼多的記憶體管理庫?本質都是在多執行緒程式設計下,追求更高記憶體管理效率:更快的分配是主要目的。

我們前面提到引入虛擬記憶體後,讓記憶體的併發訪問問題的粒度從多程序級別,降低到多執行緒級別。然而同一程序下的所有執行緒共享相同的記憶體空間,它們申請記憶體時需要加鎖,如果不加鎖就存在同一塊記憶體被2個執行緒同時訪問的問題。

TCMalloc的做法是什麼呢?為每個執行緒預分配一塊快取,執行緒申請小記憶體時,可以從快取分配記憶體,這樣有2個好處:

  1. 為執行緒預分配快取需要進行1次系統呼叫,後續執行緒申請小記憶體時直接從快取分配,都是在使用者態執行的,沒有了系統呼叫,縮短了記憶體總體的分配和釋放時間,這是快速分配記憶體的第二個層次。
  2. 多個執行緒同時申請小記憶體時,從各自的快取分配,訪問的是不同的地址空間,從而無需加鎖,把記憶體併發訪問的粒度進一步降低了,這是快速分配記憶體的第三個層次。

2.1. 基本原理

下面就簡單介紹下TCMalloc,細緻程度夠我們理解Go的記憶體管理即可。

結合上圖,介紹TCMalloc的幾個重要概念:

  • Page

作業系統對記憶體管理以頁為單位,TCMalloc也是這樣,只不過TCMalloc裡的Page大小與作業系統裡的大小並不一定相等,而是倍數關係。《TCMalloc解密》裡稱x64下Page大小是8KB。

  • Span

一組連續的Page被稱為Span,比如可以有2個頁大小的Span,也可以有16頁大小的Span,Span比Page高一個層級,是為了方便管理一定大小的記憶體區域,Span是TCMalloc中記憶體管理的基本單位。

  • ThreadCache

ThreadCache是每個執行緒各自的Cache,一個Cache包含多個空閒記憶體塊連結串列,每個連結串列連線的都是記憶體塊,同一個連結串列上記憶體塊的大小是相同的,也可以說按記憶體塊大小,給記憶體塊分了個類,這樣可以根據申請的記憶體大小,快速從合適的連結串列選擇空閒記憶體塊。由於每個執行緒有自己的ThreadCache,所以ThreadCache訪問是無鎖的。

  • CentralCache

CentralCache是所有執行緒共享的快取,也是儲存的空閒記憶體塊連結串列,連結串列的數量與ThreadCache中連結串列數量相同,當ThreadCache的記憶體塊不足時,可以從CentralCache獲取記憶體塊;當ThreadCache記憶體塊過多時,可以放回CentralCache。由於CentralCache是共享的,所以它的訪問是要加鎖的。

  • PageHeap

PageHeap是對堆記憶體的抽象,PageHeap存的也是若干連結串列,連結串列儲存的是Span。當CentralCache的記憶體不足時,會從PageHeap獲取空閒的記憶體Span,然後把1個Span拆成若干記憶體塊,新增到對應大小的連結串列中並分配記憶體;當CentralCache的記憶體過多時,會把空閒的記憶體塊放回PageHeap中。

如下圖所示,分別是1頁Page的Span連結串列,2頁Page的Span連結串列等,最後是large span set,這個是用來儲存中大物件的。毫無疑問,PageHeap也是要加鎖的。

前文提到了小、中、大物件,Go記憶體管理中也有類似的概念,我們看一眼TCMalloc的定義:

  • 小物件大小:0~256KB
  • 中物件大小:257~1MB
  • 大物件大小:>1MB

小物件的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache快取都是足夠的,不需要去訪問CentralCache和HeapPage,無系統呼叫配合無鎖分配,分配效率是非常高的。

中物件分配流程:直接在PageHeap中選擇適當的大小即可,128 Page的Span所儲存的最大記憶體就是1MB。

大物件分配流程:從large span set選擇合適數量的頁面組成span,用來儲存資料。

通過本節的介紹,你應當對TCMalloc主要思想有一定了解了,我建議再回顧一下上面的內容。

三. Go記憶體管理

前文提到Go記憶體管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提高生產力的絕佳武器。這一大章節,我們先介紹Go記憶體管理和Go記憶體分配,最後涉及一點垃圾回收和記憶體釋放。

3.1. Go記憶體管理的基本概念

Go記憶體管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給大家上一幅巨集觀的圖,藉助圖一起來介紹。

  • Page

與TCMalloc中的Page相同,x64架構下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形代表1個Page。

  • Span

Span與TCMalloc中的Span相同,Span是記憶體管理的基本單位,程式碼中為mspan,一組連續的Page組成1個Span,所以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。

  • mcache

mcache與TCMalloc中的ThreadCache類似,mcache儲存的是各種大小的Span,並按Span class分類,小物件直接從mcache分配記憶體,它起到了快取的作用,並且可以無鎖訪問。但是mcache與ThreadCache也有不同點,TCMalloc中是每個執行緒1個ThreadCache,Go中是每個P擁有1個mcache。因為在Go程式中,當前最多有GOMAXPROCS個執行緒在執行,所以最多需要GOMAXPROCS個mcache就可以保證各執行緒對mcache的無鎖訪問,執行緒的執行又是與P繫結的,把mcache交給P剛剛好。

  • mcentral

mcentral與TCMalloc中的CentralCache類似,是所有執行緒共享的快取,需要加鎖訪問。它按Span級別對Span分類,然後串聯成連結串列,當mcache的某個級別Span的記憶體被分配光時,它會向mcentral申請1個當前級別的Span。

但是mcentral與CentralCache也有不同點,CentralCache是每個級別的Span有1個連結串列,mcache是每個級別的Span有2個連結串列,這和mcache申請記憶體有關,稍後我們再解釋。

  • mheap

mheap與TCMalloc中的PageHeap類似,它是堆記憶體的抽象,把從OS申請出的記憶體頁組織成Span,並儲存起來。當mcentral的Span不夠用時會向mheap申請記憶體,而mheap的Span不夠用時會向OS申請記憶體。mheap向OS的記憶體申請是按頁來的,然後把申請來的記憶體頁生成Span組織起來,同樣也是需要加鎖訪問的。

但是mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是連結串列,並且還是2棵樹,然後把Span分配到heapArena進行管理,它包含地址對映和span是否包含指標等點陣圖,這樣做的主要原因是為了更高效的利用記憶體:分配、回收和再利用。

  1. object size:程式碼裡簡稱size,指申請記憶體的物件大小。
  2. size class:程式碼裡簡稱class,它是size的級別,相當於把size歸類到一定大小的區間段,比如size[1,8]屬於size class 1,size(8,16]屬於size class 2。
  3. span class:指span的級別,但span class的大小與span的大小並沒有正比關係。span class主要用來和size class做對應,1個size class對應2個span class,2個span class的span大小相同,只是功能不同,1個用來存放包含指標的物件,一個用來存放不包含指標的物件,不包含指標物件的Span就無需GC掃描了。
  4. num of page:程式碼裡簡稱npage,代表Page的數量,其實就是Span包含的頁數,用來分配記憶體。

3.2. Go記憶體分配

Go中的記憶體分類並不像TCMalloc那樣分成小、中、大物件,但是它的小物件裡又細分了一個Tiny物件,Tiny物件指大小在1Byte到16Byte之間並且不包含指標的物件。小物件和大物件只用大小劃定,無其他區分。

小物件是在mcache中分配的,而大物件是直接從mheap分配的,從小物件的記憶體分配看起。

3.2.1. 小物件的記憶體分配

大小轉換這一小節,我們介紹了轉換表,size class從1到66共66個,程式碼中_NumSizeClasses=67代表了實際使用的size class數量,即67個,從0到67,size class 0實際並未使用到。

上文提到1個size class對應2個span class:

numSpanClasses = _NumSizeClasses * 2

numSpanClasses為span class的數量為134個,所以span class的下標是從0到133,所以上圖中mcache標註了的span class是,span class 0到span class 133。每1個span class都指向1個span,也就是mcache最多有134個span。

  • 為物件尋找span

尋找span的流程如下:

  1. 計算物件所需記憶體大小size
  2. 根據size到size class對映,計算出所需的size class
  3. 根據size class和物件是否包含指標計算出span class
  4. 獲取該span class指向的span

以分配一個不包含指標的,大小為24Byte的物件為例,根據對映表:

// class  bytes/obj  bytes/span  objects  tail waste  max 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%

對應的size class為3,它的物件大小範圍是(16,32]Byte,24Byte剛好在此區間,所以此物件的size class為3。

Size class到span class的計算如下:

// noscan為true代表物件不包含指標
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
    return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}

所以對應的span class為7,所以該物件需要的是span class 7指向的span。

span class = 3 << 1 | 1 = 7
  • 從span分配物件空間

Span可以按物件大小切成很多份,這些都可以從對映表上計算出來,以size class 3對應的span為例,span大小是8KB,每個物件實際所佔空間為32Byte,這個span就被分成了256塊,可以根據span的起始地址計算出每個物件塊的記憶體地址。

隨著記憶體的分配,span中的物件記憶體塊,有些被佔用,有些未被佔用,比如上圖,整體代表1個span,藍色塊代表已被佔用記憶體,綠色塊代表未被佔用記憶體。當分配記憶體時,只要快速找到第一個可用的綠色塊,並計算出記憶體地址即可,如果需要還可以對記憶體塊資料清零。

當span內的所有記憶體塊都被佔用時,沒有剩餘空間繼續分配物件,mcache會向mcentral申請1個span,mcache拿到span後繼續分配物件。

  • mcache向mcentral申請span

mcentral和mcache一樣,都是0~133這134個span class級別,但每個級別都儲存了2個span list,即2個span連結串列:

  1. nonempty:這個連結串列裡的span,所有span都至少有1個空閒的物件空間。這些span是mcache釋放span時加入到該連結串列的。
  2. empty:這個連結串列裡的span,所有的span都不確定裡面是否有空閒的物件空間。當一個span交給mcache的時候,就會加入到empty連結串列。

這兩個東西名稱一直有點繞,建議直接把empty理解為沒有物件空間就好了。

mcache向mcentral申請span時,mcentral會先從nonempty搜尋滿足條件的span,如果沒有找到再從emtpy搜尋滿足條件的span,然後把找到的span交給mcache。

  • mheap的span管理

mheap裡儲存了兩棵二叉排序樹,按span的page數量進行排序:

  1. free:free中儲存的span是空閒並且非垃圾回收的span。
  2. scav:scav中儲存的是空閒並且已經垃圾回收的span。

如果是垃圾回收導致的span釋放,span會被加入到scav,否則加入到free,比如剛從OS申請的的記憶體也組成的Span。

mheap中還有arenas,由一組heapArena組成,每一個heapArena都包含了連續的pagesPerArena個span,這個主要是為mheap管理span和垃圾回收服務。mheap本身是一個全域性變數,它裡面的資料,也都是從OS直接申請來的記憶體,並不在mheap所管理的那部分記憶體以內。

  • mcentral向mheap申請span

當mcentral向mcache提供span時,如果empty裡也沒有符合條件的span,mcentral會向mheap申請span。

此時,mcentral需要向mheap提供需要的記憶體頁數和span class級別,然後它優先從free中搜索可用的span。如果沒有找到,會從scav中搜索可用的span。如果還沒有找到,它會向OS申請記憶體,再重新搜尋2棵樹,必然能找到span。如果找到的span比需要的span大,則把span進行分割成2個span,其中1個剛好是需求大小,把剩下的span再加入到free中去,然後設定需要的span的基本資訊,然後交給mcentral。

  • mheap向OS申請記憶體

當mheap沒有足夠的記憶體時,mheap會向OS申請記憶體,把申請的記憶體頁儲存為span,然後把span插入到free樹。在32位系統中,mheap還會預留一部分空間,當mheap沒有空間時,先從預留空間申請,如果預留空間記憶體也沒有了,才向OS申請。

3.2.2. 大物件的記憶體分配

大物件的分配比小物件省事多了,99%的流程與mcentral向mheap申請記憶體的相同,所以不重複介紹了。不同的一點在於mheap會記錄一點大物件的統計資訊,詳情見mheap.alloc_m()。

4. Go的棧記憶體

最後提一下棧記憶體。從一個巨集觀的角度看,記憶體管理不應當只有堆,也應當有棧。每個goroutine都有自己的棧,棧的初始大小是2KB,100萬的goroutine會佔用2G,但goroutine的棧會在2KB不夠用時自動擴容,當擴容為4KB的時候,百萬goroutine會佔用4GB。

總結

Go的記憶體分配原理就不再回顧了,它主要強調兩個重要的思想:

  1. 使用快取提高效率。在儲存的整個體系中到處可見快取的思想,Go記憶體分配和管理也使用了快取,利用快取一是減少了系統呼叫的次數,二是降低了鎖的粒度、減少加鎖的次數,從這2點提高了記憶體管理效率。
  2. 以空間換時間,提高記憶體管理效率。空間換時間是一種常用的效能優化思想,這種思想其實非常普遍,比如Hash、Map、二叉排序樹等資料結構的本質就是空間換時間,在資料庫中也很常見,比如資料庫索引、索引檢視和資料快取等,再如Redis等快取資料庫也是空間換時間的思想。