06.進程的虛擬內存管理.md

正好遇到 華庭(莊命強)的glibc內存管理Ptmalloc2 源代碼分析 一文,非常開心。真是大佬。我只是藉着這篇文章稍微整理一下,爲了以後自己回顧的時候能夠更好的排查問題。

6.1 linux進程內存佈局

x86 平臺 Linux 進程內存佈局
  Linux 系統在裝載 elf 格式的程序文件時,會調用 loader 把可執行文件中的各個段依次
載入到從某一地址開始的空間中(載入地址取決 link editor(ld)和機器地址位數,在 32 位機
器上是 0x8048000,即 128M 處)。如下圖所示,以 32 位機器爲例,首先被載入的是.text 段,
然後是.data 段,最後是.bss 段。這可以看作是程序的開始空間。程序所能訪問的最後的地
址是 0xbfffffff,也就是到 3G 地址處,3G 以上的 1G 空間是內核使用的,應用程序不可以直
接訪問。
 &emsp應用程序的堆棧從最高地址處開始向下生長,.bss 段與堆棧之間的空間是空閒的,
空閒空間被分成兩部分,一部分爲 heap,一部分爲 mmap 映射區域,mmap 映射區域一般
從 TASK_SIZE/3 的地方開始,但在不同的 Linux 內核和機器上,mmap 區域的開始位置一般是
不同的。Heap 和 mmap 區域都可以供用戶自由使用,但是它在剛開始的時候並沒有映射到
內存空間內,是不可訪問的。在向內核請求分配該空間之前,對這個空間的訪問會導致
segmentation fault。用戶程序可以直接使用系統調用來管理 heap 和 mmap 映射區域,但更
多的時候程序都是使用 C 語言提供的 malloc()和 free()函數來動態的分配和釋放內存。Stack
區域是唯一不需要映射,用戶卻可以訪問的內存區域,這也是利用堆棧溢出進行攻擊的基礎。

6.1.1 32 位模式下進程默認內存佈局

  從下圖可以看到,棧至頂向下擴展,並且棧是有界的。堆至底向上擴展,mmap 映射區
域至頂向下擴展,mmap 映射區域和堆相對擴展,直至耗盡虛擬地址空間中的剩餘區域,這
種結構便於 C 運行時庫使用 mmap 映射區域和堆進行內存分配。上圖的佈局形式是在內核
2.6.7 以後才引入的,這是 32 位模式下進程的默認內存佈局形式。

32位圖片

6.1.2 64位進程虛擬地址空間

  在 64 位模式下各個區域的起始位置是什麼呢?對於 AMD64 系統,內存佈局採用經典
內存佈局,text 的起始地址爲 0x0000000000400000,堆緊接着 BSS 段向上增長,mmap 映射
區域開始位置一般設爲 TASK_SIZE/3。

#define TASK_SIZE_MAX ((1UL << 47) - PAGE_SIZE)
#define TASK_SIZE (test_thread_flag(TIF_IA32) ? IA32_PAGE_OFFSET : TASK_SIZE_MAX)
#define STACK_TOP TASK_SIZE
#define TASK_UNMAPPED_BASE (PAGE_ALIGN(TASK_SIZE / 3))

計算一下可知,mmap 的開始區域地址爲 0x00002AAAAAAAA000,棧頂地址爲
0x00007FFFFFFFF000
在這裏插入圖片描述

  相對於32位可以發現,64位的機器mmap是像上生長的,因爲地址空間比較大,所以不用擔心會和stack衝突。上圖是 X86_64 下 Linux 進程的默認內存佈局形式,這只是一個示意圖,當前內核默認配置下,進程的棧和 mmap 映射區域並不是從一個固定地址開始,並且每次啓動時的值都不一樣,這是程序在啓動時隨機改變這些值的設置,使得使用緩衝區溢出進行攻擊更加困難。當然也可以讓進程的棧和 mmap 映區域從一個固定位置開始,只需要設置全局變量andomize_va_space值 爲 0 , 這 個 變 量 默 認 值 爲 1 。 用 戶 可 以 通 過 設 置/proc/sys/kernel/randomize_va_space來停用該特性,也可以用如下命令:

sudo sysctl -w kernel.randomize_va_space=0

