一次"內(nèi)存泄漏"引發(fā)的血案

2017年末,手Q春節(jié)紅包項目期間,為保障活動期間服務正常穩(wěn)定,我對性能不佳的Ark Server進行了改造和重寫。重編發(fā)布一段時間后,結果發(fā)現(xiàn)新發(fā)布的Svr的機器內(nèi)存一直在上漲。如下圖示:

內(nèi)存增長趨勢圖

觀察后,第一反應是完了,一定存在內(nèi)存泄漏?;?、4天時間,使用各種辦法進行定位,一無所獲。
后來無意中在SPP日志中發(fā)現(xiàn)了端倪,日志中一直打印tcp socket[%d] user check pkg not ok, but no more buff,看代碼邏輯,是收包緩沖區(qū)太小,導致調(diào)用方不斷使用new操作來擴充緩沖區(qū)。我仔細檢查了下調(diào)用方的代碼邏輯,使用的是SPP微線程架構,收包緩沖區(qū)是一個Msg的局部變量,在Msg析構時,都會調(diào)用delete,換而言之,這里絕不可能存在內(nèi)存泄漏。
既然不存在內(nèi)存泄漏,內(nèi)存為什么會一直漲呢?按照上圖來看,內(nèi)存在1天內(nèi)漲了1G左右,這個速度也太可怕了吧。既然唯一的線索在內(nèi)存分配操作newdelete上,那么只可能是這里有貓膩。
網(wǎng)上搜索了下delete not return memory,果然說來話長啊。下面我們就來回顧下C++程序中的內(nèi)存管理機制........


物理內(nèi)存、虛擬內(nèi)存

首先,要理清楚2個概念:虛擬內(nèi)存(空間)、物理內(nèi)存

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

進程虛擬空間

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

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


Linux下動態(tài)內(nèi)存分配實現(xiàn)機制

C、C++的動態(tài)內(nèi)存分配、管理都是基于malloc和free的,動態(tài)內(nèi)存即虛擬空間堆區(qū)。另外多說一句,malloc和free操作的也是虛擬地址空間。
malloc,動態(tài)內(nèi)存分配函數(shù)。是通過brk(sbrk)mmap這兩個系統(tǒng)調(diào)用實現(xiàn)的。
結合上文進程虛擬空間圖,brk(sbrk)是將數(shù)據(jù)段(.data)的最高地址指針_edata往高地址推。mmap是在進程的虛擬地址空間中(堆和棧中間,稱為文件映射區(qū)域的地方)找一塊空閑的虛擬內(nèi)存。這兩種實現(xiàn)方式的區(qū)別大致如下:

  1. brk(sbrk),性能損耗少; mmap相對而言,性能損耗大
  2. mmap不存在內(nèi)存碎片(是物理頁對齊的,整頁映射和釋放); brk(sbrk)可能存在內(nèi)存碎片(由于new和delete的順序不同,可能存在空洞,又稱為碎片)
    無論是通過brk(sbrk)還是mmap調(diào)用分配的內(nèi)存都是虛擬空間的內(nèi)存,只有在第一次訪問已分配的虛擬地址空間的時候,發(fā)生缺頁中斷,操作系統(tǒng)負責分配物理內(nèi)存,然后建立虛擬內(nèi)存和物理內(nèi)存之間的映射關系。

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

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

在標準C庫中,提供了malloc/free函數(shù)分配釋放內(nèi)存,這兩個函數(shù)底層是由brk,mmap,munmap這些系統(tǒng)調(diào)用實現(xiàn)的。

如何查看進程發(fā)生缺頁中斷的次數(shù)?
用ps -o majflt,minflt -C program命令查看。
majflt代表major fault,中文名叫大錯誤,minflt代表minor fault,中文名叫小錯誤。這兩個數(shù)值表示一個進程自啟動以來所發(fā)生的缺頁中斷的次數(shù)。

發(fā)成缺頁中斷后,執(zhí)行了那些操作?
當一個進程發(fā)生缺頁中斷的時候,進程會陷入內(nèi)核態(tài),執(zhí)行以下操作:
1、檢查要訪問的虛擬地址是否合法
2、查找/分配一個物理頁
3、填充物理頁內(nèi)容(讀取磁盤,或者直接置0,或者啥也不干)
4、建立映射關系(虛擬地址到物理地址)
重新執(zhí)行發(fā)生缺頁中斷的那條指令
如果第3步,需要讀取磁盤,那么這次缺頁中斷就是majflt,否則就是minflt。

