【引言】
?? ?最近在生產(chǎn)環(huán)境遇到一個奇怪的現(xiàn)象,nginx占用的虛擬內(nèi)存和物理內(nèi)存都很高,并且一直不會下降。

? ? 因?yàn)榉?wù)器本身的業(yè)務(wù)量并不大,而且對比集群其他服務(wù)器nginx才幾十兆的內(nèi)存消耗,第一個想到的就是內(nèi)存泄漏。但是連續(xù)觀察了多天,內(nèi)存也沒有進(jìn)一步上漲,和以前遇過的內(nèi)存泄漏問題不是很像。
? ? 偶然發(fā)現(xiàn)了一個特別有用的函數(shù)malloc_stats(),可以打印出進(jìn)程malloc分配的虛擬內(nèi)存信息。故gdb attach到其中一個worker進(jìn)程,調(diào)用call?malloc_stats()函數(shù),將worker進(jìn)程的內(nèi)存分配信息打印出來,默認(rèn)會輸出到nginx的error.log中。 如下所示:

? ? 從圖中可以看到,雖然進(jìn)程malloc了2G左右的內(nèi)存,但是實(shí)際in use的只有28m,這說明其他絕大部分的內(nèi)存都已經(jīng)free了,出現(xiàn)了內(nèi)存空洞,而不是內(nèi)存泄漏。那到底內(nèi)存空洞和內(nèi)存泄漏有什么區(qū)別呢?
【glibc內(nèi)存管理】
? ? ? ?Linux通過brk、mmap/munmap系統(tǒng)調(diào)用來操作內(nèi)存。但是頻繁的系統(tǒng)調(diào)用對于系統(tǒng)性能是很大的損耗。為了解決這個問題,glibc對系統(tǒng)調(diào)用進(jìn)行了一層封裝,相當(dāng)于一個代理,它實(shí)現(xiàn)了一個內(nèi)存池的功能,提供malloc/free函數(shù)給用戶調(diào)用,也就是ptmalloc。類似有g(shù)oogle實(shí)現(xiàn)的tcmalloc等。這樣用戶通過malloc/free函數(shù)來操作內(nèi)存池,減少頻繁系統(tǒng)調(diào)用帶來的性能損耗。當(dāng)內(nèi)存池中的空閑內(nèi)存可以滿足用戶申請時,優(yōu)先返回內(nèi)存池中的內(nèi)存地址;否則glibc才會通過brk/mmap等系統(tǒng)調(diào)用去向系統(tǒng)申請。
【ptmalloc內(nèi)存管理三個概念:arena、bin、chunk】
? ?1.? Arena:ptmalloc對進(jìn)程內(nèi)存是通過一個個Arena來進(jìn)行管理的。
? ????? 在ptmalloc機(jī)制下,每個進(jìn)程都有一個內(nèi)存主分配區(qū)Main_arena和若干個非主分配區(qū)Non_Main_arena,主分配區(qū)只有一個,非主分配區(qū)可以動態(tài)增加。主分配區(qū)和非主分配區(qū)采用環(huán)形鏈表進(jìn)行管理,每一個分配區(qū)采用互斥鎖mutex實(shí)現(xiàn)多線程訪問互斥。在多線程的場景下,如果線程申請內(nèi)存時當(dāng)前的分配區(qū)都已經(jīng)被加鎖,那么ptmalloc將會生成一個新的非主分配區(qū)。
? ????? 當(dāng)一個線程調(diào)用malloc申請內(nèi)存時,該線程先查看線程私有變量中是否已經(jīng)存在一個分配區(qū)。如果存在,則對該分配區(qū)加鎖,加鎖成功的話就用該分配區(qū)進(jìn)行內(nèi)存分配;失敗的話則搜索環(huán)形鏈表找一個未加鎖的分配區(qū)。如果所有分配區(qū)都已經(jīng)加鎖,那么malloc會開辟一個新的分配區(qū)加入環(huán)形鏈表并加鎖,用它來分配內(nèi)存。釋放操作同樣需要獲得鎖才能進(jìn)行。
????????這種機(jī)制在多線程競爭鎖激烈的場景下會帶來一個問題:非主分配區(qū)開辟越來越多,因?yàn)樗坏╅_辟了就不會釋放,一個分配區(qū)就是64MB。這樣也會導(dǎo)致進(jìn)程占用的內(nèi)存越來越多(可能實(shí)際使用的并不多)。如果系統(tǒng)配置的ulimit進(jìn)程最大虛擬內(nèi)存值不是unlimited,那么當(dāng)進(jìn)程占用的內(nèi)存達(dá)到ulimit值,就會core掉。這個情況也可以在pmap -p pid中看到里面有大量的64MB大小的anon內(nèi)存塊。這個問題可以通過設(shè)置MALLOC_ARENA_MAX環(huán)境變量來限制Arena的最大數(shù)量規(guī)避。
?? ?????主分配區(qū)可以使用brk和mmap向操作系統(tǒng)申請?zhí)摂M內(nèi)存;但是非主分配區(qū)只能通過mmap申請,并且mmap每次申請的單位為64MB(64位系統(tǒng)下),再從中切割出用戶所需大小的內(nèi)存。
? ????? 主分配區(qū)使用brk調(diào)用可以訪問進(jìn)程的heap堆區(qū)。堆區(qū)的內(nèi)存申請是通過brk調(diào)用將堆頂指針往高地址移動實(shí)現(xiàn)的,這樣brk申請的內(nèi)存肯定是連續(xù)的;釋放的時候?qū)⒍秧斨羔樛偷刂芬苿樱ú⒉槐WC將free的內(nèi)存歸還給操作系統(tǒng),需要堆頂出現(xiàn)一塊連續(xù)的超過閾值大小的空閑內(nèi)存時才會歸還給操作系統(tǒng))。如果主分配區(qū)使用mmap申請內(nèi)存,那么free時會調(diào)用munmap直接將內(nèi)存歸還給操作系統(tǒng)。那么主分配區(qū)什么時候使用brk什么時候使用mmap呢?
?? ?????系統(tǒng)內(nèi)核有一個閾值DEFAULT_MMAP_THRESHOLD,一般默認(rèn)為128KB。當(dāng)malloc申請的內(nèi)存小于該閾值,glibc會采用brk去向系統(tǒng)申請內(nèi)存;而申請的內(nèi)存大于該閾值時,glibc會采用mmap去向系統(tǒng)申請。
?? ?????但是這樣會帶來一個問題:我們在程序中釋放一個對象是無法保證它的內(nèi)存是否連續(xù)釋放的。可能出現(xiàn)先申請的內(nèi)存,即堆底的內(nèi)存先釋放的情況,因?yàn)槎秧數(shù)膬?nèi)存還在使用,這時候是不能將堆頂指針往下移的,這時候雖然前面的那塊內(nèi)存已經(jīng)free來,但是ptmalloc仍然不會將其還給操作系統(tǒng),而是把它緩存到自己的池子里。這時候內(nèi)存看上去仍然會被計(jì)算在進(jìn)程的內(nèi)存使用中,導(dǎo)致進(jìn)程的內(nèi)存使用量一直降不下去(如果堆頂?shù)闹羔樢恢辈会尫诺脑挘?,這即是內(nèi)存空洞(內(nèi)存碎片)。理論上這些內(nèi)存空洞都是可以復(fù)用的,如果后面用戶又申請同樣大小的內(nèi)存,ptmalloc會將這些空洞內(nèi)存分配給它。所以在大量申請釋放小塊內(nèi)存的場景下,進(jìn)程容易出現(xiàn)內(nèi)存空洞的問題。即隨著某個時候業(yè)務(wù)量的激增,進(jìn)程使用的虛擬內(nèi)存漲上去了就降不下來了,看著就像是出現(xiàn)了內(nèi)存泄漏。
? ? ????但是相比內(nèi)存泄漏,內(nèi)存空洞的問題情況相對要好一些,因?yàn)閮?nèi)存可以復(fù)用,如果后面的業(yè)務(wù)量不再繼續(xù)上漲,理論上進(jìn)程內(nèi)存使用量是不會繼續(xù)增多的。
????2. Chunk:ptmalloc使用chunk數(shù)據(jù)結(jié)構(gòu)來表示一塊具體申請或者釋放的內(nèi)存。
????????也就是說chunk是glibc內(nèi)存管理的“最小單位”。
????3. Bin:用于管理chunk的數(shù)據(jù)結(jié)構(gòu)
????????用戶free掉的內(nèi)存并不是都會馬上歸還給系統(tǒng),ptmalloc會統(tǒng)一管理heap和mmap映射區(qū)域中的空閑的chunk,當(dāng)用戶進(jìn)行下一次分配請求時,ptmalloc會首先試圖在空閑的chunk中挑選一塊給用戶,這樣就避免了頻繁的系統(tǒng)調(diào)用,降低了內(nèi)存分配的開銷。ptmalloc將相似大小的chunk用雙向鏈表鏈接起來,這樣的一個鏈表被稱為一個bin。
? ? ? ? ?關(guān)于chunk的數(shù)據(jù)結(jié)構(gòu)細(xì)節(jié)不進(jìn)行過多描述,詳情可以參考華庭大神的文章:《glibc內(nèi)存管理》
? ? ? ? ?https://paper.seebug.org/papers/Archive/refs/heap/glibc內(nèi)存管理ptmalloc源代碼分析.pdf