6.1.3 linux各個內存區域的存放

  1. 內核空間
    內核總是駐留在內存中,是操作系統的一部分。內核空間爲內核保留,不允許應用程序讀寫該區域的內容或直接調用內核代碼定義的函數。同時也包含了一些當前進程私有的進程控制塊相關的信息。

  2. 棧(stack)
    棧又稱堆棧,由編譯器自動分配釋放,行爲類似數據結構中的棧(先進後出)。堆棧主要有三個用途:
    2.1. 爲函數內部聲明的非靜態局部變量(C語言中稱“自動變量”)提供存儲空間。
    2.2. 記錄函數調用過程相關的維護性信息,稱爲棧幀(Stack Frame)或過程活動記錄(Procedure Activation Record)。它包括函數返回地址,不適合裝入寄存器的函數參數及一些寄存器值的保存。除遞歸調用外,堆棧並非必需。因爲編譯時可獲知局部變量,參數和返回地址所需空間,並將其分配於BSS段。
    2.3. 臨時存儲區,用於暫存長算術表達式部分計算結果或alloca()函數分配的棧內內存。
      持續地重用棧空間有助於使活躍的棧內存保持在CPU緩存中,從而加速訪問。進程中的每個線程都有屬於自己的棧。向棧中不斷壓入數據時,若超出其容量就會耗盡棧對應的內存區域,從而觸發一個頁錯誤。此時若棧的大小低於堆棧最大值RLIMIT_STACK(通常是8M),則棧會動態增長,程序繼續運行。映射的棧區擴展到所需大小後,不再收縮。
    Linux中ulimit -s命令可查看和設置堆棧最大值,當程序使用的堆棧超過該值時, 發生棧溢出(Stack Overflow),程序收到一個段錯誤(Segmentation Fault)。注意,調高堆棧容量可能會增加內存開銷和啓動時間。
    堆棧既可向下增長(向內存低地址)也可向上增長, 這依賴於具體的實現。
    堆棧的大小在運行時由內核動態調整。

  3. 內存映射段(mmap)
      此處,內核將硬盤文件的內容直接映射到內存, 任何應用程序都可通過Linux的mmap()系統調用或Windows的CreateFileMapping()/MapViewOfFile()請求這種映射。內存映射是一種方便高效的文件I/O方式, 因而被用於裝載動態共享庫。
      用戶也可創建匿名內存映射,該映射沒有對應的文件, 可用於存放程序數據。在 Linux中,若通過malloc()請求一大塊內存,C運行庫將創建一個匿名內存映射,而不使用堆內存。”大塊” 意味着比閾值 MMAP_THRESHOLD還大,缺省爲128KB,可通過mallopt()調整。
      該區域用於映射可執行文件用到的動態鏈接庫。在Linux 2.4版本中,若可執行文件依賴共享庫,則系統會爲這些動態庫在從0x40000000開始的地址分配相應空間,並在程序裝載時將其載入到該空間。在Linux 2.6內核中,共享庫的起始地址被往上移動至更靠近棧區的位置。

  4. 堆(heap)
      堆用於存放進程運行時動態分配的內存段,可動態擴張或縮減。堆中內容是匿名的,不能按名字直接訪問,只能通過指針間接訪問。當進程調用malloc©/new(C++)等函數分配內存時,新分配的內存動態添加到堆上(擴張);當調用free©/delete(C++)等函數釋放內存時,被釋放的內存從堆中剔除(縮減) 。
      分配的堆內存是經過字節對齊的空間,以適合原子操作。堆管理器通過鏈表管理每個申請的內存,由於堆申請和釋放是無序的,最終會產生內存碎片。堆內存一般由應用程序分配釋放,回收的內存可供重新使用。若程序員不釋放,程序結束時操作系統可能會自動回收。

  5. BSS段
    BSS(Block Started by Symbol)段中通常存放程序中以下符號:
    1.未初始化的全局變量和靜態局部變量
    2.初始值爲0的全局變量和靜態局部變量(依賴於編譯器實現)
    3.未定義且初值不爲0的符號(該初值即common block的大小)
      C語言中,未顯式初始化的靜態分配變量被初始化爲0(算術類型)或空指針(指針類型)。由於程序加載時,BSS會被操作系統清零,所以未賦初值或初值爲0的全局變量都在BSS中。BSS段僅爲未初始化的靜態分配變量預留位置,在目標文件中並不佔據空間,這樣可減少目標文件體積。但程序運行時需爲變量分配內存空間,故目標文件必須記錄所有未初始化的靜態分配變量大小總和(通過start_bss和end_bss地址寫入機器代碼)。當加載器(loader)加載程序時,將爲BSS段分配的內存初始化爲0。在嵌入式軟件中,進入main()函數之前BSS段被C運行時系統映射到初始化爲全零的內存(效率較高)。

  6. 數據段(Data)
    數據段通常用於存放程序中已初始化且初值不爲0的全局變量和靜態局部變量。數據段屬於靜態內存分配(靜態存儲區),可讀可寫。

  7. 代碼段(text)
      代碼段也稱正文段或文本段,通常用於存放程序執行代碼(即CPU執行的機器指令)。一般C語言執行語句都編譯成機器代碼保存在代碼段。通常代碼段是可共享的,因此頻繁執行的程序只需要在內存中擁有一份拷貝即可。代碼段通常屬於只讀,以防止其他程序意外地修改其指令(對該段的寫操作將導致段錯誤)。

  8. 保留區
      位於虛擬地址空間的最低部分,未賦予物理地址。任何對它的引用都是非法的,用於捕捉使用空指針和小整型值指針引用內存的異常情況。

6.2 操作系統內存分配的相關函數

  上節提到 heap 和 mmap 映射區域是可以提供給用戶程序使用的虛擬內存空間,如何獲得該區域的內存呢?操作系統提供了相關的系統調用來完成相關工作。

  1. 對 heap 的操作,操作系統提供了 brk()函數,C 運行時庫提供了 sbrk()函數;
  2. 對 mmap 映射區域的操作,操作系統提供了 mmap()和 munmap()函數。
  3. sbrk(),brk() 或者 mmap() 都可以用來向我們的進程添加額外的虛擬內存。