查看物理內(nèi)存頁使用情況:cat /proc/$PID/smaps,里面詳細記錄了該進程使用的物理頁內(nèi)存情況,如Private_Dirty、Private_Clean等
mmap系統(tǒng)調(diào)用:讀寫MMAP映射區(qū),相當于讀寫被映射的文件。本意是將文件當作內(nèi)存一樣讀寫。相比Read、Write,減少了內(nèi)存拷貝(Read、Write一個硬盤文件,需要先將數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到應用緩沖區(qū)(read),然后再將數(shù)據(jù)從應用緩沖區(qū)拷貝回內(nèi)核緩沖區(qū)(write)。mmap直接將數(shù)據(jù)從內(nèi)核緩沖區(qū)映拷貝到另一個內(nèi)核緩沖區(qū)),但是被修改的數(shù)據(jù)從MMAP區(qū)同步到磁盤文件上,依賴于系統(tǒng)的頁管理算法,默認會慢條斯理得將內(nèi)容寫到磁盤上。另外提供了msync強制同步到磁盤上。


Glibc內(nèi)存分配算法

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

整體來說,glibc采用的是dlmalloc。為了避免頻繁調(diào)用系統(tǒng)調(diào)用,它內(nèi)部維護了一個內(nèi)存池,方便reuse,又稱為free-list或bins,如下圖示:

free-list

所有調(diào)用delete釋放的內(nèi)存,并不是立即調(diào)用brk(sbrk)歸還給操作系統(tǒng),而是先將這個內(nèi)存塊掛在free-list(bins)里面,然后進行內(nèi)存歸并(可選操作,相鄰的可用內(nèi)存塊合并為更大的可用內(nèi)存塊),并檢查是否達到malloc_trim的threshhold,如果達到了,則調(diào)用malloc_trim歸還部分可用內(nèi)存給操作系統(tǒng)。
glibc中,設置了默認進行malloc_trim的threshhold為128K,也就是說當dlmalloc管理的內(nèi)存池中最大可用內(nèi)存>128K時,就會執(zhí)行malloc_trim操作,歸還部分內(nèi)存給操作系統(tǒng);而在可用內(nèi)存<=128K時,及時程序中delete了這部分內(nèi)存,這些內(nèi)存也是不會歸還給操作系統(tǒng)的。表現(xiàn)為:調(diào)用delete之后,進程占用的內(nèi)存并沒有減少。

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

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

這些參數(shù)都可以通過mallopt進行調(diào)整。
malloc_trim(0)可以立即執(zhí)行trim操作,將內(nèi)存還給操作系統(tǒng)。
具體fastbin相關的內(nèi)容,此處不做介紹,前期有很多基于fastbin的堆溢出攻擊,感興趣的同學可以google關鍵字fastbin搜索下。

測試:

  1. 循環(huán)new分配64K * 2048的內(nèi)存空間,寫入臟數(shù)據(jù)后,循環(huán)調(diào)用delete釋放。top看進程依然使用131M內(nèi)存,沒有釋放。 ---- 此時用brk
  2. 循環(huán)new分配128K * 2048的內(nèi)存空間,寫入臟數(shù)據(jù)后,循環(huán)調(diào)用delete釋放。top看進程使用,2960字節(jié)內(nèi)存,完全釋放。 ---- 此時用mmap
  3. 設置M_MMAP_THRESHOLD 256k,循環(huán)new分配128k * 2048 的內(nèi)存空間,寫入臟數(shù)據(jù)后,循環(huán)調(diào)用delete釋放,而后調(diào)用malloc_trim(0)。top看進程使用,2348字節(jié),完全釋放。 ----此時用brk
64k Delete前內(nèi)存占用

64k Delete后內(nèi)存占用

128k Delete前內(nèi)存占用

128k Delete后內(nèi)存占用

測試代碼如下:

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<2064; j++)
        delete []Array[j];

    cout << "Delete All" << endl;

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

    while(1) sleep(10);
}

