2萬字|30張圖帶你領略glibc記憶體管理精髓(因為OOM導致了上千萬損失)

高效能架構探索發表於2021-11-05

 

  

前言

 

大家好,我是雨樂。

 

5年前,在上家公司的時候,因為程式OOM造成了上千萬的損失,當時用了一個月的時間來分析glibc原始碼,最終將問題徹底解決。

 

最近在逛知乎的時候,發現不少人有對malloc/free有類似的疑惑,恰好自己有閱讀過這方面的原始碼,所以將之前的原始碼閱讀筆記整理了下,用了大概3周的時間寫了這篇文章,分析glibc的記憶體管理精髓,相信對c/c++從業者都會有用。

 

由於本文涉及知識點較多,因此為了方便閱讀,提供了PDF版本,可以留言獲取

 

 

提綱

主要內容

 

 

 

1 寫在前面

原始碼分析本身就很枯燥乏味,尤其是要將其寫成通俗易懂的文章,更是難上加難。

 

本文儘可能的從讀者角度去進行分析,重點寫大家關心的點,必要的時候,會貼出部分原始碼,以加深大家的理解,儘可能的通過本文,讓大家理解記憶體分配釋放的本質原理。

 

接下來的內容,乾貨滿滿,對於你我都是一次收穫的過程。主要從記憶體佈局、glibc記憶體管理、malloc實現以及free實現幾個點來帶你遨遊glibc記憶體管理的實質。最後,針對專案中的問題,指出瞭解決方案。

2 背景

幾年前,在上家公司做了一個專案,暫且稱之為SeedService吧。SeedService從kafka獲取feed流的轉、評、贊資訊,載入到記憶體。因為每天資料不一樣,所以kafka的topic也是按天來切分,整個server內部,類似於雙指標實現,當天0點釋放頭一天的資料。

 

專案上線,一切執行正常。

 

但是幾天之後,程式開始無緣無故的消失。開始定位問題,最終發現是因為記憶體暴增導致OOM,最終被作業系統kill掉。

 

弄清楚了程式消失的原因之後,開始著手分析記憶體洩漏。在解決了幾個記憶體洩露的潛在問題以後,發現系統在高壓力高併發環境下長時間執行仍然會發生記憶體暴增的現象,最終程式因OOM被作業系統殺掉。

 

由於記憶體管理不外乎三個層面,使用者管理層,C 執行時庫層,作業系統層,在作業系統層發現程式的記憶體暴增,同時又確認了使用者管理層沒有記憶體洩露,因此懷疑是 C 執行時庫的問題,也就是Glibc 的記憶體管理方式導致了程式的記憶體暴增。

 

問題縮小到glibc的記憶體管理方面,把下面幾個問題弄清楚,才能解決SeedService程式消失的問題:

  • glibc 在什麼情況下不會將記憶體歸還給作業系統?

  • glibc 的記憶體管理方式有哪些約束?適合什麼樣的記憶體分配場景?

  • 我們的系統中的記憶體管理方式是與glibc 的記憶體管理的約束相悖的?

  • glibc 是如何管理記憶體的?

帶著上面這些問題,大概用了將近一個月的時間分析了glibc執行時庫的記憶體管理程式碼,今天將當時的筆記整理了出來,希望能夠對大家有用。

3 基礎

Linux 系統在裝載 elf 格式的程式檔案時,會呼叫 loader 把可執行檔案中的各個段依次載入到從某一地址開始的空間中。

 

使用者程式可以直接使用系統呼叫來管理 heap 和mmap 對映區域,但更多的時候程式都是使用 C 語言提供的 malloc()和 free()函式來動態的分配和釋放記憶體。stack區域是唯一不需要對映,使用者卻可以訪問的記憶體區域,這也是利用堆疊溢位進行攻擊的基礎。

 

3.1 程式記憶體佈局

計算機系統分為32位和64位,而32位和64位的程式佈局是不一樣的,即使是同為32位系統,其佈局依賴於核心版本,也是不同的。

 

在介紹詳細的記憶體佈局之前,我們先描述幾個概念:

  • 棧區(Stack)— 儲存程式執行期間的本地變數和函式的引數,從高地址向低地址生長

  • 堆區(Heap)動態記憶體分配區域,通過 malloc、new、free 和 delete 等函式管理

  • 未初始化變數區(BSS)— 儲存未被初始化的全域性變數和靜態變數

  • 資料區(Data)— 儲存在原始碼中有預定義值的全域性變數和靜態變數

  • 程式碼區(Text)— 儲存只讀的程式執行程式碼,即機器指令

3.1.1 32位程式記憶體佈局

基於核心版本的不同,在32位系統中,程式內的佈局也不一樣。

3.1.1.1 經典佈局

在Linux核心2.6.7以前,程式記憶體佈局如下圖所示。

 

32位預設佈局

 

 

在該記憶體佈局示例圖中,mmap 區域與棧區域相對增長,這意味著堆只有 1GB 的虛擬地址空間可以使用,繼續增長就會進入 mmap 對映區域, 這顯然不是我們想要的。這是由於 32 模式地址空間限制造成的,所以核心引入了另一種虛擬地址空間的佈局形式。但對於 64 位系統,因為提供了巨大的虛擬地址空間,所以64位系統就採用的這種佈局方式。

3.1.1.2 預設佈局

如上所示,由於經典記憶體佈局具有空間侷限性,因此在核心2.6.7以後,就引入了下圖這種預設程式佈局方式。

 

32位經典佈局

 

 

從上圖可以看到,棧至頂向下擴充套件,並且棧是有界的。堆至底向上擴充套件,mmap 對映區域至頂向下擴充套件,mmap 對映區域和堆相對擴充套件,直至耗盡虛擬地址空間中的剩餘區域,這種結構便於C執行時庫使用 mmap 對映區域和堆進行記憶體分配。

3.1.2 64位程式記憶體佈局

如之前所述,64位程式記憶體佈局方式由於其地址空間足夠,且實現方便,所以採用的與32位經典記憶體佈局的方式一致,如下圖所示:

64位程式佈局

3.2 作業系統記憶體分配函式