Glibc 同樣是使用這些函數向操作系統申請虛擬內存。

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

6.2.1 Heap 操作相關函數

  Heap 操作函數主要有兩個,brk()爲系統調用,sbrk()爲 C 庫函數。系統調用通常提供一種最小功能,而庫函數通常提供比較複雜的功能。Glibc 的 malloc 函數族(realloc,calloc 等)就調用 sbrk()函數將數據段的下界移動,sbrk()函數在內核的管理下將虛擬地址空間映射到內存,供 malloc()函數使用。
  內核數據結構 mm_struct 中的成員變量 start_code 和 end_code 是進程代碼段的起始和終止地址,start_data 和 end_data 是進程數據段的起始和終止地址,start_stack 是進程堆棧段起始地址,start_brk 是進程動態內存分配起始地址(堆的起始地址),還有一個 brk(堆的當前最後地址),就是動態內存分配當前的終止地址。C 語言的動態內存分配基本函數是malloc(),在 Linux 上的實現是通過內核的 brk 系統調用。brk()是一個非常簡單的系統調用,
只是簡單地改變 mm_struct 結構的成員變量 brk 的值。
這兩個函數的定義如下:

 #include <unistd.h>
 int brk(void *addr);
 void *sbrk(intptr_t increment);

需要說明的是,但 sbrk()的參數 increment 爲 0 時,sbrk()返回的是進程的當前 brk 值,
increment 爲正數時擴展 brk 值,當 increment 爲負值時收縮 brk 值。

6.2.2 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);

在這裏不準備對這兩個函數做詳細介紹,只是對 ptmalloc 中用到的功能做一下介紹,其
他的用法請參看相關資料。
參數:

 1. start:映射區的開始地址。
 2. length:映射區的長度。
 3. prot:期望的內存保護標誌,不能與文件的打開模式衝突。是以下的某個值,可以通過or 運算合理地組合在一起。Ptmalloc 中主要使用瞭如下的幾個標誌:
 	3.1 PROT_EXEC //頁內容可以被執行,ptmalloc 中沒有使用
    3.2 PROT_READ //頁內容可以被讀取,ptmalloc 直接用 mmap 分配內存並立即返回給用戶時設置該標誌
 	3.3 PROT_WRITE //頁可以被寫入,ptmalloc 直接用 mmap 分配內存並立即返回給用戶時設置該標誌
 	3.4 PROT_NONE //頁不可訪問,ptmalloc 用 mmap 向系統“批發”一塊內存進行管理時設置該標誌
4. flags:指定映射對象的類型,映射選項和映射頁是否可以共享。它的值可以是一個或者多個以下位的組合體
 	4.1 MAP_FIXED //使用指定的映射起始地址,如果由 start 和 len 參數指定的內存區重疊於現存的映射空間,重疊部分將會被丟棄。如果指定的起始地址不可用,操作將會失敗。並且起始地址必須落在頁的邊界上。Ptmalloc 在回收從系統中“批發”的內存時設置該標誌。
 	4.2 MAP_PRIVATE //建立一個寫入時拷貝的私有映射。內存區域的寫入不會影響到原文件。這個標誌和以上標誌是互斥的,只能使用其中一個。Ptmalloc每次調用mmap都設置該標誌。
 	4.3 MAP_NORESERVE //不要爲這個映射保留交換空間。當交換空間被保留,對映射區修改的可能會得到保證。當交換空不被保留,同時內存不足,對映射區的修改會引起段違例信號。Ptmalloc 向系統“批發”內存塊時設置該標誌。
 	4.4 MAP_ANONYMOUS //匿名映射,映射區不與任何文件關聯。Ptmalloc 每次調用 mmap都設置該標誌。這說明malloc每次調用都是使用的匿名文件的方式進行映射。直接分配內存使用,而不是進行文件映射。
5. fd:有效的文件描述詞。如果 MAP_ANONYMOUS 被設定,爲了兼容問題,其值應爲-1。
6. offset:被映射對象內容的起點。

6.3. 內存管理概述

  當不知道程序的每個部分將需要多少內存時,系統內存空間有限,而內存需求又是變化的,這時就需要內存管理程序來負責分配和回收內存。程序的動態性越強,內存管理就越重要,內存分配程序的選擇也就更重要。

6.3.1 內存管理的方法

可用於內存管理的方法有許多種,它們各有好處與不足,不同的內存管理方法有最適用的情況。

6.3.1.1 C 風格的內存管理程序

  C 風格的內存管理程序主要實現 malloc()和 free()函數。內存管理程序主要通過調用 brk()或者 mmap()進程添加額外的虛擬內存。Doug Lea Malloc,ptmalloc,BSD malloc,Hoard,TCMalloc 都屬於這一類內存管理程序。
  基於 malloc()的內存管理器仍然有很多缺點,不管使用的是哪個分配程序。對於那些需要保持長期存儲的程序使用 malloc()來管理內存可能會非常令人失望。如果有大量的不固定的內存引用,經常難以知道它們何時被釋放。生存期侷限於當前函數的內存非常容易管理,但是對於生存期超出該範圍的內存來說,管理內存則困難得多。因爲管理內存的問題,很多程序傾向於使用它們自己的內存管理規則。

