一次“內存泄露”引發的血案

2017年末,手Q春節紅包項目期間,爲保障活動期間服務正常穩定,我對性能不佳的Ark Server進行了改造和重寫。重編髮布一段時間後,結果發現新發布的Svr的機器內存一直在上漲。如下圖示:

觀察後,第一反應是完了,一定存在內存泄露。花了3、4天時間,使用各種辦法進行定位,一無所獲。

後來無意中在SPP日誌中發現了端倪,日誌中一直打印tcp socket[%d] user check pkg not ok, but no more memory,看代碼邏輯,是收包緩衝區太小,導致調用方不斷使用new操作來擴充緩衝區,我仔細檢查了下調用方的代碼邏輯,使用的是SPP微線程架構,收包緩衝區是一個Msg的局部變量,在Msg析構時,都會調用delete,換而言之,這裏絕不可能存在內存泄露。

既然不存在內存泄露,內存爲什麼會一直漲呢?按照

上圖來看,內存在1天內漲了1G左右,這個速度也太可怕了吧。既然唯一的線索在內存分配操作newdelete上,那麼只可能是這裏有貓膩。
網上搜索了下delete not return memory,果然說來話長啊。下面我們就來回顧下C++程序中的內存管理機制


物理內存、虛擬內存

物理內存好說,就是機器的真實內存,你機器是多大內存條,物理內存就多大。虛擬內存(虛擬地址空間)是一個邏輯概念,32bit下每個進程都有4G虛擬地址空間,而且每個進程間的地址空間相互獨立。
從進程的角度來說,每個進程均認爲自己獨享整個內存空間(4G)。進程空間分佈如下圖:

如上圖示:最高的1G空間保留給內核使用。接下來是棧,棧向低地址方向延伸(棧的大小受RLIMIT_STACK限制,默認爲8M),下面是MMAP區(文件映射內存,如動態庫等,SPP微線程的私有棧也位於這裏)下面是堆(動態內存增長),堆向高地址方向延伸,接下來依次是BSS、數據段、代碼段。

需要注意的一點是:上面所說的都是虛擬內存。只有在真正使用到這片內存空間時,纔會涉及到物理內存頁的分配等(內核管理,頁錯誤)。

Linux下動態內存分配實現機制

C、C++的動態內存分配、管理都是基於malloc和free的,動態內存即虛擬空間堆區。另外多說一句,malloc和free操作的也是虛擬地址空間。

malloc,動態內存分配函數。是通過brk(sbrk)和mmap這兩個系統調用實現的。

結合上文進程虛擬空間圖,brk(sbrk)是將數據段(.data)的最高地址指針_edata往高地址推。mmap是在進程的虛擬地址空間中(堆和棧中間,稱爲文件映射區域的地方)找一塊空閒的虛擬內存。這兩種實現方式的區別大致如下:

1.brk(sbrk),性能損耗少;mmap相對而言,性能損耗大

2.mmap不存在內存碎片(是物理頁對齊的,整頁映射和釋放);brk(sbrk)可能存在內存碎片(由於new和delete的順序不同,可能存在空洞,又稱爲碎片)

無論是通過brk(sbrk)還是mmap調用分配的內存都是虛擬空間的內存,只有在第一次訪問已分配的虛擬地址空間的時候,發生缺頁終端,操作系統負責分配物理內存,然後建立虛擬內存和物理內存之間的映射關係。

delete,動態內存釋放函數。如果是brk(sbrk)分配的內存,直接調用brk(sbrk)並傳入負數,即可縮小Heap區的大小;如果是mmap分配的內存,調用munmap歸還內存。無論這兩種那種處理方式,都會立即縮減進程虛擬地址空間,並歸還未使用的物理內存給操作系統。

 

brk(sbrk)和mmap都是系統調用,如果程序中頻繁的進行內存的擴張和收縮,每次都直接調用,當然可以實現內存精確管理的目的,但是隨之而來的性能損耗也很顯著。目前大多數運行庫(glibc)等對內存管理做了一層封裝,避免每次直接調用系統調用影響性能。如此,就設計到運行庫的內存分配的算法問題了。

在標準C庫中,提供了malloc/free函數分配釋放內存,這兩個函數底層是由brk,mmap,munmap這些系統調用實現的。

如何查看進程發生缺頁終端的次數?

用ps -o majflt,minflt -C program命令查看。

majflt代表major fault,中文名叫大錯誤,minflt代表minor fault,中文名叫小錯誤。這兩個數值表示一個進程自啓動以來所發生的缺頁中斷的次數。

發生缺頁中斷後,執行了哪些操作?

當一個進程發生缺頁中斷的時候,進程會陷入內核態,執行一下操作:

1、檢察要訪問的虛擬地址是否合法。

2、查找/分配一個物理頁

3、填充物理頁內容(讀取磁盤,或者直接置0,或者啥也不幹)

4、建立映射關係(虛擬地址到物理地址)

重新執行發生缺頁終端的那條指令

如果第三步,需要讀取磁盤,那麼這次缺頁中斷就是majflt,否則就是minflt。

查看物理內存使用情況: cat /proc/$PID/smaps,裏面詳細記錄了該進程使用的物理頁內存情況,如Private_Dirty、Private_Clean等

mmap系統調用:讀寫MMAP映射區,相當於讀寫被映射的文件。本意是將文件當做內存一樣讀寫。相比Read、Write,減少了內存拷貝(Read、Write一個硬盤文件,需要先將數據從內核緩衝區拷貝到應用緩衝區(read),然後再將數據從應用緩衝區拷貝回內核緩衝區(write)。mmap直接將數據從內核緩衝區映射拷貝到另一個內核緩衝區),但是被修改的數據從MMAP區同步到磁盤文件上,依賴於系統的頁管理算法,默認hi慢條斯理的將內容寫到磁盤上。另外提供了msync強制同步到磁盤上。

Glibc內存分配算法

glibc的內存分配算法,是基於dlmalloc實現的ptmalloc,dlmalloc詳細可以參考A Memory Allocator或者我之前的文章Glibc內存分配器。這裏主要講下和內存歸還策略相關的,其他內容不做過多擴展。

整體來說,glibc採用的是dlmalloc。爲了避免頻繁調用系統調用,它內部維護了一個內存池,方便reuse,又稱爲free-list或bins,如下圖所示:

所有調用delete釋放的內存,並不是立即調用brk(sbrk)歸還給操作系統,而是先將這個內存塊掛在free-list(bins)裏面,然後進行內存歸併(可選操作,相鄰的可用內存塊合併爲更大的可用內存塊),並檢查是否達到malloc_trim的threshhold,如果達到了,則調用malloc_trim歸還部分可用內存給操作系統。

glibc中,設置了默認進行malloc_trim的threshhold爲128K,也就是說當dlmalloc管理的內存池中最大可用內存>128K時,就會執行malloc_trim操作,歸還部分內存給操作系統;而在可用內存<=128K時,及時程序中delete了這部分內存,這些內存也是不歸還給操作系統的。表現爲:調用delete之後,進程佔用的內存並沒有減少。

另外,部分glibc的默認設置如下:

DEFAULT_MXFAST       64 (for 32bit), 128(for 64bit) //free-list(fastbin)最大內存塊
DEFAULT_TRIM_THRESHOLD  128 * 1024 // malloc_trim的門檻值  128k
DEFAULT_TOP_PAD      0
DEFAULT_MMAP_THRESHOLD  128 * 1024   // 使用mmap分配內存的門檻值  128k
DEFAULT_MMAP_MAX     65536  // mmap的最大數量

這些參數都可以通過mallopt進行調整。

malloc_trim(0)可以立即執行trim操作,將內存還給操作系統。

具體fastbin相關的內容,此處不做介紹,前期有很多基於fastbin的堆溢出攻擊,感興趣的同學可以google關鍵字fastbin搜索下。

測試:

1.循環new分配64K * 2048的內存空間,寫入髒數據後,循環調用delete釋放。top看進程依然使用131M內存,沒有釋放。  ---- 此時用brk

2.循環new分配128K * 2048的內存空間,寫入髒數據後,循環調用delete釋放。top看進程使用,2960字節內存,完全釋放。   ----此時用mmap

3.設置M_MMAP_THRESHOLD 256k, 循環new分配128k * 2048的內存空間,寫入髒數據後,循環調用delete釋放,而後調用malloc_trim(0).top看進程使用,2348字節,完全釋放。  ----此時用brk

 

64k Delete前內存佔用:

64k Delete後內存佔用:

128k Delete前內存佔用:

128k Delete後內存佔用:

測試代碼如下:

int main(int argc, char *argv[])
{
    mallopt(M_MMAP_THRESHOLD, 256*1024);
    //mallopt(M_TRIM_THRESHOLD, 64*1024);
    //MemoryLeak
    int MEMORY_SIZE = hydra::CTrans::STOI(argv[1]);
    vector<char *> Array;
    for (int j=0; j<2064; j++) {
        char *Buff = new char[MEMORY_SIZE];
        for (int i=0; i<MEMORY_SIZE; i++) 
            Buff[i] = i;
        Array.push_back(Buff);
    }
    
    sleep(10);

    for (int j=0; j<2065; j++) 
        delete []Array[j];

    cout << "Delete All" << endl;

    //sleep(10);
    //malloc_trim(0);
    //cout << "strim" << endl;

    while(1) sleep(10);
}

