理解 glibc 記憶體分配器的機制與實現

fiona8953發表於2016-11-23

一直以來,我都沉迷於堆記憶體的一切。腦子裡一直充斥著這些問題:

  • 怎樣從核心獲取堆記憶體?
  • 記憶體管理的效率如何?
  • 是什麼在管理它?核心?庫函式?還是應用程式?
  • 堆記憶體可以擴充套件嗎?

直到最近我才有時間思考並理解這些問題。所以想在本文和大家分享我的思考過程。除了我們即將討論的記憶體分配器,還有如下幾種的存在:

  • dlmalloc – General purpose allocator
  • ptmalloc2 – glibc
  • jemalloc – FreeBSD and Firefox
  • tcmalloc – Google
  • libumem – Solaris …

每個記憶體分配器都說自己可以快速分配記憶體、可擴充套件而且高效。但並不是所有的分配器都適用於我們的應用程式。像那些對記憶體異常渴求的應用程式來說,它的效能很大程度上依賴於記憶體分配器的效能。在這篇文章中,我打算只介紹 ‘glibc malloc’記憶體分配器。希望未來有機會可以介紹其他幾種。為了幫助大家深入理解 ‘glibc malloc’,本文會貼一些原始碼。現在,準備好了嗎?開始 glibc malloc 吧!

歷史

ptmalloc2 是 dlmalloc 的分支。於 2006 年釋出,在這條分支上,新增了對執行緒的支援。在正式版釋出後, ptmalloc2 被整合到 glibc 的原始碼中。在這之後,對這個記憶體分配器的修改直接在 glibc malloc 的原始碼中進行。今後,ptmalloc2 和 glibc的 malloc 之間可能差異會越來越大。

系統呼叫:從本文的分析中,我們會發現 malloc 內部要麼呼叫 brk,要麼呼叫 mmap。

執行緒化:

在早期的 linux 中,將 dlmalloc 作為預設的記憶體分配器。不過由於 ptmalloc2 對執行緒的支援,它便取代了 dlmalloc 的地位。執行緒化可以提高記憶體分配器的效能從而提高應用程式的效能。在 dlmalloc 中,如果兩個執行緒同時呼叫 malloc,由於資料結構 freelist 在所有執行緒間共享, 只有一個執行緒可以進入臨界區。這樣導致了在多執行緒的應用程式中,malloc 會很浪費時間,從而降低應用程式的效能。而在 ptmalloc2 中,當兩個執行緒同時呼叫 malloc 就不會出現這種窘境,因為每個執行緒都有自己獨立的 heap 段,管理 heap 的freelist 結構也是相互獨立的,從而不管多少執行緒同時請求記憶體,都會立即分配完成。這種每個執行緒都有獨立 heap、獨立 freelist 的行為稱為 “per thread arena”。

例子

輸出分析:

在 main 執行緒 malloc 之前:從下面的輸出我們可以看出,由於此時還沒有建立執行緒 thread1,從而沒有 heap 段,也沒有執行緒棧。

在 main 執行緒 malloc 後:從下面輸出能看出, heap segment已經建立了,並且就處於 data segment(0804b000-0806c000)之上。這就意味著: heap 記憶體是通過增加程式中斷點(program break location)來建立的(例如,系統呼叫 brk)。同時請注意,儘管程式中只申請 1000位元組的記憶體,建立的 heap 記憶體卻是 132KB。這一段連續的 heap 記憶體被稱為 “arena”。由於它是在 main 執行緒中建立,所以也被稱為 “main arena”。在這之後的記憶體請求都會使用這一塊 “arena”,直到消耗完這片空間。當 arena 耗完,可以通過增加程式中斷點的方式來增加 arena(在增加之後,top chunk 的大小也會隨之調整)。與之類似,如果 top chunk 中有過多的空閒空間, arena 的區域也會收縮。

注意: 所謂 “top chunk” 指的是 “arena” 最頂部的記憶體塊。想要了解更多知識,請看下面 ”Top Chunk” 的內容。