6.3.1.2 池式內存管理

  內存池是一種半內存管理方法。內存池幫助某些程序進行自動內存管理,這些程序會經歷一些特定的階段,而且每個階段中都有分配給進程的特定階段的內存。例如,很多網絡服務器進程都會分配很多針對每個連接的內存——內存的最大生存期限爲當前連接的存在期。

  Apache 使用了池式內存(pooled memory),將其連接拆分爲各個階段,每個階段都有自己的內存池。在結束每個階段時,會一次釋放所有內存。在池式內存管理中,每次內存分配都會指定內存池,從中分配內存。每個內存池都有不同的生存期限。在 Apache 中,有一個持續時間爲服務器存在期的內存池,還有一個持續時間爲連接的存在期的內存池,以及一個持續時間爲請求的存在期的池,另外還有其他一些內存池。因此,如果我的一系列函數不會生成比連接持續時間更長的數據,那麼我就可以完全從連接池中分配內存,並知道在連接結束時,這些內存會被自動釋放。另外,有一些實現允許註冊清除函數(cleanup functions),在清除內存池之前,恰好可以調用它,來完成在內存被清理前需要完成的其他所有任務(類似於面向對象中的析構函數)。

注意,這個池不是java中的堆內存中的那個池式管理,只是分階段的內存管理,內存池的使用是直接按照時間週期進行管理的。

6.3.1.3 引用計數

  在引用計數中,所有共享的數據結構都有一個域來包含當前活動“引用”結構的次數。當向一個程序傳遞一個指向某個數據結構指針時,該程序會將引用計數增加 1。實質上,是在告訴數據結構,它正在被存儲在多少個位置上。然後,當進程完成對它的使用後,該程序就會將引用計數減少 1。結束這個動作之後,它還會檢查計數是否已經減到零。如果是,那麼它將釋放內存。

  在 Java,Perl 等高級語言中,進行內存管理時使用引用計數非常廣泛。在這些語言中,引用計數由語言自動地處理,所以您根本不必擔心它,除非要編寫擴展模塊。由於所有內容都必須進行引用計數,所以這會對速度產生一些影響,但它極大地提高了編程的安全性和方便性。

6.3.1.4 垃圾收集

  垃圾收集(Garbage collection)是全自動地檢測並移除不再使用的數據對象。垃圾收集器通常會在當可用內存減少到少於一個具體的閾值時運行。通常,它們以程序所知的可用的一組“基本”數據——棧數據、全局變量、寄存器——作爲出發點。然後它們嘗試去追蹤通過這些數據連接到每一塊數據。收集器找到的都是有用的數據;它沒有找到的就是垃圾,可以被銷燬並重新使用這些無用的數據。爲了有效地管理內存,很多類型的垃圾收集器都需要知道數據結構內部指針的規劃,所以,爲了正確運行垃圾收集器,它們必須是語言本身的一部分。
垃圾收集的一些優點:

  • 永遠不必擔心內存的雙重釋放或者對象的生命週期。
  • 使用某些收集器,您可以使用與常規分配相同的 API。
    其缺點包括:
  • 使用大部分收集器時,您都無法干涉何時釋放內存。
  • 在多數情況下,垃圾收集比其他形式的內存管理更慢。
  • 垃圾收集錯誤引發的缺陷難於調試。
  • 如果您忘記將不再使用的指針設置爲 null,那麼仍然會有內存泄漏,這一點在java中不是這個樣子的。

6.3.2 常見的c內存管理程序

1.Doug Lea Malloc:Doug Lea Malloc 實際上是完整的一組分配程序,其中包括 Doug Lea的原始分配程序,GNU libc 分配程序和 ptmalloc。Doug Lea 的分配程序加入了索引,這使得搜索速度更快,並且可以將多個沒有被使用的塊組合爲一個大的塊。它還支持緩存,以便更快地再次使用最近釋放的內存。ptmalloc 是 Doug Lea Malloc 的一個擴展版本,支持多線程。

2.BSD Malloc:BSD Malloc 是隨 4.2 BSD 發行的實現,包含在 FreeBSD 之中,這個分配
程序可以從預先確實大小的對象構成的池中分配對象。

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

4.TCMalloc:(Thread-Caching Malloc)是 google 開發的開源工具──“google-perftools”
中的成員。與標準的 Glibc 庫的 malloc 相比,TCMalloc 在內存的分配上效率和速度
要高得多。

6.4. Ptmalloc 內存管理概述

6.4.1 ptmalloc設計假設