在之前介紹記憶體佈局的時候,有提到過,heap 和mmap 對映區域是可以提供給使用者程式使用的虛擬記憶體空間。那麼我們該如何獲得該區域的記憶體呢?

 

作業系統提供了相關的系統呼叫來完成記憶體分配工作。

  • 對於heap的操作,作業系統提供了brk()函式,c執行時庫提供了sbrk()函式。

  • 對於mmap對映區域的操作,作業系統提供了mmap()和munmap()函式。

sbrk(),brk() 或者 mmap() 都可以用來向我們的程式新增額外的虛擬記憶體。而glibc就是使用這些函式來向作業系統申請虛擬記憶體,以完成記憶體分配的。

這裡要提到一個很重要的概念,記憶體的延遲分配,只有在真正訪問一個地址的時候才建立這個地址的物理對映,這是 Linux 記憶體管理的基本思想之一。Linux 核心在使用者申請記憶體的時候,只是給它分配了一個線性區(也就是虛擬記憶體),並沒有分配實際實體記憶體;只有當使用者使用這塊記憶體的時候,核心才會分配具體的物理頁面給使用者,這時候才佔用寶貴的實體記憶體。核心釋放物理頁面是通過釋放線性區,找到其所對應的物理頁面,將其全部釋放的過程。

記憶體分配

程式的記憶體結構,在核心中,是用mm_struct來表示的,其定義如下:

 struct mm_struct {
  ...
  unsigned long (*get_unmapped_area) (struct file *filp,
  unsigned long addr, unsigned long len,
  unsigned long pgoff, unsigned long flags);
  ...
  unsigned long mmap_base; /* base of mmap area */
  unsigned long task_size; /* size of task vm space */
  ...
  unsigned long start_code, end_code, start_data, end_data;
  unsigned long start_brk, brk, start_stack;
  unsigned long arg_start, arg_end, env_start, env_end;
  ...
 }

在上述mm_struct結構中:

  • [start_code,end_code)表示程式碼段的地址空間範圍。

  • [start_data,end_start)表示資料段的地址空間範圍。

  • [start_brk,brk)分別表示heap段的起始空間和當前的heap指標。

  • [start_stack,end_stack)表示stack段的地址空間範圍。

  • mmap_base表示memory mapping段的起始地址。

C語言的動態記憶體分配基本函式是 malloc(),在 Linux 上的實現是通過核心的 brk 系統呼叫。brk()是一個非常簡單的系統呼叫, 只是簡單地改變mm_struct結構的成員變數 brk 的值。

mm_struct

3.2.1 Heap操作

在前面有提過,有兩個函式可以直接從堆(Heap)申請記憶體,brk()函式為系統呼叫,sbrk()為c庫函式。

 

系統呼叫通常提過一種最小的功能,而庫函式相比系統呼叫,則提供了更復雜的功能。在glibc中,malloc就是呼叫sbrk()函式將資料段的下界移動以來代表記憶體的分配和釋放。sbrk()函式在核心的管理下,將虛擬地址空間對映到記憶體,供malloc()函式使用。

 

下面為brk()函式和sbrk()函式的宣告。

 #include 
 int brk(void *addr);
 
 void *sbrk(intptr_t increment);

需要說明的是,當sbrk()的引數increment為0時候,sbrk()返回的是程式當前brk值。increment 為正數時擴充套件 brk 值,當 increment 為負值時收縮 brk 值。

3.2.2 MMap操作

在Linux系統中我們可以使用mmap用來在程式虛擬記憶體地址空間中分配地址空間,建立和實體記憶體的對映關係。

共享記憶體

mmap()函式將一個檔案或者其它物件對映進記憶體。檔案被對映到多個頁上,如果檔案的大小不是所有頁的大小之和,最後一個頁不被使用的空間將會清零。

 

munmap 執行相反的操作,刪除特定地址區域的物件對映。

 

函式的定義如下:

 #include <sys/mman.h>
 void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
 
 int munmap(void *addr, size_t length);

· 對映關係分為以下兩種:

  • 檔案對映: 磁碟檔案對映程式的虛擬地址空間,使用檔案內容初始化實體記憶體。

  • 匿名對映: 初始化全為0的記憶體空間

對映關係是否共享,可以分為:

  • 私有對映(MAP_PRIVATE)

    • 多程式間資料共享,修改不反應到磁碟實際檔案,是一個copy-on-write(寫時複製)的對映方式。

  • 共享對映(MAP_SHARED)

    • 多程式間資料共享,修改反應到磁碟實際檔案中。

因此,整個對映關係總結起來分為以下四種:

  • 私有檔案對映 多個程式使用同樣的實體記憶體頁進行初始化,但是各個程式對記憶體檔案的修改不會共享,也不會反應到物理檔案中

  • 私有匿名對映

    • mmap會建立一個新的對映,各個程式不共享,這種使用主要用於分配記憶體(malloc分配大記憶體會呼叫mmap)。例如開闢新程式時,會為每個程式分配虛擬的地址空間,這些虛擬地址對映的實體記憶體空間各個程式間讀的時候共享,寫的時候會copy-on-write。

  • 共享檔案對映

    • 多個程式通過虛擬記憶體技術共享同樣的實體記憶體空間,對記憶體檔案的修改會反應到實際物理檔案中,也是程式間通訊(IPC)的一種機制。

  • 共享匿名對映

    • 這種機制在進行fork的時候不會採用寫時複製,父子程式完全共享同樣的實體記憶體頁,這也就實現了父子程式通訊(IPC)。

這裡值得注意的是,mmap只是在虛擬記憶體分配了地址空間,只有在第一次訪問虛擬記憶體的時候才分配實體記憶體。

 

在mmap之後,並沒有在將檔案內容載入到物理頁上,只有在虛擬記憶體中分配了地址空間。當程式在訪問這段地址時,通過查詢頁表,發現虛擬記憶體對應的頁沒有在實體記憶體中快取,則產生"缺頁",由核心的缺頁異常處理程式處理,將檔案對應內容,以頁為單位(4096)載入到實體記憶體,注意是隻載入缺頁,但也會受作業系統一些排程策略影響,載入的比所需的多。

下面的內容將是本文的重點中的重點,對於瞭解記憶體佈局以及後面glibc的記憶體分配原理至關重要,必要的話,可以多閱讀幾次。