Main 執行緒 free 記憶體後:從下面輸出可以看出,當我們在程式中釋放已分配的記憶體後,實際上這塊記憶體並沒有立即還給作業系統。這塊已分配的記憶體(大小為 1000 位元組)僅僅是釋放給了 ‘glibc malloc’ 的庫,這個庫將這塊空閒出來的記憶體塊新增到 “main arena bin” 中(在 glibc malloc 中,freelist 結構被認為是一個個容器 bins)。在這之後,一旦使用者請求記憶體, ‘glibc malloc’ 不再從 kernel 請求 heap 記憶體,而是從這些 bin 中找到空閒的記憶體塊分配出去。只有當找不到空閒的記憶體塊時,它才會從 kernel 請求新的記憶體。

Thread1 執行緒 malloc 之前:從下面的輸出可以看出,此時沒有thread1 的 heap segment,但是執行緒棧已經建立了。

Thread1 執行緒 malloc 之後:從下面輸出可以看出,thread1的 heap segment 也已經建立了。並且就處於記憶體對映段(b7500000-b7521000 大小為 132 Kb)中,由此我們可以得出結論:不同於 main 執行緒,這裡的 heap 記憶體是通過系統呼叫 mmap 來建立的。同樣,即便使用者只請求了 1000 位元組,實際對映到程式地址空間的大小為 1M。在這 1M 記憶體中,只有 132K 的記憶體被賦予了讀寫許可權,作為該執行緒的 heap 記憶體。這一塊連續的記憶體塊被稱為 “thread arena”。

注意:如果使用者請求記憶體大小超過 128K(比如 malloc(132*1024)),並且單個 arena 無法滿足使用者需求的情況下,不管這個記憶體請求來自 main arena 還是 thread arena,都會採用 mmap 的方式來分配記憶體。

Thread1 中釋放記憶體之後: 同樣,這塊記憶體實際上也並沒有還給作業系統。而是被轉交給了’glibc malloc’,從而被新增到 thread arena bin 的空閒塊中。

Arena 的數量: 從上面幾個例子中,我們可以看到主執行緒有 main arena,執行緒1 有它自己的 thread arena。那麼問題來了,我們是否可以不管執行緒個數多少,都將執行緒與 arena 的個數做一一對映呢?答案是 NO!某些應用程式可能有很多執行緒(大於 CPU 個數),在這種情況下,如果我們給每個執行緒配一個 arena 簡直是自作孽,而且很沒意義。所以呢,我們應用程式的 arena 數目受制於系統中 CPU 個數。

 

對 32 位系統:

arena 個數 = 2 核心個

64 位系統: arena 個數 = 8 核心個數

例如: 假設有個多執行緒程式(4 個執行緒 ———— 主執行緒 + 3 個使用者執行緒) 跑在 32 位的單核系統上,執行緒數大於 2 乘以核心個數(2*1 = 2).碰到這種情況, glibc malloc 會保證所有可用的執行緒間共享多個 arena。但是這種共享是怎麼實現的呢?

在主執行緒中,當我們第一次呼叫 malloc 函式會建立 main arena,這是毋庸置疑的。

線上程1、執行緒2 中第一次呼叫 malloc 函式後,會分別給他倆建立各自的 arena。直到執行緒和 arena 達到了一對一對映。

線上程3中,第一次呼叫 malloc 函式,這時候會計算是否到了 arena 最大數。在這個例子中已經達到限額了,因此會嘗試複用其他已存在的 arena(主執行緒或執行緒1 或執行緒2的arena)。

複用: 遍歷所有可用的 arena,並嘗試給 arena 枷鎖。

如果成功枷鎖(假設 main rena 被鎖住),將這個 arena 返回給使用者。

 

如果沒有找到空閒的 arena,那麼就會堵塞在當前 arena 上。 當執行緒 3 第二次呼叫 malloc,malloc 會嘗試使用可訪問 arena(main arena)。如果 main arena 空閒,直接使用。否則執行緒3 會被堵塞,直到 main arena 空閒。這時候 main arena 就在主執行緒和執行緒 3 之間共享啦!

下面三個主要資料結構可以在 ‘glibc malloc’ 原始碼中找到:

Heap_info – Heap Header – 每個執行緒 arena 都可以有多個heap 段。每個 heap 段都有自己的 header。為什麼需要多個 heap 段呢?一開始的時候,每個執行緒都只有一個 heap 段的,但是漸漸的,堆空間用光光了,就會呼叫 mmap 來獲取新的heap 段(非連續區域)。