Ptmalloc 在設計時折中了高效率,高空間利用率,高可用性等設計目標。在其實現代碼
中,隱藏着內存管理中的一些設計假設,這些設計假設包括:
的行爲很詭異。這些設計假設包括:

  1. 具有長生命週期的大內存分配使用 mmap。
  2. 特別大的內存分配總是使用 mmap。(這裏使用mmap和上一個使用還有不同點)
  3. 具有短生命週期的內存分配使用 brk,因爲用 mmap 映射匿名頁,當發生缺頁異常
    時,linux 內核爲缺頁分配一個新物理頁,並將該物理頁清 0,一個 mmap 的內存塊
    需要映射多個物理頁,導致多次清 0 操作,很浪費系統資源,所以引入了 mmap
    分配閾值動態調整機制,保證在必要的情況下才使用 mmap 分配內存。
  4. 儘量只緩存臨時使用的空閒小內存塊,對大內存塊或是長生命週期的大內存塊在釋
    放時都直接歸還給操作系統。
  5. 對空閒的小內存塊只會在 malloc 和 free 的時候進行合併,free 時空閒內存塊可能
    放入 pool 中,不一定歸還給操作系統。
  6. 收縮堆的條件是當前 free 的塊大小加上前後能合併 chunk 的大小大於 64KB、,並且
    堆頂的大小達到閾值,纔有可能收縮堆,把堆最頂端的空閒內存返回給操作系統。
  7. 需要保持長期存儲的程序不適合用 ptmalloc 來管理內存。
  8. 爲了支持多線程,多個線程可以從同一個分配區(arena)中分配內存,ptmalloc
    假設線程 A 釋放掉一塊內存後,線程 B 會申請類似大小的內存,但是 A 釋放的內
    存跟 B 需要的內存不一定完全相等,可能有一個小的誤差,就需要不停地對內存塊
    作切割和合並,這個過程中可能產生內存碎片。

6.4.2 內存管理數據結構概述

6.4.2.1 Main_arena 與 non_main_arena

  在 Doug Lea 實現的內存分配器中只有一個主分配區(main arena),每次分配內存都必須對主分配區加鎖,分配完成後釋放鎖,在 SMP 多線程環境下,對主分配區的鎖的爭用很激烈,嚴重影響了 malloc 的分配效率。於是 Wolfram Gloger 在 Doug Lea 的基礎上改進使得Glibc 的 malloc 可以支持多線程,增加了非主分配區(non main arena)支持,主分配區與非主分配區用環形鏈表進行管理。每一個分配區利用互斥鎖(mutex)使線程對於該分配區的訪問互斥。
  每個進程只有一個主分配區,但可能存在多個非主分配區,ptmalloc 根據系統對分配區的爭用情況動態增加非主分配區的數量,分配區的數量一旦增加,就不會再減少了。主分配區可以訪問進程的 heap 區域和 mmap 映射區域,也就是說主分配區可以使用 sbrk 和 mmap向操作系統申請虛擬內存。而非主分配區只能訪問進程的 mmap 映射區域,非主分配區每次使用 mmap()向操作系統“批發”HEAP_MAX_SIZE(32 位系統上默認爲 1MB,64 位系統默認爲 64MB)大小的虛擬內存,當用戶向非主分配區請求分配內存時再切割成小塊“零售”出去,畢竟系統調用是相對低效的,直接從用戶空間分配內存快多了。所以 ptmalloc 在必要的情況下才會調用 mmap()函數向操作系統申請虛擬內存。
  主分配區可以訪問 heap 區域,如果用戶不調用 brk()或是 sbrk()函數,分配程序就可以保證分配到連續的虛擬地址空間,因爲每個進程只有一個主分配區使用 sbrk()分配 heap 區域的虛擬內存。內核對 brk 的實現可以看着是 mmap 的一個精簡版,相對高效一些。如果主分配區的內存是通過 mmap()向系統分配的,當 free 該內存時,主分配區會直接調用 munmap()將該內存歸還給系統。
  當某一線程需要調用 malloc()分配內存空間時,該線程先查看線程私有變量中是否已經存在一個分配區,如果存在,嘗試對該分配區加鎖,如果加鎖成功,使用該分配區分配內存,如果失敗,該線程搜索循環鏈表試圖獲得一個沒有加鎖的分配區。如果所有的分配區都已經加鎖,那麼 malloc()會開闢一個新的分配區,把該分配區加入到全局分配區循環鏈表並加鎖,然後使用該分配區進行分配內存操作。在釋放操作中,線程同樣試圖獲得待釋放內存塊所在分配區的鎖,如果該分配區正在被別的線程使用,則需要等待直到其他線程釋放該分配區的互斥鎖之後纔可以進行釋放操作。
  申請小塊內存時會產生很多內存碎片,ptmalloc 在整理時也需要對分配區做加鎖操作。每個加鎖操作大概需要 5~10 個 cpu 指令,而且程序線程很多的情況下,鎖等待的時間就會延長,導致 malloc 性能下降。一次加鎖操作需要消耗 100ns 左右,正是鎖的緣故,導致 ptmalloc在多線程競爭情況下性能遠遠落後於 tcmalloc。最新版的 ptmalloc 對鎖進行了優化,加入了PER_THREAD 和 ATOMIC_FASTBINS 優化,但默認編譯不會啓用該優化,這兩個對鎖的優化應該能夠提升多線程內存的分配的效率。