【內(nèi)存空洞和內(nèi)存泄漏】
? ?????簡單來說,內(nèi)存空洞是指進(jìn)程已經(jīng)free了內(nèi)存,但是由于glibc的原因,這部分內(nèi)存并沒有還給操作系統(tǒng),而是緩存在glibc為進(jìn)程維護(hù)的內(nèi)存池中。所以在top等工具中看起來這部分內(nèi)存仍然是進(jìn)程在使用。而這些內(nèi)存空洞是可以被進(jìn)程自身復(fù)用的,后續(xù)如果有同樣大小的malloc請求,glibc會使用這部分空洞的內(nèi)存進(jìn)行分配。
? ? ????而內(nèi)存泄漏,是進(jìn)程調(diào)用了malloc申請內(nèi)存,但是沒有調(diào)用free釋放。這樣導(dǎo)致進(jìn)程的內(nèi)存空間一直上漲,后續(xù)的malloc請求無法復(fù)用前面申請的內(nèi)存,直到達(dá)到ulimit的限制或者觸發(fā)OOM。
? ? ????注意這里說的內(nèi)存空間申請、釋放和上漲都是針對虛擬內(nèi)存空間來說。只有當(dāng)申請的虛擬內(nèi)存空間得到訪問,比如對malloc的空間進(jìn)行初始化,這時候才會將虛擬內(nèi)存空間映射到物理內(nèi)存空間。如果物理內(nèi)存空間出現(xiàn)不足,而后續(xù)又有虛擬內(nèi)存要映射過來,就會出現(xiàn)swap交換,將物理內(nèi)存中暫時沒有用到的數(shù)據(jù)置換到硬盤上配置的swap空間中。如果連swap空間也不足了,就會觸發(fā)OOM,甚至系統(tǒng)hang死。