Mallac_state – Arena Header – 每個執行緒 arena 可以有多個堆,但是,所有這些堆只能有一個 arena header。Arena header 結構中包含 bin、top chunk、 last reminder chunk 等。

mallc_chunk – Chunk Header – 由於使用者的記憶體請求,堆會被分割成許多塊(chunk)。每個這樣的塊有著自己的 chunk header。

注意: Main arena 沒有多個 heap 段,也因此沒有 heap_info 結構。當 main arena 中堆空間耗盡了,就會呼叫 sbrk 來擴充套件堆空間(連續區域),直到heap 段頂端達到了記憶體對映段。不同於執行緒 arena,main arena 的 arena header 結構不屬於sbrk 的heap 段。它是一個全域性變數, 因此我們可以在 libc.so 的資料段中看到它的身影。

下圖很形象的展示了 main arena 和執行緒 arena 的結構(單個 heap 段):

理解 glibc 記憶體分配器的機制與實現

下圖展示了執行緒 arena 的結構(多個 heap 段):

理解 glibc 記憶體分配器的機制與實現

 

Chunk

 

heap 段中的塊可以是以下幾種型別:

已分配的記憶體塊

空閒記憶體塊

頂層塊

最終提示塊

Allocated chunk:

理解 glibc 記憶體分配器的機制與實現

Prev_size: 如果前一個記憶體塊是空閒的,這個欄位儲存前一個記憶體塊的大小。否則說明前一個記憶體塊已經分配出去了,這個欄位儲存前一個記憶體塊的使用者資料。

Size: 這個欄位儲存已分配記憶體塊的大小。最後三個位包含標誌資訊。

 

PREV_USE (P) – 前塊記憶體已分配

IS_MAPPED (M) – 記憶體塊通過 mmap 申請

NON_MAIN_ARENA (N) – 記憶體塊屬於執行緒 arena

NOTE:

Malloc_chunk 的其他欄位(例如 fd, bk)不存在於已分配記憶體塊。因此這種記憶體塊的使用者資料就儲存在這些欄位中。

為了儲存 malloc_chunk 結構,還有出於記憶體對齊的目的,我們需要額外的空間,因此將使用者請求的記憶體大小轉化為可用大小(內部表示形式)。轉化方式為:可用大小的後三位不置位,用於儲存標誌資訊。

Free Chunk:

理解 glibc 記憶體分配器的機制與實現

Prev_size : 要知道兩塊空閒記憶體塊不可能相鄰。一旦兩個記憶體塊都空閒,它們就會被整合成一整個空閒記憶體塊。也就是說,我們這塊空閒記憶體塊的前一個記憶體塊肯定是已分配的,因此, prev_size 欄位儲存的是前一個記憶體塊的使用者資料。

Size: 這個欄位儲存當前空閒記憶體塊的大小。

fd: 前向指標 – 指向同一容器中的下一塊記憶體塊(注意是容器,這裡並不是實體記憶體上的下一個 chunk)。

Bk: 後向指標 – 指向同一容器中的前一個記憶體塊(同上)。

Bins:

容器(bins): 容器指的是空閒連結串列結構 freelist。用於管理多個空閒記憶體塊。根據塊大小的不同,容器可以分為:

用於儲存這些容器的資料結構有:

fastbinsY : 這個陣列用於儲存 fast bins。

Bins : 這個陣列用於後三種容器。一共有 126 個容器,劃分為三組:

Bin 1 – Unsorted bin

Bin 2 to Bin 63 – Small bin

Bin 64 to Bin 126 – Large bin

Fast Bin:

大小為 16~80位元組的記憶體塊稱為 fast chunk。儲存這些 fast chunk 的容器就是 fast bins。在所有這些容器中, fast bins 操作記憶體效率高。

Number of bins – 10

容器 bin 的個數 – 10 每個 fast bin 都包含一個空閒記憶體塊的單連結串列。之所以採用單連結串列,是因為 fast bins 中記憶體塊的新增、刪除操作都在列表的頭尾端進行,不涉及中間段的移除操作 —— 後進先出。

Chunk size – 8 bytes apart

Chunk 大小 – 以 8 位元組劃分 Fast bins 包含一條連結串列 binlist,以 8 位元組對齊。例如,第一個 fast bin(索引為0)包含一條連結串列,它的記憶體塊大小為 16位元組,第二個 fast bin 中連結串列上的記憶體塊大小為 24 位元組,以此類推。