6.4.2.2 chunk

  不管內存是在哪裏被分配的,用什麼方法分配,用戶請求分配的空間在 ptmalloc 中都使用一個 chunk 來表示。用戶調用 free()函數釋放掉的內存也並不是立即就歸還給操作系統,相反,它們也會被表示爲一個 chunk,ptmalloc 使用特定的數據結構來管理這些空閒的 chunk。

6.4.2.2.1 chunk的分類

1.bins
  用戶 free 掉的內存並不是都會馬上歸還給系統,ptmalloc 會統一管理 heap 和 mmap 映射區域中的空閒的 chunk,當用戶進行下一次分配請求時,ptmalloc 會首先試圖在空閒的chunk 中挑選一塊給用戶,這樣就避免了頻繁的系統調用,降低了內存分配的開銷。ptmalloc將相似大小的 chunk 用雙向鏈表鏈接起來,這樣的一個鏈表被稱爲一個 bin。Ptmalloc 一共維護了 128 個 bin,並使用一個數組來存儲這些 bin(如下圖所示)。
  數組中的第一個爲 unsorted bin,數組中從 2 開始編號的前 64 個 bin 稱爲 small bins,同一個small bin中的chunk具有相同的大小。兩個相鄰的small bin中的chunk大小相差8bytes。small bins 中的 chunk 按照最近使用順序進行排列,最後釋放的 chunk 被鏈接到鏈表的頭部,而申請 chunk 是從鏈表尾部開始,這樣,每一個 chunk 都有相同的機會被 ptmalloc 選中。
  Small bins 後面的 bin 被稱作 large bins。large bins 中的每一個 bin 分別包含了一個給定範圍內的 chunk,其中的 chunk 按大小序排列。相同大小的 chunk 同樣按照最近使用順序排列。ptmalloc 使用“smallest-first,best-fit”原則在空閒 large bins 中查找合適的 chunk。當空閒的 chunk 被鏈接到 bin 中的時候,ptmalloc 會把表示該 chunk 是否處於使用中的標誌 P 設爲 0(注意,這個標誌實際上處在下一個 chunk 中),同時 ptmalloc 還會檢查它前後的 chunk 是否也是空閒的,如果是的話,ptmalloc 會首先把它們合併爲一個大的 chunk,然後將合併後的 chunk 放到 unstored bin 中。要注意的是,並不是所有的 chunk 被釋放後就立即被放到 bin 中。ptmalloc 爲了提高分配的速度,會把一些小的的 chunk 先放到一個叫做fast bins 的容器內。

2.Fast Bins
  一般的情況是,程序在運行時會經常需要申請和釋放一些較小的內存空間。當分配器合併了相鄰的幾個小的 chunk 之後,也許馬上就會有另一個小塊內存的請求,這樣分配器又需要從大的空閒內存中切分出一塊,這樣無疑是比較低效的,故而,ptmalloc 中在分配過程中引入了 fast bins,不大於 max_fast (默認值爲 64B)的 chunk 被釋放後,首先會被放到 fast bins中,fast bins 中的 chunk 並不改變它的使用標誌 P(這個標識的意思是上一個bin是否是空閒的)。這樣也就無法將它們合併,當需要給用戶分配的 chunk 小於或等於 max_fast 時,ptmalloc 首先會在 fast bins 中查找相應的空閒塊,然後纔會去查找bins中的空閒chunk。在某個特定的時候,ptmalloc會遍歷fast bins中的chunk,將相鄰的空閒 chunk 進行合併,並將合併後的 chunk 加入 unsorted bin 中,然後再將 usortedbin 裏的 chunk 加入 bins 中。

3.Unsorted Bin
  unsorted bin 的隊列使用 bins 數組的第一個,如果被用戶釋放的 chunk 大於 max_fast,或者 fast bins 中的空閒 chunk 合併後,這些 chunk 首先會被放到 unsorted bin 隊列中,在進行 malloc 操作的時候,如果在 fast bins 中沒有找到合適的 chunk,則 ptmalloc 會先在 unsortedbin 中查找合適的空閒 chunk,然後才查找 bins。如果 unsorted bin 不能滿足分配要求。malloc便會將 unsorted bin 中的 chunk 加入 bins 中。然後再從 bins 中繼續進行查找和分配過程。從這個過程可以看出來,unsorted bin 可以看做是 bins 的一個緩衝區,增加它只是爲了加快分配的速度。