4 概述

在前面,我們有提到在堆上分配記憶體有兩個函式,分別為brk()系統呼叫和sbrk()c執行時庫函式,在記憶體對映區分配記憶體有mmap函式。

 

現在我們假設一種情況,如果每次分配,都直接使用brk(),sbrk()或者mmap()函式進行多次記憶體分配。如果程式頻繁的進行記憶體分配和釋放,都是和作業系統直接打交道,那麼效能可想而知。

 

這就引入了一個概念,記憶體管理

 

本節大綱如下:

2萬字|30張圖帶你領略glibc記憶體管理精髓(因為OOM導致了上千萬損失)

4.1 記憶體管理

記憶體管理是指軟體執行時對計算機記憶體資源的分配和使用的技術。其最主要的目的是如何高效,快速的分配,並且在適當的時候釋放和回收記憶體資源。

 

一個好的記憶體管理器,需要具有以下特點:

1、跨平臺、可移植 通常情況下,記憶體管理器向作業系統申請記憶體,然後進行再次分配。所以,針對不同的作業系統,記憶體管理器就需要支援作業系統相容,讓使用者在跨平臺的操作上沒有區別。

 

2、浪費空間小 記憶體管理器管理記憶體,如果記憶體浪費比較大,那麼顯然這就不是一個優秀的記憶體管理器。 通常說的記憶體碎片,就是浪費空間的罪魁禍首,若記憶體管理器中有大量的記憶體碎片,它們是一些不連續的小塊記憶體,它們總量可能很大,但無法使用,這顯然也不是一個優秀的記憶體管理器。

 

3、速度快 之所以使用記憶體管理器,根本原因就是為了分配/釋放快。

 

4、除錯功能 作為一個 C/C++程式設計師,記憶體錯誤可以說是我們的噩夢,上一次的記憶體錯誤一定還讓你記憶猶新。記憶體管理器提供的除錯功能,強大易用,特別對於嵌入式環境來說,記憶體錯誤檢測工具缺乏,記憶體管理器提供的除錯功能就更是不可或缺了。

4.2 管理方式

記憶體管理的管理方式,分為 手動管理自動管理 兩種。

 

所謂的手動管理,就是使用者在申請記憶體的時候使用malloc等函式進行申請,在需要釋放的時候,需要呼叫free函式進行釋放。一旦用過的記憶體沒有釋放,就會造成記憶體洩漏,佔用更多的系統記憶體;如果在使用結束前釋放,會導致危險的懸掛指標,其他物件指向的記憶體已經被系統回收或者重新使用。

 

自動管理記憶體由程式語言的記憶體管理系統自動管理,在大多數情況下不需要使用者的參與,能夠自動釋放不再使用的記憶體。

4.2.1 手動管理

手動管理記憶體是一種比較傳統的記憶體管理方式,C/C++ 這類系統級的程式語言不包含狹義上的自動記憶體管理機制,使用者需要主動申請或者釋放記憶體。

經驗豐富的工程師能夠精準的確定記憶體的分配和釋放時機,人肉的記憶體管理策略只要做到足夠精準,使用手動管理記憶體的方式可以提高程式的執行效能,也不會造成記憶體安全問題。

 

但是,畢竟這種經驗豐富且能精準確定記憶體和分配釋放實際的使用者還是比較少的,只要是人工處理,總會帶來一些錯誤,記憶體洩漏和懸掛指標基本是 C/C++ 這類語言中最常出現的錯誤,手動的記憶體管理也會佔用工程師的大量精力,很多時候都需要思考物件應該分配到棧上還是堆上以及堆上的記憶體應該何時釋放,維護成本相對來說還是比較高的,這也是必然要做的權衡。

4.2.2 自動管理

自動管理記憶體基本是現代程式語言的標配,因為記憶體管理模組的功能非常確定,所以我們可以在程式語言的編譯期或者執行時中引入自動的記憶體管理方式,最常見的自動記憶體管理機制就是垃圾回收,不過除了垃圾回收之外,一些程式語言也會使用自動引用計數輔助記憶體的管理。

 

自動的記憶體管理機制可以幫助工程師節省大量的與記憶體打交道的時間,讓使用者將全部的精力都放在核心的業務邏輯上,提高開發的效率;在一般情況下,這種自動的記憶體管理機制都可以很好地解決記憶體洩漏和懸掛指標的問題,但是這也會帶來額外開銷並影響語言的執行時效能。

4.1 常見的記憶體管理器

1、ptmalloc

ptmalloc是隸屬於glibc(GNU Libc)的一款記憶體分配器,現在在Linux環境上,我們使用的執行庫的記憶體分配(malloc/new)和釋放(free/delete)就是由其提供。

 

2、 BSD Malloc:BSD Malloc 是隨 4.2 BSD 發行的實現,包含在 FreeBSD 之中,這個分配程式可以從預先確實大小的物件構成的池中分配物件。它有一些用於物件大小的size 類,這些物件的大小為 2 的若干次冪減去某一常數。所以,如果您請求給定大小的一個物件,它就簡單地分配一個與之匹配的 size 類。這樣就提供了一個快速的實現,但是可能會浪費記憶體。

 

3、Hoard:編寫 Hoard 的目標是使記憶體分配在多執行緒環境中進行得非常快。因此,它的構造以鎖的使用為中心,從而使所有程式不必等待分配記憶體。它可以顯著地加快那些進行很多分配和回收的多執行緒程式的速度。

 

4、TCMalloc:Google 開發的記憶體分配器,在不少專案中都有使用,例如在 Golang 中就使用了類似的演算法進行記憶體分配。它具有現代化記憶體分配器的基本特徵:對抗記憶體碎片、在多核處理器能夠 scale。據稱,它的記憶體分配速度是 glibc2.3 中實現的 malloc的數倍。

5 glibc之記憶體管理(ptmalloc)

 

因為本次事故就是用的執行庫函式new/delete進行的記憶體分配和釋放,所以本文將著重分析glibc下的記憶體分配庫ptmalloc。

 

本節大綱如下:

2萬字|30張圖帶你領略glibc記憶體管理精髓(因為OOM導致了上千萬損失)

 