每個 fast bin 中的記憶體塊大小是一致的。

在 malloc 初始化期間, fast bin 最大值設定為 64 位元組(不是80位元組)。因此,預設情況下16~64 位元組的記憶體塊都被視為 fast chunks。

不存在合併 ——在 fast bins 中允許相鄰兩塊都是空閒塊,不會被自動合併。雖然不合並會導致記憶體碎片,但是它大大加速了 free 操作!!

Malloc(fast chunk) – 一開始 fast bin 最大值和fast bin 都是空的,所以不管使用者請求的塊大小怎樣,都不會走到 fast bin 的程式碼,而是 small bin code。

之後,由於記憶體釋放等原因,fast bin 不為空,通過fast bin 索引來找到對應的 binlist。

最後,binlist 中的第一塊記憶體塊從連結串列中移除並返回給使用者。

根據 fast bin 索引值找到對應的 binlist

這塊待釋放的記憶體塊新增到上面 binlist 的連結串列頭。

理解 glibc 記憶體分配器的機制與實現

Unsorted Bin:

當釋放small chunk 或者 large chunk時,不會將它們新增到 small bin 或者 large bin 中,而是新增到 unsorted bin。這種解決方案讓‘glibc mallc’多了一種重複利用空閒記憶體塊的方式。因為不需要花時間去各自的容器中去找,從而也提高了分配和釋放記憶體的效率。

Number of bins – 1

Unsorted bin 中包含空閒記憶體塊的環形雙向連結串列。

記憶體塊大小 —— 無大小限制,任意大小的記憶體塊都可以。

理解 glibc 記憶體分配器的機制與實現

Small Bin:

小於 512 位元組的記憶體塊稱為 small chunk。容納這類 small chunk 的稱為 small bins。 在記憶體分配和釋放時, Small bins 的處理速度優於 large bins(但是低於 fast bins)。

Number of bins – 62

Bins 數目 – 62

每個 small bin 都包含一條環形雙向連結串列(binlist)。之所以使用雙向連結串列是因為記憶體塊需要在連結串列中間解引用。在連結串列頭新增記憶體塊,在連結串列尾端刪除(FIFO)。

Chunk Size – 8 bytes apart

記憶體塊大小 —— 以 8位元組劃分

Small bin 也包含一條記憶體塊連結串列 binlist, 連結串列中每個記憶體塊按照 8 位元組對齊。例如:第一個 small bin(bin 2),它的連結串列中記憶體塊的大小為 16位元組,第二個 small bin (bin 3),它的連結串列中記憶體塊大小為 24 位元組,以此類推。 每個 small bin 中的記憶體塊大小一致,因此無需排序。

記憶體塊合併 ——相鄰的兩塊記憶體塊如果都是空閒的話會合併為一整塊。合併會消除記憶體碎片,但是很顯然會降低記憶體釋放的效率。

Malloc(small chunk) —— 起初,所有的 small bins 都是 NULL,因此即使使用者請求的是 small chunk,也不會走到 small bin 的程式碼,而是 unsorted bin 提供服務。 同樣,第一次呼叫 malloc 時, 會初始化 malloc_state 結構中的 small bin 和large bin (bins)。比如,bins 可以指向自身,表明它是空的。 之後當 small bin 不為空, malloc 會從 binlist 中移除最後一塊記憶體塊並返回給使用者。

Free(small chunk)—— 釋放 small chunk 時,首先檢查他的前後記憶體塊是否為空閒,如果空閒的話,需要合併記憶體塊。例如,將記憶體塊從對應的連結串列中解引用,將合併後的大記憶體塊新增到 unsorted bin 連結串列頭。

Large Bin:

大於或等於 512b 的記憶體塊就是 large chunk。儲存這類記憶體塊的容器稱為 large bins。在記憶體分配和釋放時, large bins 效率低於 small bins。

Number of bins – 63

每個 large bin 都包含一條環形雙向連結串列(也稱為 binlist)。因為 large bin 中記憶體塊可能在任何位置新增刪除。

在這 63 個 bin 中:

其中有 32 個 bins,它們所包含的環形雙向連結串列 binlist,其中每個記憶體塊按照 64 位元組劃分。例如,第一個 large bin (bin 65)包含一條 binlist,它的記憶體塊大小為 512b~568b,第二個 large bin( bin 66),它的連結串列中中每個記憶體塊的大小為 576b~632b,以此類推。

有16個 bins ,它的 binlist 中記憶體塊大小為 512 位元組。

有 8 個 bins ,它們的 binlist 中記憶體塊大小為 4096 位元組

有 4 個 bins 的 binlist 大小為 32768 位元組

有兩個 bins ,它的 binlist 中記憶體塊大小為 262144 位元組

唯一有一個 bin ,它的記憶體塊就是剩下的所有可用記憶體。

與 small bin 不同的是, large bin 中的記憶體塊大小並不是完全一樣的。降序存放,最大的記憶體塊儲存在 binlist 的 front 端,最小的儲存在 rear 端。

合併 —— 兩個空閒記憶體塊不可相鄰,會合併為單個空閒快。

Large bins 初始化為空,因此即使使用者請求 large chunk,也不會走到 large bin 的程式碼,而是走到下一個 largest bin程式碼【這部分程式碼很多,按順序排下來,在 _int_malloc 函式中,先轉化使用者請求size,根據size 判斷是哪一種請求,接下來依次為 fast bin、small bin、 large bin、以及更大的 large request 等等】。

同樣,在我們第一次呼叫 malloc 期間, malloc_state 結構中的 small bin 和 large bin 結構也會被初始化,比如指標指向它們自身以表明為空。

在這之後的請求,如果 large bin 不為空,且最大記憶體塊大於使用者請求的 size,這時候會從尾端到前端遍歷 binlist,直到找到一塊大小等於或者約等於使用者請求的記憶體塊。一旦找到,這個記憶體塊會被分割為兩塊:

其一:使用者塊(使用者請求的實際大小)——返回給使用者

其二:剩餘塊(找到的記憶體塊減去使用者請求大小後剩下的部分)——新增到 unsorted bin。

如果最大記憶體塊小於使用者請求大小,malloc 會嘗試下一個 largest bin(非空的)。下一塊 largest bin 程式碼會掃描 binmaps,去尋找下一個非空的 largest bin,一旦找到 bin,就會檢索這個 bin 中 binlist 的記憶體塊,分割記憶體塊並返回給使用者。如果找不到的話,就會接著使用 top chunk,力爭解決使用者需求。

Free(large chunk) —— 這個釋放的過程類似於 free(small chunk).

Top Chunk:

Arena 中最頂部的記憶體塊就是 top chunk。它不屬於任何 bin。如果所有的 bins 中都沒有空閒記憶體塊,就會使用 top chunk 來服務使用者。如果 top chunk 大於使用者實際請求的 size,就會被分割為兩塊:

使用者塊(大小等於使用者實際請求)

剩餘塊(剩下的那部分)

這樣,剩餘塊就是新的 top chunk。如果 top chunk 小於使用者請求塊大小,sbrk (main arena)或者 mmap (thread arena)系統呼叫就會用來擴充套件 top chunk。

Last Remainder Chunk:

最近一次分割產生的剩餘塊就是 last reminder chunk。它可以用來提高訪問區域性性,比如,連續的小記憶體塊 malloc 請求可能會使得這些分配的記憶體塊離的很近。

但是 arena 中可用的記憶體塊如此之多,哪塊有資格可以成為 last reminder chunk 呢?

當使用者請求小記憶體塊時,如果無法從 small bin 和 unsorted bin 獲取幫助,就會遍歷 binmaps 找到下一個非空 largest bin。就像之前說的,找到了非空 bin 後,取得的記憶體塊被一分為二,使用者塊返回給使用者,剩餘塊到了 unsorted bin。這個剩餘塊就是新的 last reminder chunk。

如何實現訪問區域性性?

假設使用者連續多次請求分配小記憶體塊,而 unsorted bin 中只剩下 last reminder chunk,這個 chunk 會被一分為二。還是那樣分割,返回一塊給使用者,留下剩餘塊塞給 unsorted bin。下一個請求又是這樣,一次次操作下來,記憶體分配都在最開始的 last reminder chunk 上進行,最終使得分配出去的記憶體塊都在連續區域中。

相關文章