4.Top chunk
  並不是所有的 chunk 都按照上面的方式來組織,實際上,有三種例外情況。Top chunk,mmaped chunk 和 last remainder,下面會分別介紹這三類特殊的 chunk。top chunk 對於主分配區和非主分配區是不一樣的。
  對於非主分配區會預先從 mmap 區域分配一塊較大的空閒內存模擬 sub-heap,通過管理 sub-heap 來響應用戶的需求,因爲內存是按地址從低向高進行分配的,在空閒內存的最高處,必然存在着一塊空閒 chunk,叫做 top chunk。當 bins 和 fast bins 都不能滿足分配需要的時候,ptmalloc 會設法在 top chunk 中分出一塊內存給用戶,如果 top chunk 本身不夠大,分配程序會重新分配一個 sub-heap,並將 top chunk 遷移到新的 sub-heap 上,新的 sub-heap與已有的 sub-heap 用單向鏈表連接起來,然後在新的 top chunk 上分配所需的內存以滿足分配的需要,實際上,top chunk 在分配時總是在 fast bins 和 bins 之後被考慮,所以,不論 topchunk 有多大,它都不會被放到 fast bins 或者是 bins 中。Top chunk 的大小是隨着分配和回收不停變換的,如果從 top chunk 分配內存會導致 top chunk 減小,如果回收的 chunk 恰好與 top chunk 相鄰,那麼這兩個 chunk 就會合併成新的 top chunk,從而使 top chunk 變大。如果在 free 時回收的內存大於某個閾值,並且 top chunk 的大小也超過了收縮閾值,ptmalloc會收縮 sub-heap,如果 top-chunk 包含了整個 sub-heap,ptmalloc 會調用 munmap 把整個sub-heap 的內存返回給操作系統。

  由於主分配區是唯一能夠映射進程 heap 區域的分配區,它可以通過 sbrk()來增大或是收縮進程 heap 的大小,ptmalloc 在開始時會預先分配一塊較大的空閒內存(也就是所謂的 heap),主分配區的 top chunk 在第一次調用 malloc 時會分配一塊(chunk_size + 128KB)align 4KB 大小的空間作爲初始的 heap,用戶從 top chunk 分配內存時,可以直接取出一塊內存給用戶。在回收內存時,回收的內存恰好與 top chunk 相鄰則合併成新的 top chunk,當該次回收的空閒內存大小達到某個閾值,並且 top chunk 的大小也超過了收縮閾值,會執行內存收縮,減小 top chunk 的大小,但至少要保留一個頁大小的空閒內存,從而把內存歸還給操作系統。如果向主分配區的 top chunk 申請內存,而 top chunk 中沒有空閒內存,ptmalloc會調用 sbrk()將的進程 heap 的邊界 brk 上移,然後修改 top chunk 的大小。

5.mmaped chunk
  當需要分配的 chunk 足夠大,而且 fast bins 和 bins 都不能滿足要求,甚至 top chunk 本身也不能滿足分配需求時,ptmalloc 會使用 mmap 來直接使用內存映射來將頁映射到進程空間。這樣分配的 chunk 在被 free 時將直接解除映射,於是就將內存歸還給了操作系統,再次對這樣的內存區的引用將導致 segmentation fault 錯誤。這樣的 chunk 也不會包含在任何bin 中。

6.Last remainder
  Last remainder 是另外一種特殊的 chunk,就像 top chunk 和 mmaped chunk 一樣,不會在任何 bins 中找到這種 chunk。當需要分配一個 small chunk,但在 small bins 中找不到合適的 chunk,如果 last remainder chunk 的大小大於所需的 small chunk 大小,last remainder chunk被分裂成兩個 chunk,其中一個 chunk 返回給用戶,另一個 chunk 變成新的 last remainder chuk。

6.4.2.3 sbrk 與 mmap

  從進程的內存佈局可知,.bss 段之上的這塊分配給用戶程序的空間被稱爲 heap (堆)。start_brk 指向 heap 的開始,而 brk 指向 heap 的頂部。可以使用系統調用 brk()和 sbrk()來增加標識 heap 頂部的 brk 值,從而線性的增加分配給用戶的 heap 空間。在使 malloc 之前,brk的值等於start_brk,也就是說heap大小爲0。ptmalloc在開始時,若請求的空間小於 mmap分配閾值(mmap threshold,默認值爲 128KB)時,主分配區會調用 sbrk()增加一塊大小爲 (128KB + chunk_size) align 4KB 的空間作爲 heap。非主分配區會調用 mmap 映射一塊大小爲HEAP_MAX_SIZE(32 位系統上默認爲 1MB,64 位系統上默認爲 64MB)的空間作爲 sub-heap。這就是前面所說的 ptmalloc 所維護的分配空間,當用戶請求內存分配時,首先會在這個區域內找一塊合適的 chunk 給用戶。當用戶釋放了 heap 中的 chunk 時,ptmalloc 又會使用 fastbins 和 bins 來組織空閒 chunk。以備用戶的下一次分配。若需要分配的 chunk 大小小於 mmap分配閾值,而 heap 空間又不夠,則此時主分配區會通過 sbrk()調用來增加 heap 大小,非主分配區會調用 mmap 映射一塊新的 sub-heap,也就是增加 top chunk 的大小,每次 heap 增加的值都會對齊到 4KB。

  當用戶的請求超過 mmap 分配閾值,並且主分配區使用 sbrk()分配失敗的時候,或是非主分配區在 top chunk 中不能分配到需要的內存時,ptmalloc 會嘗試使用 mmap()直接映射一塊內存到進程內存空間。使用 mmap()直接映射的 chunk 在釋放時直接解除映射,而不再屬於進程的內存空間。任何對該內存的訪問都會產生段錯誤。而在 heap 中或是 sub-heap 中分配的空間則可能會留在進程內存空間內,還可以再次引用(當然是很危險的)。

  當 ptmalloc munmap chunk 時,如果回收的 chunk 空間大小大於 mmap 分配閾值的當前值,並且小於 DEFAULT_MMAP_THRESHOLD_MAX(32 位系統默認爲 512KB,64 位系統默認爲 32MB),ptmalloc 會把 mmap 分配閾值調整爲當前回收的 chunk 的大小,並將 mmap 收縮閾值(mmap trim threshold)設置爲 mmap 分配閾值的 2 倍。這就是 ptmalloc 的對 mmap分配閾值的動態調整機制,該機制是默認開啓的,當然也可以用 mallopt()關閉該機制(將在3.2.6 節介紹如何關閉該機制)。