一個例子來說明內存分配的原理

情況下、malloc小於128k的內存,使用brk分配內存,將_edata往高地址推(只分配虛擬空間,不對應物理內存(因此沒有初始化),第一次讀/寫數據時,引起內核缺頁中斷,內核才分配對應的物理內存,然後虛擬地址空間建立映射關係),如下圖:

1.進程啓動的時候,其(虛擬)內存空間的初始佈局如圖1所示。

其中,mmap內存映射文件是在堆和棧的中間(例如libc-2.2.93.so,其他數據文件等),爲了簡單起見,省略了內存映射文件。

_edata指針(glibc裏面定義)指向數據段的最高地址。

2.進程調用A=malloc(30k以後,內存空間如圖2:

malloc調用會調用brk系統調用,將_edata指針往高地址推30K,就完成虛擬內存分配。

你可能會問:只要把_edata+30K就完成內存分配了?

事實是這樣的,_edata_30K只是完成虛擬地址的分配,A這塊內存現在還是沒有物理頁與之對應的,等到進程第一次讀寫A這塊內存的時候,發生缺頁中斷,這個時候,內核才分配A這塊內存對應的物理頁。也就是說,如果用malloc分配了A這塊內容,然後從來不訪問它,那麼,A對應的物理頁是不會被分配的。

3.進程調用B=malloc(40K)以後,內存空間如圖3.

情況二、malloc大於128k的內存,使用mmap分配內存,在堆和棧之間找一塊空閒內存分配(對應獨立內存,而且初始化爲0),如下圖:

4.進程調用C=malloc(200K)以後,內存空間如圖4:

默認情況下,malloc函數分配內存,如果請求內存大於128k(可由M_MAP_THRESHOLD選項調節),那就不是去推_edata指針了,而是利用mmap系統調用,從堆和棧的中間分配一塊虛擬內存。

這樣子做主要是因爲::brk分配的內存需要等到高地址內存釋放以後才能釋放(例如,在B釋放之前,A是不可能釋放的,這就是內存碎片產生的原因,什麼時候緊縮看下面),而mmap分配的內存可以單獨釋放。

當然,還有其他的好處,也有壞處,再具體下去,有興趣的同學可以去看glibc裏面malloc的代碼了。

5.進程調用D=malloc(100K)以後,內存空間如圖5;

6.進程調用free(C)以後,C對應的虛擬內存和物理內存一起釋放。

7.進程調用free(B)以後,如圖7所示:

B對應的虛擬內存和物理內存都沒有釋放,因爲只有一個_edata指針,如果往回推,那麼D這塊內存怎麼辦呢?當然,B這塊內存,是可以重用的,如果這個時候再來一個40K的請求,那麼malloc很可能就把B這塊內存返回回去了。

8.進程調用free(D)以後,如圖8所示:

B和D連接起來,變成了一塊140K的空閒內存。

9.默認情況下:

  當最高地址空閒的空閒內存超過128K(可由M_TRIM_THRESHOLD選項調節)時,執行內存緊縮操作(trim)。在上一個步驟free的時候,發現最高地址空閒內存超過128K,於是內存緊縮,變成圖9所示。


結論

簡單來說,文章開頭內存不斷增長的趨勢的根本原因是:glibc在利用操作系統的內存構建進程自身的內存池。由於進程本身處理請求量大,頻繁調用new和delete,在一段時間內,進程不斷的從操作系統獲取內存來滿足新增的調用要求,但是從最終結果上來將,總有一個臨界點,使得進程從操作系統新獲取的內存和歸還操作系統的內存達成相對平衡。在這個動態平衡建立前,內存會不斷增長,直到到達臨界點。

按照這裏理論,機器內存應該先漲後平。我們看下幾天後,機器的內存趨勢圖:

 

可以看出,在系統內存增長到3.7G左右時,整個機器的內存處於動態平衡的階段,不再顯著增長。由此驗證,我們的推斷是正確的。

經驗

遇到如文章開頭所說的那種內存不斷增長的情況,不要輕易斷定內存泄漏,先觀察一段時間再說。很可能是上文分析的原因。

參考文章

  1. A Memory Allocator(dlmalloc, glibc)
  2. Free/Delete Not Returning Memory To OS?
  3. Does calling free or delete ever release memory back to the “system”
  4. How is malloc() implemented internally? [duplicate]
  5. How do malloc() and free() work?
  6. 淺析Linux堆溢出之fastbin
  7. Unix環境高級編程
  8. 內存分配的原理__進程分配內存有兩種方式,分別由兩個系統調用完成:brk和mmap(不考慮共享內存)

 

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