一個例子來說明內(nèi)存分配的原理

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

  1. 進程啟動的時候,其(虛擬)內(nèi)存空間的初始布局如圖1所示。
    其中,mmap內(nèi)存映射文件是在堆和棧的中間(例如libc-2.2.93.so,其它數(shù)據(jù)文件等),為了簡單起見,省略了內(nèi)存映射文件。
    _edata指針(glibc里面定義)指向數(shù)據(jù)段的最高地址。
  2. 進程調(diào)用A=malloc(30K)以后,內(nèi)存空間如圖2:
    malloc函數(shù)會調(diào)用brk系統(tǒng)調(diào)用,將_edata指針往高地址推30K,就完成虛擬內(nèi)存分配。
    你可能會問:只要把_edata+30K就完成內(nèi)存分配了?
    事實是這樣的,_edata+30K只是完成虛擬地址的分配,A這塊內(nèi)存現(xiàn)在還是沒有物理頁與之對應的,等到進程第一次讀寫A這塊內(nèi)存的時候,發(fā)生缺頁中斷,這個時候,內(nèi)核才分配A這塊內(nèi)存對應的物理頁。也就是說,如果用malloc分配了A這塊內(nèi)容,然后從來不訪問它,那么,A對應的物理頁是不會被分配的。
  3. 進程調(diào)用B=malloc(40K)以后,內(nèi)存空間如圖3。
    情況二、malloc大于128k的內(nèi)存,使用mmap分配內(nèi)存,在堆和棧之間找一塊空閑內(nèi)存分配(對應獨立內(nèi)存,而且初始化為0),如下圖:
  4. 進程調(diào)用C=malloc(200K)以后,內(nèi)存空間如圖4:
    默認情況下,malloc函數(shù)分配內(nèi)存,如果請求內(nèi)存大于128K(可由M_MMAP_THRESHOLD選項調(diào)節(jié)),那就不是去推_edata指針了,而是利用mmap系統(tǒng)調(diào)用,從堆和棧的中間分配一塊虛擬內(nèi)存。
    這樣子做主要是因為::brk分配的內(nèi)存需要等到高地址內(nèi)存釋放以后才能釋放(例如,在B釋放之前,A是不可能釋放的,這就是內(nèi)存碎片產(chǎn)生的原因,什么時候緊縮看下面),而mmap分配的內(nèi)存可以單獨釋放。
    當然,還有其它的好處,也有壞處,再具體下去,有興趣的同學可以去看glibc里面malloc的代碼了。
  5. 進程調(diào)用D=malloc(100K)以后,內(nèi)存空間如圖5;
  6. 進程調(diào)用free(C)以后,C對應的虛擬內(nèi)存和物理內(nèi)存一起釋放。


  7. 進程調(diào)用free(B)以后,如圖7所示:
    B對應的虛擬內(nèi)存和物理內(nèi)存都沒有釋放,因為只有一個_edata指針,如果往回推,那么D這塊內(nèi)存怎么辦呢?當然,B這塊內(nèi)存,是可以重用的,如果這個時候再來一個40K的請求,那么malloc很可能就把B這塊內(nèi)存返回回去了。
  8. 進程調(diào)用free(D)以后,如圖8所示:
    B和D連接起來,變成一塊140K的空閑內(nèi)存。
  9. 默認情況下:
    當最高地址空間的空閑內(nèi)存超過128K(可由M_TRIM_THRESHOLD選項調(diào)節(jié))時,執(zhí)行內(nèi)存緊縮操作(trim)。在上一個步驟free的時候,發(fā)現(xiàn)最高地址空閑內(nèi)存超過128K,于是內(nèi)存緊縮,變成圖9所示。

結論

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

按照這個理論,機器內(nèi)存應該先漲后平。我們看下幾天后,機器的內(nèi)存趨勢圖:

內(nèi)存增長趨勢圖

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

經(jīng)驗

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


參考文章

  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環(huán)境高級編程
  8. 內(nèi)存分配的原理__進程分配內(nèi)存有兩種方式,分別由兩個系統(tǒng)調(diào)用完成:brk和mmap(不考慮共享內(nèi)存)
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容