這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
前幾天斷斷續續的寫了3篇關於Go語言記憶體 Clerk的文章,分別是Go語言記憶體 Clerk設計、Go語言記憶體 Clerk-FixAlloc、Go語言記憶體 Clerk-MSpan,這3篇主要是本文的前戲,其實所有的內容本可以在一篇裡寫完的,但內容實在太多了,沒精力一口氣搞定。本文將把整個記憶體 Clerk的架構以及核心組件給詳細的介紹一下,當然親自對照著翻看一下代碼才是王道。
記憶體布局結構圖
我把整個核心代碼的邏輯給抽象繪製出了這個記憶體布局圖,它基本展示了Go語言記憶體 Clerk的整體結構以及部分細節(這結構圖應該同樣適用於tcmalloc)。從此結構圖來看,記憶體 Clerk還是有一點小複雜的,但根據具體的邏輯層次可以拆成三個大模組——cache,central,heap,然後一個一個的模組分析下去,邏輯就顯得特別清晰明了了。位於結構圖最下邊的Cache
就是cache模組部分;central模組對應深藍色部分的MCentral
,central模組的邏輯結構很簡單,所以結構圖就沒有詳細的繪製了;Heap
是結構圖中的核心結構,對應heap模組,也可以看出來central是直接被Heap管理起來的,屬於Heap的子模組。
在分析記憶體 Clerk這部分源碼的時候,首先需要明確的是所有記憶體配置的入口,有了入口就可以從這裡作為起點一條線的看下去,不會有太大的障礙。這個入口就是malloc.goc源檔案中的runtime·mallocgc
函數,這個入口函數的主要工作就是分配記憶體以及觸發gc(本文將只介紹記憶體配置),在進入真正的分配記憶體之前,此入口函數還會判斷請求的是小記憶體配置還是大記憶體配置(32k作為分界線);小記憶體配置將調用runtime·MCache_Alloc
函數從Cache擷取,而大記憶體配置調用runtime·MHeap_Alloc
直接從Heap擷取。入口函數過後,就會真正的進入到具體的記憶體配置過程中去了。
在真正進入記憶體配置過程之前,還需要瞭解一下整個記憶體 Clerk是如何建立的以及初始化成什麼樣子。完成記憶體 Clerk建立初始化的函數是runtime·mallocinit
,看一下簡化的源碼:
voidruntime·mallocinit(void){// 建立mheap對象,這個是直接從作業系統分配記憶體。heap是全域的,所有線程共用,一個Go進程裡只有一個heap。if((runtime·mheap = runtime·SysAlloc(sizeof(*runtime·mheap))) == nil)runtime·throw("runtime: cannot allocate heap metadata");// 64位平台,申請一大塊記憶體位址保留區,後續所有page的申請都會從這個地址區裡分配。這個區就是結構圖中的arena。 if(sizeof(void*) == 8 && (limit == 0 || limit > (1<<30))) {arena_size = MaxMem;bitmap_size = arena_size / (sizeof(void*)*8/4);p = runtime·SysReserve((void*)(0x00c0ULL<<32), bitmap_size + arena_size);}// 初始化好heap的arena以及bitmap。runtime·mheap->bitmap = p;runtime·mheap->arena_start = p + bitmap_size;runtime·mheap->arena_used = runtime·mheap->arena_start;runtime·mheap->arena_end = runtime·mheap->arena_start + arena_size;// 初始化heap的其他內部結構,如:spanalloc、cacachealloc都FixAlloc的初始化,free、large欄位都是掛載維護span的雙向迴圈鏈表。runtime·MHeap_Init(runtime·mheap, runtime·SysAlloc);// 從heap的cachealloc從分配MCache,掛在一個線程上。m->mcache = runtime·allocmcache();}
初始化過程主要是在折騰mcache和mheap兩個部分,而mcentral在實際邏輯中是屬於mheap的子模組,所以初始化過程就沒明確的體現出來,這和我繪製的結構圖由兩大塊構造相對應。heap是所有底層線程共用的;而cache是每個線程都分別擁有一個,是獨享的。在64位平台,heap從作業系統申請的記憶體位址保留區只有136G,其中bitmap需要8G空間,因此真正可申請的記憶體就是128G。當然128G在絕大多數情況都是夠用的,但我所知道的還是有個別特殊應用的單機記憶體是超過128G的。
下面按小記憶體配置的處理路徑,從cache到central到heap的過程詳細介紹一下。
Cache
cache的實現主要在mcache.c源檔案中,結構MCache定義在malloc.h中,從cache中申請記憶體的函數原型:
void *runtime·MCache_Alloc(MCache *c, int32 sizeclass, uintptr size, int32 zeroed)
參數size
是需要申請的記憶體大小,需要知道的是這個size並不一定是我們申請記憶體的時候指定的大小,一般來說它會稍大於指定的大小。從結構圖上可以清晰看到cache有一個0到n的list數組,list數組的每個單元掛載的是一個鏈表,鏈表的每個節點就是一塊可用的記憶體,同一鏈表中的所有節點記憶體塊都是大小相等的;但是不同鏈表的記憶體大小是不等的,也就是說list數組的一個單中繼存放區的是一類固定大小的記憶體塊,不同單元裡儲存的記憶體塊大小是不等的。這就說明cache緩衝的是不同類大小的記憶體對象,當然想申請的記憶體大小最接近於哪類緩衝記憶體塊時,就分配哪類記憶體塊。list數組的0到n下標就是不同的sizeclass,n是一個固定值等於60,所以cache能夠提供60類(0
runtime·MCache_Alloc分配記憶體的過程是,根據參數sizeclass從list數組中取出一個記憶體塊鏈表,如果這個鏈表不為空白,就直接把第一個節點返回即可;如果鏈表是空,說明cache中沒有滿足此類大小的緩衝記憶體,這個時候就調用runtime·MCentral_AllocList
從central中擷取 **一批** 此類大小的記憶體塊,再把第一個節點返回使用,其他剩餘的記憶體塊掛到這個鏈表上,為下一次分配做好緩衝。
cache上的記憶體配置邏輯很簡單,就是cache取不到就到central中去取。除了記憶體的分配外,cache上還存在很多的狀態計數器,主要是用來統計記憶體的分配情況,比如:分配了多少記憶體,緩衝了多少記憶體等等。這些狀態計數器非常重要,可以用來監控我們程式的記憶體管理情況以及profile等,runtime包裡的MemStats
類的資料就是來自這些底層的計數器。
cache的釋放條件主要有兩個,一是當某個記憶體塊鏈表過長(>=256)時,就會截取此鏈表的一部分節點,返還給central;二是整個cache緩衝的記憶體過大(>=1M),同樣將每個鏈表返還一部分節點給central。
cache層在進行記憶體配置和釋放操作的時候,是沒有進行加鎖的,也不需要加鎖,因為cache是每個線程獨享的。所以cache層的主要目的就是提高小記憶體的頻繁分配釋放速度。
Central
malloc.h源檔案裡MHeap結構關於central的定義如下:
struct MHeap{。。。struct {MCentral;byte pad[CacheLineSize];} central[NumSizeClasses];。。。}
結合結構圖可以看出,在heap結構裡,使用了一個0到n的數組來儲存了一批central,並不是只有一個central對象。從上面結構定義可以知道這個數組長度位61個元素,也就是說heap裡其實是維護了61個central,這61個central對應了cache中的list數組,也就是每一個sizeclass就有一個central。所以,在cache中申請記憶體時,如果在某個sizeclass的記憶體鏈表上找不到空閑記憶體,那麼cache就會向對應的sizeclass的central擷取一批記憶體塊。注意,這裡central數組的定義裡面使用填充位元組,這是因為多線程會並發訪問不同central避免false sharing。
central的實現主要在mcentral.c源檔案,核心結構MCentral定義在malloc.h中,結構如下:
struct MCentral{Lock;int32 sizeclass;MSpan nonempty;MSpan empty;int32 nfree;};
MCentral結構中的nonempty和empty欄位是比較重要的,重點解釋一下這兩個欄位。這兩欄位都是MSpan類型,大膽的猜測一下這兩個欄位將分別掛一個span節點構造的雙向鏈表(MSpan在上一篇文章詳細介紹了),只是這個雙向鏈表的前端節點不作使用罷了。nonempty
字面意思是非空,表示這個鏈表上儲存的span節點都是非空狀態,也就是說這些span節點都有閒置記憶體;empty
表示此鏈表格儲存體的span都是空的,它們都沒有空閑可用的記憶體了。其實一開始empty鏈表就是空的,是當nonempty上的的一個span節點被用完後,就會將span移到empty鏈表中。
我們知道cache在記憶體不足的時候,會通過runtime·MCentral_AllocList
從central擷取一批記憶體塊,central其實也只有cache一個上遊使用者,看一下此函數的簡化邏輯:
int32runtime·MCentral_AllocList(MCentral *c, int32 n, MLink **pfirst){runtime·lock(c);// 第一部分:判斷nonempty是否為空白,如果是空的話,就需要從heap中擷取span來填充nonempty鏈表了。// Replenish central list if empty.if(runtime·MSpanList_IsEmpty(&c->nonempty)) {if(!MCentral_Grow(c)) {。。。。。。。}}// 第二部分:從nonempty鏈表上取一個span節點,然後從span的freelist裡擷取足夠的記憶體塊。這個span記憶體不足時,就有多少拿多少了。s = c->nonempty.next;cap = (s->npages << PageShift) / s->elemsize;avail = cap - s->ref;if(avail < n)n = avail;// First one is guaranteed to work, because we just grew the list.first = s->freelist;last = first;for(i=1; inext;}s->freelist = last->next;last->next = nil;s->ref += n;c->nfree -= n;// 第三部分:如果上面的span記憶體被取完了,就將它移到empty鏈表中去。if(n == avail) {runtime·MSpanList_Remove(s);runtime·MSpanList_Insert(&c->empty, s);}runtime·unlock(c);// 第四部分:最後將取得的記憶體塊鏈通過參數pfirst返回。*pfirst = first;return n;}
從central中擷取一批記憶體塊交給cache的過程,看上去也不怎麼複雜,只是這個過程是需要加鎖的。這裡重點要關注第一部分填充nonempty的情況,也就是central沒有空閑記憶體,需要向heap申請。這裡調用的是MCentral_Grow
函數。MCentral_Grow函數的主要工作是,首先調用runtime·MHeap_Alloc函數向heap申請一個span;然後將span裡的連續page給切分成central對應的sizeclass的小記憶體塊,並將這些小記憶體串成鏈表掛在span的freelist上;最後將span放入到nonempty鏈表中。central在無空閑記憶體的時候,向heap只要了一個span,不是多個;申請的span含多少page是根據central對應的sizeclass來確定。
central中的記憶體配置過程搞定了,看一下大概的釋放過程。cache層在釋放記憶體的時候,是將一批小記憶體塊返還給central,central在收到這些歸還的記憶體塊的時候,會把每個記憶體塊分別還給對應的span。在把記憶體塊還給span後,如果span先前是被用完了記憶體,待在empty鏈表中,那麼此刻就需要將它移動到nonempty中,表示又有可用記憶體了。在歸還小記憶體塊給span後,如果span中的所有page記憶體都收回來了,也就是沒有任何記憶體被使用了,此刻就會將這個span整體歸還給heap了。
在central這一層,記憶體的管理粒度基本就是span了,所以span是非常重要的一個工具組件。
Heap
總算來到了最大的一層heap,這是離Go程式最遠的一層,離作業系統最近的一層,這一層主要就是從作業系統申請記憶體交給central等。heap的核心結構MHeap定義在malloc.h中,一定要細看。不管是通過central從heap擷取記憶體,還是大記憶體情況跳過了cache和central直接向heap要記憶體,都是調用如下函數來請求記憶體的。
MSpan* runtime·MHeap_Alloc(MHeap *h, uintptr npage, int32 sizeclass, int32 acct, int32 zeroed)
函數原型可以看出,向heap要記憶體,不是以位元組為單位,而是要多少個page。參數npage就是需要的page數量,sizeclass等於0,就是繞過cache和central的直接找heap的大記憶體配置;central調用此函數時,sizecalss一定是1到60的一個值。從heap中申請到的所有page肯定都是連續的,並且是通過span來管理的,所以傳回值是一個span,不是page數組等。
真正在heap中執行記憶體配置邏輯的是位於mheap.c中的MHeap_AllocLocked
函數。分析此函數的邏輯前,先看一下結構圖中heap的free和large兩個域。free
是一個256個單元的數組,每個單元上儲存的都是一個span鏈表;但是不同的單元span含page的個數也是不同的,span含page的個數等於此span所在單元的下標,比如:free[5]中的span都含有5個page。如果一個span的page數超過了255,那這個span就會被放到large
鏈表中。
從heap中要記憶體,首先是根據請求的page數量到free或者large中擷取一個最合適的span。當然,如果在large鏈表中都找不到合適的span,就只能調用MHeap_Grow函數從作業系統申請記憶體,然後填充Heap,再試圖分配。拿到的span所含page數大於了請求的page數的的時候,並不會將整個span返回使用,而是對這個span進行拆分,拆為兩個span,將剩餘span重新放回到free或者large鏈表中去。因為heap面對的始終是page,如果全部返回使用,可能就會太浪費記憶體了,所以這裡只返回請求的page數是有必要的。
從heap中請求出去的span,在遇到記憶體釋放退還給heap的時候,主要也是將span放入到free或者large鏈表中。
heap比驕複雜的不是分配釋放記憶體,而是需要維護很多的中繼資料,比如結構圖還沒有介紹的map域,這個map維護的就是page到span的映射,也就是任何一塊記憶體在算出page後,就可以知道這塊記憶體屬於哪個span了,這樣才能做到正確的記憶體回收。除了map以外,還有bitmap等結構,用來標記記憶體,為gc服務。
後面對垃圾收集器(gc)的分析時,還會回頭來看heap。本文內容已經夠多了,但確實還有很多的細節沒有介紹到,比如:heap是如何從作業系統拿記憶體、heap中存在一個2分鐘定時強制gc的goroutine等等。
強烈建議熟悉C語言的,親自看看源碼,裡面有太多有趣的細節了。
注意:本文基於Go1.1.2版本代碼。
**在C語言的世界裡,記憶體管理是最頭痛的事情,同時也是最酷的事情。