在c/c++中,我們分配記憶體是在堆上進行分配,那麼這個堆,在glibc中是怎麼表示的呢?

我們先看下堆的結構宣告:

 typedef struct _heap_info
 {
  mstate ar_ptr;           /* Arena for this heap. */
  struct _heap_info *prev; /* Previous heap. */
  size_t size;             /* Current size in bytes. */
  size_t mprotect_size;     /* Size in bytes that has been mprotected
                              PROT_READ|PROT_WRITE. */
  /* Make sure the following data is properly aligned, particularly
      that sizeof (heap_info) + 2 * SIZE_SZ is a multiple of
      MALLOC_ALIGNMENT. */
  char pad[-6 * SIZE_SZ & MALLOC_ALIGN_MASK];

 

在堆的上述定義中,ar_ptr是指向分配區的指標,堆之間是以連結串列方式進行連線,後面我會詳細講述程式佈局下,堆的結構表示圖。

 

在開始這部分之前,我們先了解下一些概念。

5.1 分配區(arena)

ptmalloc對程式記憶體是通過一個個Arena來進行管理的。

在ptmalloc中,分配區分為主分配區(arena)和非主分配區(narena),分配區用struct malloc_state來表示。主分配區和非主分配區的區別是 主分配區可以使用sbrk和mmap向os申請記憶體,而非分配區只能通過mmap向os申請記憶體

 

當一個執行緒呼叫malloc申請記憶體時,該執行緒先檢視執行緒私有變數中是否已經存在一個分配區。如果存在,則對該分配區加鎖,加鎖成功的話就用該分配區進行記憶體分配;失敗的話則搜尋環形連結串列找一個未加鎖的分配區。如果所有分配區都已經加鎖,那麼malloc會開闢一個新的分配區加入環形連結串列並加鎖,用它來分配記憶體。釋放操作同樣需要獲得鎖才能進行。

 

需要注意的是,非主分配區是通過mmap向os申請記憶體,一次申請64MB,一旦申請了,該分配區就不會被釋放,為了避免資源浪費,ptmalloc對分配區是有個數限制的。

對於32位系統,分配區最大個數 = 2 * CPU核數 + 1

對於64位系統,分配區最大個數 = 8 * CPU核數 + 1

 

堆管理結構:

 struct malloc_state {
  mutex_t mutex;                 /* Serialize access. */
  int flags;                       /* Flags (formerly in max_fast). */
  #if THREAD_STATS
  /* Statistics for locking. Only used if THREAD_STATS is defined. */
  long stat_lock_direct, stat_lock_loop, stat_lock_wait;
  #endif
  mfastbinptr fastbins[NFASTBINS];   /* Fastbins */
  mchunkptr top;
  mchunkptr last_remainder;
  mchunkptr bins[NBINS * 2];
  unsigned int binmap[BINMAPSIZE];   /* Bitmap of bins */
  struct malloc_state *next;           /* Linked list */
  INTERNAL_SIZE_T system_mem;
  INTERNAL_SIZE_T max_system_mem;
  };

malloc_state

每一個程式只有一個主分配區和若干個非主分配區。主分配區由main執行緒或者第一個執行緒來建立持有。主分配區和非主分配區用環形連結串列連線起來。分配區內有一個變數mutex以支援多執行緒訪問。

環形連結串列連結的分配區

在前面有提到,在每個分配區中都有一個變數mutex來支援多執行緒訪問。每個執行緒一定對應一個分配區,但是一個分配區可以給多個執行緒使用,同時一個分配區可以由一個或者多個的堆組成,同一個分配區下的堆以連結串列方式進行連線,它們之間的關係如下圖:

執行緒-分配區-堆

一個程式的動態記憶體,由分配區管理,一個程式內有多個分配區,一個分配區有多個堆,這就組成了複雜的程式記憶體管理結構。

2萬字|30張圖帶你領略glibc記憶體管理精髓(因為OOM導致了上千萬損失)

需要注意幾個點:

  • 主分配區通過brk進行分配,非主分配區通過mmap進行分配

  • 非主分配區雖然是mmap分配,但是和大於128K直接使用mmap分配沒有任何聯絡。大於128K的記憶體使用mmap分配,使用完之後直接用ummap還給系統

  • 每個執行緒在malloc會先獲取一個area,使用area記憶體池分配自己的記憶體,這裡存在競爭問題

  • 為了避免競爭,我們可以使用執行緒區域性儲存,thread cache(tcmalloc中的tc正是此意),執行緒區域性儲存對area的改進原理如下:

  • 如果需要在一個執行緒內部的各個函式呼叫都能訪問、但其它執行緒不能訪問的變數(被稱為static memory local to a thread 執行緒區域性靜態變數),就需要新的機制來實現。這就是TLS。

  • thread cache本質上是在static區為每一個thread開闢一個獨有的空間,因為獨有,不再有競爭

  • 每次malloc時,先去執行緒區域性儲存空間中找area,用thread cache中的area分配存在thread area中的chunk。當不夠時才去找堆區的area

5.2 chunk

ptmalloc通過malloc_chunk來管理記憶體,給User data前儲存了一些資訊,使用邊界標記區分各個chunk。

 

chunk定義如下:

 struct malloc_chunk {  
  INTERNAL_SIZE_T     prev_size;   /* Size of previous chunk (if free). */  
  INTERNAL_SIZE_T     size;         /* Size in bytes, including overhead. */  
   
  struct malloc_chunk* fd;           /* double links -- used only if free. */  
  struct malloc_chunk* bk;  
   
  /* Only used for large blocks: pointer to next larger size. */  
  struct malloc_chunk* fd_nextsize;     /* double links -- used only if free. */  
  struct malloc_chunk* bk_nextsize;
 };  
  • prev_size: 如果前一個chunk是空閒的,則該域表示前一個chunk的大小,如果前一個chunk不空閒,該域無意義。

一段連續的記憶體被分成多個chunk,prev_size記錄的就是相鄰的前一個chunk的size,知道當前chunk的地址,減去prev_size便是前一個chunk的地址。prev_size主要用於相鄰空閒chunk的合併

  • size :當前 chunk 的大小,並且記錄了當前 chunk 和前一個 chunk 的一些屬性,包括前一個 chunk 是否在使用中,當前 chunk 是否是通過 mmap 獲得的記憶體,當前 chunk 是否屬於非主分配區。

  • fd 和 bk : 指標 fd 和 bk 只有當該 chunk 塊空閒時才存在,其作用是用於將對應的空閒 chunk 塊加入到空閒chunk 塊連結串列中統一管理,如果該 chunk 塊被分配給應用程式使用,那麼這兩個指標也就沒有用(該 chunk 塊已經從空閒鏈中拆出)了,所以也當作應用程式的使用空間,而不至於浪費。

  • fd_nextsize 和 bk_nextsize: 當前的 chunk 存在於 large bins 中時, large bins 中的空閒 chunk 是按照大小排序的,但同一個大小的 chunk 可能有多個,增加了這兩個欄位可以加快遍歷空閒 chunk ,並查詢滿足需要的空閒 chunk , fd_nextsize 指向下一個比當前 chunk 大小大的第一個空閒 chunk , bk_nextszie 指向前一個比當前 chunk 大小小的第一個空閒 chunk 。(同一大小的chunk可能有多塊,在總體大小有序的情況下,要想找到下一個比自己大或小的chunk,需要遍歷所有相同的chunk,所以才有fd_nextsize和bk_nextsize這種設計) 如果該 chunk 塊被分配給應用程式使用,那麼這兩個指標也就沒有用(該chunk 塊已經從 size 鏈中拆出)了,所以也當作應用程式的使用空間,而不至於浪費。

正如上面所描述,在ptmalloc中,為了儘可能的節省記憶體,使用中的chunk和未使用的chunk在結構上是不一樣的。

非空閒chunk

在上圖中:

  • chunk指標指向chunk開始的地址

  • mem指標指向使用者記憶體塊開始的地址。

  • p=0時,表示前一個chunk為空閒,prev_size才有效

  • p=1時,表示前一個chunk正在使用,prev_size無效 p主要用於記憶體塊的合併操作;ptmalloc 分配的第一個塊總是將p設為1, 以防止程式引用到不存在的區域

  • M=1 為mmap對映區域分配;M=0為heap區域分配

  • A=0 為主分配區分配;A=1 為非主分配區分配。

 

與非空閒chunk相比,空閒chunk在使用者區域多了四個指標,分別為fd,bk,fd_nextsize,bk_nextsize,這幾個指標的含義在上面已經有解釋,在此不再贅述。

空閒chunk

5.3 空閒連結串列(bins)

使用者呼叫free函式釋放記憶體的時候,ptmalloc並不會立即將其歸還作業系統,而是將其放入空閒連結串列(bins)中,這樣下次再呼叫malloc函式申請記憶體的時候,就會從bins中取出一塊返回,這樣就避免了頻繁呼叫系統呼叫函式,從而降低記憶體分配的開銷。

 

在ptmalloc中,會將大小相似的chunk連結起來,叫做bin。總共有128個bin供ptmalloc使用。

根據chunk的大小,ptmalloc將bin分為以下幾種:

  • fast bin

  • unsorted bin

  • small bin

  • large bin

從前面malloc_state結構定義,對bin進行分類,可以分為fast bin和bins,其中unsorted bin、small bin 以及 large bin屬於bins。

 

在glibc中,上述4中bin的個數都不等,如下圖所示:

bin

5.3.1 fast bin

程式在執行時會經常需要申請和釋放一些較小的記憶體空間。當分配器合併了相鄰的幾個小的 chunk 之後,也許馬上就會有另一個小塊記憶體的請求,這樣分配器又需要從大的空閒記憶體中切分出一塊,這樣無疑是比較低效的,故而,malloc 中在分配過程中引入了 fast bins。

 

在前面malloc_state定義中

 mfastbinptr fastbins[NFASTBINS]; // NFASTBINS  = 10
  1. fast bin的個數是10個

  2. 每個fast bin都是一個單連結串列(只使用fd指標)。這是因為fast bin無論是新增還是移除chunk都是在連結串列尾進行操作,也就是說,對fast bin中chunk的操作,採用的是LIFO(後入先出)演算法:新增操作(free記憶體)就是將新的fast chunk加入連結串列尾,刪除操作(malloc記憶體)就是將連結串列尾部的fast chunk刪除。

  3. chunk size:10個fast bin中所包含的chunk size以8個位元組逐漸遞增,即第一個fast bin中chunk size均為16個位元組,第二個fast bin的chunk size為24位元組,以此類推,最後一個fast bin的chunk size為80位元組。

  4. 不會對free chunk進行合併操作。這是因為fast bin設計的初衷就是小記憶體的快速分配和釋放,因此係統將屬於fast bin的chunk的P(未使用標誌位)總是設定為1,這樣即使當fast bin中有某個chunk同一個free chunk相鄰的時候,系統也不會進行自動合併操作,而是保留兩者。

  5. malloc操作:在malloc的時候,如果申請的記憶體大小範圍在fast bin的範圍內,則先在fast bin中查詢,如果找到了,則返回。否則則從small bin、unsorted bin以及large bin中查詢。

  6. free操作:先通過chunksize函式根據傳入的地址指標獲取該指標對應的chunk的大小;然後根據這個chunk大小獲取該chunk所屬的fast bin,然後再將此chunk新增到該fast bin的鏈尾即可。

 

下面是fastbin結構圖:

fastbin

5.3.2 unsorted bin

unsorted bin 的佇列使用 bins 陣列的第一個,是bins的一個緩衝區,加快分配的速度。當使用者釋放的記憶體大於max_fast或者fast bins合併後的chunk都會首先進入unsorted bin上。

 

在unsorted bin中,chunk的size 沒有限制,也就是說任何大小chunk都可以放進unsorted bin中。這主要是為了讓“glibc malloc機制”能夠有第二次機會重新利用最近釋放的chunk(第一次機會就是fast bin機制)。利用unsorted bin,可以加快記憶體的分配和釋放操作,因為整個操作都不再需要花費額外的時間去查詢合適的bin了。    使用者malloc時,如果在 fast bins 中沒有找到合適的 chunk,則malloc 會先在 unsorted bin 中查詢合適的空閒 chunk,如果沒有合適的bin,ptmalloc會將unsorted bin上的chunk放入bins上,然後到bins上查詢合適的空閒chunk。

 

與fast bin所不同的是,unsortedbin採用的遍歷順序是FIFO。

 

unsorted bin結構圖如下:

unsorted bin

5.3.3 small bin

大小小於512位元組的chunk被稱為small chunk,而儲存small chunks的bin被稱為small bin。陣列從2開始編號,前62個bin為small bins,small bin每個bin之間相差8個位元組,同一個small bin中的chunk具有相同大小。    每個small bin都包括一個空閒區塊的雙向迴圈連結串列(也稱binlist)。free掉的chunk新增在連結串列的前端,而所需chunk則從連結串列後端摘除。    兩個毗連的空閒chunk會被合併成一個空閒chunk。合併消除了碎片化的影響但是減慢了free的速度。    分配時,當samll bin非空後,相應的bin會摘除binlist中最後一個chunk並返回給使用者。

 

在free一個chunk的時候,檢查其前或其後的chunk是否空閒,若是則合併,也即把它們從所屬的連結串列中摘除併合併成一個新的chunk,新chunk會新增在unsorted bin連結串列的前端。

 

small bin也採用的是FIFO演算法,即記憶體釋放操作就將新釋放的chunk新增到連結串列的front end(前端),分配操作就從連結串列的rear end(尾端)中獲取chunk。

small bin

5.3.4 large bin

大小大於等於512位元組的chunk被稱為large chunk,而儲存large chunks的bin被稱為large bin,位於small bins後面。large bins中的每一個bin分別包含了一個給定範圍內的chunk,其中的chunk按大小遞減排序,大小相同則按照最近使用時間排列。

 

兩個毗連的空閒chunk會被合併成一個空閒chunk。

 

small bins 的策略非常適合小分配,但我們不能為每個可能的塊大小都有一個 bin。對於超過 512 位元組(64 位為 1024 位元組)的塊,堆管理器改為使用“large bin”。

 

63個large bin中的每一個都與small bin的操作方式大致相同,但不是儲存固定大小的塊,而是儲存大小範圍內的塊。每個large bin 的大小範圍都設計為不與small bin 的塊大小或其他large bin 的範圍重疊。換句話說,給定一個塊的大小,這個大小對應的正好是一個small bin或large bin。

 

在這63個largebins中:第一組的32個largebin鏈依次以64位元組步長為間隔,即第一個largebin鏈中chunksize為1024-1087位元組,第二個large bin中chunk size為1088~1151位元組。第二組的16個largebin鏈依次以512位元組步長為間隔;第三組的8個largebin鏈以步長4096為間隔;第四組的4個largebin鏈以32768位元組為間隔;第五組的2個largebin鏈以262144位元組為間隔;最後一組的largebin鏈中的chunk大小無限制。

 

在進行malloc操作的時候,首先確定使用者請求的大小屬於哪一個large bin,然後判斷該large bin中最大的chunk的size是否大於使用者請求的size。如果大於,就從尾開始遍歷該large bin,找到第一個size相等或接近的chunk,分配給使用者。如果該chunk大於使用者請求的size的話,就將該chunk拆分為兩個chunk:前者返回給使用者,且size等同於使用者請求的size;剩餘的部分做為一個新的chunk新增到unsorted bin中。

 

如果該large bin中最大的chunk的size小於使用者請求的size的話,那麼就依次檢視後續的large bin中是否有滿足需求的chunk,不過需要注意的是鑑於bin的個數較多(不同bin中的chunk極有可能在不同的記憶體頁中),如果按照上一段中介紹的方法進行遍歷的話(即遍歷每個bin中的chunk),就可能會發生多次記憶體頁中斷操作,進而嚴重影響檢索速度,所以glibc malloc設計了Binmap結構體來幫助提高bin-by-bin檢索的速度。Binmap記錄了各個bin中是否為空,通過bitmap可以避免檢索一些空的bin。如果通過binmap找到了下一個非空的large bin的話,就按照上一段中的方法分配chunk,否則就使用top chunk(在後面有講)來分配合適的記憶體。

 

large bin的free 操作與small bin一致,此處不再贅述。

large bin

 

上述幾種bin,組成了程式中最核心的分配部分:bins,如下圖所示:

bins結構

 

5.4 特殊chunk

上節內容講述了幾種bin以及各種bin記憶體的分配和釋放特點,但是,僅僅上面幾種bin還不能夠滿足,比如假如上述bins不能滿足分配條件的時候,glibc提出了另外幾種特殊的chunk供分配和釋放,分別為top chunk,mmaped chunk 和last remainder chunk。

5.4.1 top trunk

top chunk是堆最上面的一段空間,它不屬於任何bin,當所有的bin都無法滿足分配要求時,就要從這塊區域裡來分配,分配的空間返給使用者,剩餘部分形成新的top chunk,如果top chunk的空間也不滿足使用者的請求,就要使用brk或者mmap來向系統申請更多的堆空間(主分配區使用brk、sbrk,非主分配區使用mmap)。

 

在free chunk的時候,如果chunk size不屬於fastbin的範圍,就要考慮是不是和top chunk挨著,如果挨著,就要merge到top chunk中。

5.4.2 mmaped chunk

當分配的記憶體非常大(大於分配閥值,預設128K)的時候,需要被mmap對映,則會放到mmaped chunk上,當釋放mmaped chunk上的記憶體的時候會直接交還給作業系統。 (chunk中的M標誌位置1)

5.4.3 last remainder chunk

Last remainder chunk是另外一種特殊的chunk,這個特殊chunk是被維護在unsorted bin中的。

如果使用者申請的size屬於small bin的,但是又不能精確匹配的情況下,這時候採用最佳匹配(比如申請128位元組,但是對應的bin是空,只有256位元組的bin非空,這時候就要從256位元組的bin上分配),這樣會split chunk成兩部分,一部分返給使用者,另一部分形成last remainder chunk,插入到unsorted bin中。

 

當需要分配一個small chunk,但在small bins中找不到合適的chunk,如果last remainder chunk的大小大於所需要的small chunk大小,last remainder chunk被分裂成兩個chunk,其中一個chunk返回給使用者,另一個chunk變成新的last remainder chunk。

 

last remainder chunk主要通過提高記憶體分配的區域性性來提高連續malloc(產生大量 small chunk)的效率。

5.5 chunk 切分

chunk釋放時,其長度不屬於fastbins的範圍,則合併前後相鄰的chunk。

 

首次分配的長度在large bin的範圍,並且fast bins中有空閒chunk,則將fastbins中的chunk與相鄰空閒的chunk進行合併,然後將合併後的chunk放到unsorted bin中,如果fastbin中的chunk相鄰的chunk並非空閒無法合併,仍舊將該chunk放到unsorted bin中,即能合併的話就進行合併,但最終都會放到unsorted bin中。

 

fastbins,small bin中都沒有合適的chunk,top chunk的長度也不能滿足需要,則對fast bin中的chunk進行合併。

 

5.6 chunk 合併

前面講了相鄰的chunk可以合併成一個大的chunk,反過來,一個大的chunk也可以分裂成兩個小的chunk。chunk的分裂與從top chunk中分配新的chunk是一樣的。需要注意的一點是:分裂後的兩個chunk其長度必須均大於chunk的最小長度(對於64位系統是32位元組),即保證分裂後的兩個chunk仍舊是可以被分配使用的,否則則不進行分裂,而是將整個chunk返回給使用者。

 

6 記憶體分配(malloc)

glibc執行時庫分配動態記憶體,底層用的是malloc來實現(new 最終也是呼叫malloc),下面是malloc函式呼叫流程圖:

 

malloc

 

在此,將上述流程圖以文字形式表示出來,以方便大家理解:

 

1、獲取分配區的鎖,為了防止多個執行緒同時訪問同一個分配區,在進行分配之前需要取得分配區域的鎖。執行緒先檢視執行緒私有例項中是否已經存在一個分配區,如果存在嘗試對該分配區加鎖,如果加鎖成功,使用該分配區分配記憶體,否則,該執行緒搜尋分配區迴圈連結串列試圖獲得一個空閒(沒有加鎖)的分配區。如果所有的分配區都已經加鎖,那麼 ptmalloc 會開闢一個新的分配區,把該分配區加入到全域性分配區迴圈連結串列和執行緒的私有例項中並加鎖,然後使用該分配區進行分配操作。開闢出來的新分配區一定為非主分配區,因為主分配區是從父程式那裡繼承來的。開闢非主分配區時會呼叫 mmap()建立一個 sub-heap,並設定好 top chunk。

 

2、將使用者的請求大小轉換為實際需要分配的 chunk 空間大小。

 

3、判斷所需分配chunk 的大小是否滿足chunk_size <= max_fast (max_fast 預設為 64B), 如果是的話,則轉下一步,否則跳到第 5 步。

 

4、首先嚐試在 fast bins 中取一個所需大小的 chunk 分配給使用者。如果可以找到,則分配結束。否則轉到下一步。

 

5、判斷所需大小是否處在 small bins 中,即判斷 chunk_size < 512B 是否成立。如果chunk 大小處在 small bins 中,則轉下一步,否則轉到第 6 步。

 

6、根據所需分配的 chunk 的大小,找到具體所在的某個 small bin,從該 bin 的尾部摘取一個恰好滿足大小的 chunk。若成功,則分配結束,否則,轉到下一步。

 

7、到了這一步,說明需要分配的是一塊大的記憶體,或者 small bins 中找不到合適的chunk。於是,ptmalloc 首先會遍歷 fast bins 中的 chunk,將相鄰的 chunk 進行合併,並連結到 unsorted bin 中,然後遍歷 unsorted bin 中的 chunk,如果 unsorted bin 只有一個 chunk,並且這個 chunk 在上次分配時被使用過,並且所需分配的 chunk 大小屬於 small bins,並且 chunk 的大小大於等於需要分配的大小,這種情況下就直接將該 chunk 進行切割,分配結束,否則將根據 chunk 的空間大小將其放入 small bins 或是 large bins 中,遍歷完成後,轉入下一步。

 

8、到了這一步,說明需要分配的是一塊大的記憶體,或者 small bins 和 unsorted bin 中都找不到合適的 chunk,並且 fast bins 和 unsorted bin 中所有的 chunk 都清除乾淨了。從 large bins 中按照“smallest-first,best-fit”原則,找一個合適的 chunk,從中劃分一塊所需大小的 chunk,並將剩下的部分連結回到 bins 中。若操作成功,則分配結束,否則轉到下一步。

 

9、如果搜尋 fast bins 和 bins 都沒有找到合適的 chunk,那麼就需要操作 top chunk 來進行分配了。判斷 top chunk 大小是否滿足所需 chunk 的大小,如果是,則從 top chunk 中分出一塊來。否則轉到下一步。

 

10、到了這一步,說明 top chunk 也不能滿足分配要求,所以,於是就有了兩個選擇: 如果是主分配區,呼叫 sbrk(),增加 top chunk 大小;如果是非主分配區,呼叫 mmap 來分配一個新的 sub-heap,增加 top chunk 大小;或者使用 mmap()來直接分配。在這裡,需要依靠 chunk 的大小來決定到底使用哪種方法。判斷所需分配的 chunk 大小是否大於等於 mmap 分配閾值,如果是的話,則轉下一步,呼叫 mmap 分配, 否則跳到第 12 步,增加 top chunk 的大小。

 

11、使用 mmap 系統呼叫為程式的記憶體空間對映一塊 chunk_size align 4kB 大小的空間。然後將記憶體指標返回給使用者。

 

12、判斷是否為第一次呼叫 malloc,若是主分配區,則需要進行一次初始化工作,分配一塊大小為(chunk_size + 128KB) align 4KB 大小的空間作為初始的 heap。若已經初始化過了,主分配區則呼叫 sbrk()增加 heap 空間,分主分配區則在 top chunk 中切割出一個 chunk,使之滿足分配需求,並將記憶體指標返回給使用者。

將上面流程串起來就是:

根據使用者請求分配的記憶體的大小,ptmalloc有可能會在兩個地方為使用者分配記憶體空間。在第一次分配記憶體時,一般情況下只存在一個主分配區,但也有可能從父程式那裡繼承來了多個非主分配區,在這裡主要討論主分配區的情況,brk值等於start_brk,所以實際上heap大小為0,top chunk 大小也是0。這時,如果不增加heap大小,就不能滿足任何分配要求。所以,若使用者的請求的記憶體大小小於mmap分配閾值, 則ptmalloc會初始heap。然後在heap中分配空間給使用者,以後的分配就基於這個heap進行。若第一次使用者的請求就大於mmap分配閾值,則ptmalloc直接使用mmap()分配一塊記憶體給使用者,而heap也就沒有被初始化,直到使用者第一次請求小於mmap分配閾值的記憶體分配。第一次以後的分配就比較複雜了,簡單說來,ptmalloc首先會查詢fast bins,如果不能找到匹配的chunk,則查詢small bins。若仍然不滿足要求,則合併fast bins,把chunk加入unsorted bin,在unsorted bin中查詢,若仍然不滿足要求,把unsorted bin 中的chunk全加入large bins 中,並查詢large bins。在fast bins 和small bins中的查詢都需要精確匹配, 而在large bins中查詢時,則遵循“smallest-first,best-fit”的原則,不需要精確匹配。若以上方法都失敗了,則ptmalloc會考慮使用top chunk。若top chunk也不能滿足分配要求。而且所需chunk大小大於mmap分配閾值,則使用mmap進行分配。否則增加heap,增大top chunk。以滿足分配要求。

 

當然了,glibc中malloc的分配遠比上面的要複雜的多,要考慮到各種情況,比如指標異常越界等,將這些判斷條件也加入到流程圖中,如下圖所示:

malloc(需要高清大圖,留言區留言或者私信我)

7 記憶體釋放(free)

malloc進行記憶體分配,那麼與malloc相對的就是free,進行記憶體釋放,下面是free函式的基本流程圖:

free

對上述流程圖進行描述,如下:

 

1、 獲取分配區的鎖,保證執行緒安全。

 

2、如果free的是空指標,則返回,什麼都不做。

 

3、判斷當前chunk是否是mmap對映區域對映的記憶體,如果是,則直接munmap()釋放這塊記憶體。前面的已使用chunk的資料結構中,我們可以看到有M來標識是否是mmap對映的記憶體。

 

4、 判斷chunk是否與top chunk相鄰,如果相鄰,則直接和top chunk合併(和top chunk相鄰相當於和分配區中的空閒記憶體塊相鄰)。否則,轉到步驟8

 

5、如果chunk的大小大於max_fast(64b),則放入unsorted bin,並且檢查是否有合併,有合併情況並且和top chunk相鄰,則轉到步驟8;沒有合併情況則free。

 

6、如果chunk的大小小於 max_fast(64b),則直接放入fast bin,fast bin並沒有改變chunk的狀態。沒有合併情況,則free;有合併情況,轉到步驟7

 

7、在fast bin,如果當前chunk的下一個chunk也是空閒的,則將這兩個chunk合併,放入unsorted bin上面。合併後的大小如果大於64B,會觸發進行fast bins的合併操作,fast bins中的chunk將被遍歷,並與相鄰的空閒chunk進行合併,合併後的chunk會被放到unsorted bin中,fast bin會變為空。合併後的chunk和topchunk相鄰,則會合併到topchunk中。轉到步驟8

 

8、判斷top chunk的大小是否大於mmap收縮閾值(預設為128KB),如果是的話,對於主分配區,則會試圖歸還top chunk中的一部分給作業系統。free結束。

 

如果將free函式內部各種條件加入進去,那麼free呼叫的詳細流程圖如下:

 

 

 

8 問題分析以及解決

通過前面對glibc執行時庫的分析,基本就能定位出原因,是因為我們呼叫了free進行釋放,但僅僅是將記憶體返還給了glibc庫,而glibc庫卻沒有將記憶體歸還作業系統,最終導致系統記憶體耗盡,程式因為 OOM 被系統殺掉。

 

有以下兩種方案:

  • 禁用 ptmalloc 的 mmap 分配 閾 值 動 態 調 整 機 制 。 通 過 mallopt() 設定M_TRIM_THRESHOLD,M_MMAP_THRESHOLD,M_TOP_PAD 和 M_MMAP_MAX 中的任意一個,關閉 mmap 分配閾值動態調整機制,同時需要將 mmap 分配閾值設定為 64K,大於 64K 的記憶體分配都使用mmap 向系統分配,釋放大於 64K 的記憶體將呼叫 munmap 釋放回系統。但是,這種方案的 缺點是每次記憶體分配和申請,都是直接向作業系統申請,效率低

  • 預 估 程 序 可 以 使 用 的 最 大 物 理 內 存 大 小 , 配 置 系 統 的 /proc/sys/vm/overcommit_memory,/proc/sys/vm/overcommit_ratio,以及使用 ulimt –v限制程式能使用虛擬記憶體空間大小,防止程式因 OOM 被殺掉。這種方案的 缺點是如果預估的記憶體小於程式實際佔用,那麼仍然會出現OOM,導致程式被殺掉

  • tcmalloc

最終採用tcmalloc來解決了問題,後面有機會的話,會寫一篇tcmalloc記憶體管理的相關文章。

9 結語

業界語句說法,是否瞭解記憶體管理機制,是辨別C/C++程式設計師和其他的高階語言程式設計師的重要區別。作為C/C++中的最重要的特性,指標及動態記憶體管理在給程式設計帶來極大的靈活性的同時,也給開發人員帶來了許多困擾。

 

瞭解底層記憶體實現,有時候會有意想不到的效果哦。

 

先寫到這裡吧。

 

 

10 參考

1、https://sourceware.org/glibc/wiki/MallocInternals

2、https://titanwolf.org/Network/Articles/Article?AID=99524c69-cb90-4d61-bb28-01c0864d0ccc

3、https://blog.fearcat.in/a?ID=00100-535db575-0d98-4287-92b6-4d7d9604b216

4、https://sploitfun.wordpress.com/2015/02/10/understanding-glibc-malloc/

5、https://sploitfun.wordpress.com/2015/02/11/syscalls-used-by-malloc

 

 

 

 

相關文章