6.4.3 內存分配步驟

6.4.3.1 分配算法概述,以 32 系統爲例,64 位系統類似。

  • 小於等於 64 字節:用 pool 算法分配。
  • 64 到 512 字節之間:在最佳匹配算法分配和 pool 算法分配中取一種合適的。
  •  大於等於 512 字節:用最佳匹配算法分配。
  •  大於等於 mmap 分配閾值(默認值 128KB):根據設置的 mmap 的分配策略進行分配,

如果沒有開啓 mmap 分配閾值的動態調整機制,大於等於 128KB 就直接調用 mmap分配。否則,大於等於 mmap 分配閾值時才直接調用 mmap()分配。

6.4.3.2 ptmalloc 的響應用戶內存分配要求的具體步驟爲:

  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 的空間大小將其放入 smallbins 或是 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 的大小,如果是,則從 topchunk 中分出一塊來。否則轉到下一步。
  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。以滿足分配要求。

6.4.3.2 內存回收概述

free() 函數接受一個指向分配區域的指針作爲參數,釋放該指針所指向的 chunk。而具
體的釋放方法則看該 chunk 所處的位置和該 chunk 的大小。free()函數的工作步驟如下:

  1. free()函數同樣首先需要獲取分配區的鎖,來保證線程安全。
  2. 判斷傳入的指針是否爲 0,如果爲 0,則什麼都不做,直接 return。否則轉下一步。
  3. 判斷所需釋放的 chunk 是否爲 mmaped chunk,如果是,則調用 munmap()釋放mmaped chunk,解除內存空間映射,該該空間不再有效。如果開啓了 mmap 分配閾值的動態調整機制,並且當前回收的 chunk 大小大於 mmap 分配閾值,將 mmap分配閾值設置爲該 chunk 的大小,將 mmap 收縮閾值設定爲 mmap 分配閾值的 2倍,釋放完成,否則跳到下一步。
  4. 判斷 chunk 的大小和所處的位置,若 chunk_size <= max_fast,並且 chunk 並不位於heap 的頂部,也就是說並不與 top chunk 相鄰,則轉到下一步,否則跳到第 6 步。(因爲與 top chunk 相鄰的小 chunk 也和 top chunk 進行合併,所以這裏不僅需要判斷大小,還需要判斷相鄰情況)
  5. 將 chunk 放到 fast bins 中,chunk 放入到 fast bins 中時,並不修改該 chunk 使用狀態位 P。也不與相鄰的 chunk 進行合併。只是放進去,如此而已。這一步做完之後釋放便結束了,程序從 free()函數中返回。
  6. 判斷前一個 chunk 是否處在使用中,如果前一個塊也是空閒塊,則合併。並轉下一步。
  7. 判斷當前釋放 chunk 的下一個塊是否爲 top chunk,如果是,則轉第 9 步,否則轉下一步。
  8. 判斷下一個 chunk 是否處在使用中,如果下一個 chunk 也是空閒的,則合併,並將合併後的 chunk 放到 unsorted bin 中。注意,這裏在合併的過程中,要更新 chunk的大小,以反映合併後的 chunk 的大小。並轉到第 10 步。
  9. 如果執行到這一步,說明釋放了一個與 top chunk 相鄰的 chunk。則無論它有多大,都將它與 top chunk 合併,並更新 top chunk 的大小等信息。轉下一步。
  10. 判斷合併後的 chunk 的大小是否大於 FASTBIN_CONSOLIDATION_THRESHOLD(默認64KB),如果是的話,則會觸發進行 fast bins 的合併操作,fast bins 中的 chunk 將被遍歷,並與相鄰的空閒 chunk 進行合併,合併後的 chunk 會被放到 unsorted bin 中。fast bins 將變爲空,操作完成之後轉下一步。
  11. 判斷 top chunk 的大小是否大於 mmap 收縮閾值(默認爲 128KB),如果是的話,對於主分配區,則會試圖歸還 top chunk 中的一部分給操作系統。但是最先分配的128KB 空間是不會歸還的,ptmalloc 會一直管理這部分內存,用於響應用戶的分配請求;如果爲非主分配區,會進行 sub-heap 收縮,將 top chunk 的一部分返回給操作系統,如果 top chunk 爲整個 sub-heap,會把整個 sub-heap 還回給操作系統。做完這一步之後,釋放結束,從 free() 函數退出。可以看出,收縮堆的條件是當前free 的 chunk 大小加上前後能合併 chunk 的大小大於 64k,並且要 top chunk 的大小要達到 mmap 收縮閾值,纔有可能收縮堆。

參考
https://www.iteye.com/blog/mqzhuang-1014269

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章