程內(nèi)存分析:/proc與gperftools實(shí)戰(zhàn)對(duì)比)
1. 從一次線上內(nèi)存告警說起做C后端服務(wù)的同學(xué)大概率都遇到過這種場(chǎng)景服務(wù)跑著跑著監(jiān)控面板上RSS曲線一路爬升運(yùn)維群里開始有人問“這個(gè)進(jìn)程是不是泄漏了”。你登上機(jī)器top一看進(jìn)程確實(shí)占了好幾個(gè)G但業(yè)務(wù)量并沒有明顯增長(zhǎng)。這時(shí)候問題來了——到底是真泄漏還是內(nèi)存碎片、緩存堆積、或者分配器沒歸還給操作系統(tǒng)要回答這個(gè)問題第一步就是準(zhǔn)確讀取進(jìn)程當(dāng)前的內(nèi)存占用而且最好能拆開看堆占了多少、棧占了多少、映射區(qū)占了多少、共享庫(kù)占了多少。很多人第一反應(yīng)是讀/proc/self/status或者/proc/self/statm簡(jiǎn)單粗暴。但真到排查階段你會(huì)發(fā)現(xiàn)光看一個(gè)RSS數(shù)字根本不夠用你還需要知道堆區(qū)的細(xì)分、mmap區(qū)的分布、甚至每個(gè)線程的棧占用。這時(shí)候就繞不開兩條路一條是直接解析/proc/meminfo和/proc/self/下的相關(guān)文件純手工、零依賴另一條是引入gperftoolsGoogle Performance Tools里的tcmalloc用它的MallocExtension接口拿到堆的詳細(xì)統(tǒng)計(jì)。這篇博文就把這兩種方式掰開揉碎講清楚。我會(huì)從原理、代碼實(shí)現(xiàn)、參數(shù)含義、踩坑經(jīng)驗(yàn)幾個(gè)維度展開重點(diǎn)說清楚什么場(chǎng)景該用哪種方式、每種方式讀出來的數(shù)字到底代表什么、為什么你讀到的RSS和實(shí)際堆占用對(duì)不上。內(nèi)容偏實(shí)戰(zhàn)代碼可以直接抄適合有一定Linux系統(tǒng)編程基礎(chǔ)、正在做性能優(yōu)化或內(nèi)存排查的C開發(fā)者。如果你只是想知道“怎么快速看進(jìn)程內(nèi)存”看完第2節(jié)就夠了如果你想深入堆內(nèi)存的細(xì)粒度分析那第3、4節(jié)是重點(diǎn)。2. 方式一直接解析/proc文件系統(tǒng)2.1 /proc/meminfo和/proc/self/的區(qū)別先澄清一個(gè)容易混淆的點(diǎn)/proc/meminfo是整機(jī)的內(nèi)存信息不是某個(gè)進(jìn)程的。它告訴你系統(tǒng)總共多少內(nèi)存、空閑多少、緩存多少、Slab占了多少。而要看單個(gè)進(jìn)程的內(nèi)存得去/proc/[pid]/目錄下找比如/proc/self/status、/proc/self/statm、/proc/self/smaps、/proc/self/maps。很多人標(biāo)題里寫“讀取meminfo”其實(shí)真正讀進(jìn)程內(nèi)存時(shí)用的是/proc/self/status里的VmRSS、VmSize這些字段或者/proc/self/statm。/proc/meminfo更多是用來做系統(tǒng)級(jí)的內(nèi)存水位判斷比如判斷當(dāng)前機(jī)器是否內(nèi)存緊張、PageCache是否過多。兩者配合使用才能定位問題系統(tǒng)級(jí)看meminfo進(jìn)程級(jí)看self/status。我一般排查時(shí)的順序是先cat /proc/meminfo看整體水位確認(rèn)不是系統(tǒng)級(jí)OOM再cat /proc/[pid]/status | grep Vm看進(jìn)程的虛擬內(nèi)存和物理內(nèi)存最后如果懷疑堆有問題才上gperftools或者解析smaps。2.2 關(guān)鍵字段逐個(gè)拆解/proc/self/status里和內(nèi)存相關(guān)的字段不少我挑最常用的幾個(gè)說清楚字段含義注意事項(xiàng)VmSize虛擬內(nèi)存總大小包含所有映射通常遠(yuǎn)大于物理內(nèi)存VmRSS常駐物理內(nèi)存最??吹闹笜?biāo)但不等于堆占用VmData數(shù)據(jù)段大小包含堆但不完全等于堆VmStk棧大小主線程棧通常很小VmExe可執(zhí)行代碼段只讀一般固定VmLib共享庫(kù)占用動(dòng)態(tài)鏈接庫(kù)映射VmSwap換出到swap的大小云環(huán)境通常為0這里有個(gè)大坑VmRSS包含了共享庫(kù)、文件映射、堆、棧所有常駐部分所以它永遠(yuǎn)大于等于你實(shí)際的堆占用。如果你只看RSS就斷定“堆泄漏了”很可能會(huì)誤判——有可能是某個(gè)mmap的文件沒釋放或者是線程棧暴漲。/proc/self/statm更精簡(jiǎn)只有7個(gè)數(shù)字單位是頁通常4KB。第一個(gè)是程序總大小第二個(gè)是常駐大小。它的優(yōu)點(diǎn)是讀取快、解析簡(jiǎn)單缺點(diǎn)是信息太少排查時(shí)不夠用。2.3 用C讀取并解析的完整代碼下面這段代碼是我實(shí)際項(xiàng)目里用的讀取/proc/self/status并解析出關(guān)鍵字段。注意幾個(gè)細(xì)節(jié)用ifstream逐行讀、用find定位冒號(hào)、用stoul轉(zhuǎn)換、單位統(tǒng)一成KB。#include fstream #include string #include iostream #include unordered_map #include sstream struct ProcMemInfo { unsigned long vmSize 0; unsigned long vmRSS 0; unsigned long vmData 0; unsigned long vmStk 0; unsigned long vmExe 0; unsigned long vmLib 0; unsigned long vmSwap 0; }; ProcMemInfo readProcStatus() { ProcMemInfo info; std::ifstream ifs(/proc/self/status); if (!ifs.is_open()) { std::cerr failed to open /proc/self/status std::endl; return info; } std::string line; while (std::getline(ifs, line)) { auto pos line.find(:); if (pos std::string::npos) continue; std::string key line.substr(0, pos); std::string val line.substr(pos 1); // 去掉前導(dǎo)空格和單位kB auto start val.find_first_not_of( \t); if (start std::string::npos) continue; val val.substr(start); auto kbPos val.find(kB); if (kbPos ! std::string::npos) { val val.substr(0, kbPos); } unsigned long num 0; try { num std::stoul(val); } catch (...) { continue; } if (key VmSize) info.vmSize num; else if (key VmRSS) info.vmRSS num; else if (key VmData) info.vmData num; else if (key VmStk) info.vmStk num; else if (key VmExe) info.vmExe num; else if (key VmLib) info.vmLib num; else if (key VmSwap) info.vmSwap num; } return info; }這段代碼有幾個(gè)實(shí)操要點(diǎn)/proc/self/status里的值單位是kB但字符串里帶“kB”后綴解析時(shí)必須截掉否則stoul會(huì)拋異常。不要用sscanf一次性解析因?yàn)樽侄雾樞蛟诓煌瑑?nèi)核版本可能變逐行匹配key更穩(wěn)。讀取頻率不要太高/proc是內(nèi)核動(dòng)態(tài)生成的高頻讀比如每毫秒一次會(huì)有可觀的CPU開銷。我一般1秒或5秒采一次。如果進(jìn)程有大量線程/proc/self/status里的Threads字段也要關(guān)注線程棧默認(rèn)8MB虛擬內(nèi)存100個(gè)線程就是800MB VmSize。2.4 讀出來的數(shù)字為什么和top對(duì)不上這是新手最常問的問題我用代碼讀出來VmRSS是500MB但top顯示RES是800MB誰對(duì)答案是都對(duì)但口徑不同。top的RES包含共享內(nèi)存里實(shí)際駐留的部分而/proc/self/status的VmRSS在某些內(nèi)核版本下對(duì)共享內(nèi)存的統(tǒng)計(jì)方式略有差異。另外top默認(rèn)可能顯示的是VmRSS 部分共享庫(kù)而你的代碼只讀了status。更靠譜的做法是同時(shí)讀/proc/self/statm和/proc/self/status交叉驗(yàn)證。如果兩者差異超過10%就要去看/proc/self/smaps逐個(gè)映射區(qū)分析。smaps會(huì)列出每個(gè)映射段的RSS、PSS、Shared_Clean、Shared_DirtyPSS按比例分?jǐn)偟墓蚕韮?nèi)存才是最能反映“這個(gè)進(jìn)程真實(shí)占用”的指標(biāo)。提示/proc/self/smaps在大進(jìn)程上可能幾MB甚至幾十MB解析時(shí)不要一次性讀入內(nèi)存要流式逐行處理否則排查內(nèi)存問題時(shí)自己先把內(nèi)存吃爆了。3. 方式二gperftools的MallocExtension接口3.1 gperftools是什么為什么用它gperftools是Google開源的一套性能工具集里面最出名的是tcmallocThread-Caching Malloc一個(gè)高性能內(nèi)存分配器。它替換掉glibc默認(rèn)的ptmalloc后多線程場(chǎng)景下的分配性能通常有明顯提升而且它提供了MallocExtension接口可以在運(yùn)行時(shí)查詢堆的詳細(xì)統(tǒng)計(jì)這是glibc malloc做不到的。具體來說MallocExtension::instance()-GetStats()能拿到當(dāng)前從系統(tǒng)申請(qǐng)的總內(nèi)存實(shí)際使用的內(nèi)存各size class的分配情況線程緩存占用內(nèi)存歸還給操作系統(tǒng)的策略狀態(tài)這些信息對(duì)于判斷“是真泄漏還是分配器緩存”至關(guān)重要。glibc的ptmalloc有個(gè)臭名昭著的問題free之后內(nèi)存不一定還給OS而是留在arena里導(dǎo)致RSS居高不下。tcmalloc雖然也有緩存但它提供了ReleaseFreeMemory()接口可以主動(dòng)歸還。3.2 集成方式鏈接、編譯、替換集成gperftools有三種方式我按推薦程度排序方式一編譯時(shí)鏈接tcmalloc推薦# 安裝以Debian系為例 sudo apt-get install libgoogle-perftools-dev # 編譯時(shí)鏈接 g -O2 -o myapp myapp.cpp -ltcmalloc # 或者用靜態(tài)鏈接 g -O2 -o myapp myapp.cpp /usr/lib/x86_64-linux-gnu/libtcmalloc.a -lpthread這種方式最干凈整個(gè)進(jìn)程的所有malloc/free都走tcmalloc統(tǒng)計(jì)也最準(zhǔn)確。方式二LD_PRELOAD動(dòng)態(tài)替換LD_PRELOAD/usr/lib/x86_64-linux-gnu/libtcmalloc.so ./myapp適合不想重新編譯的場(chǎng)景但要注意如果程序里已經(jīng)靜態(tài)鏈接了其他分配器可能會(huì)沖突。方式三只鏈接tcmalloc_minimal如果只需要統(tǒng)計(jì)功能不需要heap profiler可以用libtcmalloc_minimal.so體積更小依賴更少。注意一旦用了tcmalloc就不要再混用glibc的malloc_trim、mallinfo這些接口行為是未定義的。我見過有人在tcmalloc環(huán)境下調(diào)malloc_trim(0)結(jié)果毫無效果還以為是泄漏。3.3 用MallocExtension讀取堆統(tǒng)計(jì)的代碼下面這段代碼展示如何拿到堆的詳細(xì)統(tǒng)計(jì)并解析#include gperftools/malloc_extension.h #include string #include iostream #include sstream void printHeapStats() { MallocExtension* ext MallocExtension::instance(); if (!ext) { std::cerr MallocExtension not available std::endl; return; } // 方式一拿原始統(tǒng)計(jì)字符串 char buffer[8192]; ext-GetStats(buffer, sizeof(buffer)); std::cout Raw Stats std::endl; std::cout buffer std::endl; // 方式二拿具體數(shù)值 size_t allocated 0; size_t heapSize 0; ext-GetNumericProperty(generic.current_allocated_bytes, allocated); ext-GetNumericProperty(generic.heap_size, heapSize); std::cout current_allocated_bytes: allocated / 1024 KB std::endl; std::cout heap_size: heapSize / 1024 KB std::endl; // 主動(dòng)歸還空閑內(nèi)存給OS // ext-ReleaseFreeMemory(); }GetStats輸出的字符串大概長(zhǎng)這樣不同版本略有差異------------------------------------------------ MALLOC: 104857600 ( 100.0 MiB) Bytes in use by application MALLOC: 10485760 ( 10.0 MiB) Bytes in page heap freelist MALLOC: 5242880 ( 5.0 MiB) Bytes in central cache freelist MALLOC: 2097152 ( 2.0 MiB) Bytes in transfer cache freelist MALLOC: 1048576 ( 1.0 MiB) Bytes in thread cache freelists MALLOC: 123731968 ( 118.0 MiB) Total bytes requested from system MALLOC: 134217728 ( 128.0 MiB) Actual memory used (physical swap) MALLOC: 10485760 ( 10.0 MiB) Bytes released to OS MALLOC: 144703488 ( 138.0 MiB) Virtual address space used這里每一行的含義都要搞清楚Bytes in use by application應(yīng)用實(shí)際malloc但還沒free的字節(jié)數(shù)這是最接近“真實(shí)堆占用”的數(shù)字。page heap freelisttcmalloc從系統(tǒng)申請(qǐng)但還沒分配給應(yīng)用的頁這部分是緩存。central/transfer/thread cache freelist各級(jí)緩存里的空閑內(nèi)存。Total bytes requested from systemtcmalloc向OS申請(qǐng)的總量。Actual memory used實(shí)際物理swap占用這個(gè)和RSS接近。Bytes released to OS已經(jīng)歸還給OS的部分。關(guān)鍵判斷邏輯如果Bytes in use by application持續(xù)增長(zhǎng)且不回落那是真泄漏如果它穩(wěn)定但Actual memory used很高那是緩存沒釋放調(diào)ReleaseFreeMemory()就能降下來。3.4 GetStats和GetNumericProperty的取舍GetStats適合打日志、人工看信息全但解析麻煩。GetNumericProperty適合做監(jiān)控埋點(diǎn)直接拿數(shù)值上報(bào)。常用的property名有Property名含義generic.current_allocated_bytes應(yīng)用當(dāng)前分配字節(jié)數(shù)generic.heap_size堆總大小tcmalloc.pageheap_free_bytespage heap空閑字節(jié)tcmalloc.thread_cache_free_bytes線程緩存空閑字節(jié)tcmalloc.max_total_thread_cache_bytes線程緩存上限我一般兩個(gè)都用GetNumericProperty上報(bào)到監(jiān)控系統(tǒng)做趨勢(shì)圖GetStats在告警觸發(fā)時(shí)打到日志里做現(xiàn)場(chǎng)快照。4. 兩種方式的對(duì)比與選型4.1 核心差異對(duì)照表維度/proc方式gperftools方式依賴無內(nèi)核原生需鏈接tcmalloc統(tǒng)計(jì)粒度進(jìn)程級(jí)RSS/VmSize堆級(jí)應(yīng)用分配/緩存能否區(qū)分泄漏與緩存不能能性能開銷讀取時(shí)有開銷幾乎為零對(duì)分配器的影響無替換默認(rèn)分配器適用場(chǎng)景快速排查、系統(tǒng)級(jí)監(jiān)控深度堆分析、長(zhǎng)期監(jiān)控能否主動(dòng)釋放內(nèi)存不能能ReleaseFreeMemory4.2 什么場(chǎng)景用哪種只用/proc的場(chǎng)景快速確認(rèn)進(jìn)程是否異常比如上線后看一眼RSS。系統(tǒng)級(jí)監(jiān)控采集所有進(jìn)程的內(nèi)存做大盤。不方便重新編譯或引入第三方庫(kù)的環(huán)境。排查非堆問題比如mmap文件、線程棧、共享內(nèi)存。必須上gperftools的場(chǎng)景懷疑堆泄漏需要區(qū)分“應(yīng)用真占用”和“分配器緩存”。長(zhǎng)期運(yùn)行的服務(wù)需要定期ReleaseFreeMemory控制RSS。多線程高并發(fā)場(chǎng)景ptmalloc的arena競(jìng)爭(zhēng)嚴(yán)重?fù)Qtcmalloc順便提升性能。需要做堆profiling定位是哪段代碼分配最多。兩者結(jié)合的場(chǎng)景這是我最推薦的。用/proc做7x24小時(shí)的基礎(chǔ)監(jiān)控一旦RSS超過閾值自動(dòng)觸發(fā)GetStats打快照同時(shí)解析smaps看映射分布。這樣既有全局視野又有細(xì)節(jié)深度。4.3 一個(gè)容易忽略的點(diǎn)虛擬內(nèi)存vs物理內(nèi)存不管用哪種方式都要分清VmSize和VmRSS。VmSize是虛擬地址空間可以遠(yuǎn)大于物理內(nèi)存比如你mmap了一個(gè)10GB的文件但只讀了1MBVmSize漲10GBRSS只漲1MB。很多新手看到VmSize暴漲就慌了其實(shí)完全正常。真正要盯的是RSS和PSS。PSS在/proc/self/smaps里是共享內(nèi)存按比例分?jǐn)偤蟮闹底钅芊从场斑@個(gè)進(jìn)程如果單獨(dú)跑需要多少內(nèi)存”。容器環(huán)境下PSS比RSS更接近c(diǎn)group的內(nèi)存統(tǒng)計(jì)。5. 實(shí)操中的常見問題與排查技巧5.1 常見問題速查表現(xiàn)象可能原因排查手段RSS持續(xù)增長(zhǎng)不回落真泄漏或緩存未釋放gperftools看allocated_bytesRSS高但allocated穩(wěn)定分配器緩存ReleaseFreeMemoryVmSize巨大但RSS小mmap未實(shí)際讀寫看smaps的映射多線程后RSS暴漲線程?;騛rena看Threads字段和arena數(shù)free后RSS不降ptmalloc不歸還換tcmalloc或malloc_trim容器內(nèi)OOM但RSS不高cgroup統(tǒng)計(jì)口徑看memory.stat5.2 踩坑記錄ReleaseFreeMemory不是萬能的我一開始以為調(diào)了ReleaseFreeMemory()就能把RSS降下來實(shí)測(cè)發(fā)現(xiàn)有時(shí)候降得動(dòng)有時(shí)候紋絲不動(dòng)。后來搞明白了ReleaseFreeMemory只能歸還page heap freelist里的內(nèi)存如果內(nèi)存還在thread cache或central cache里是歸還不了的。要徹底歸還得先調(diào)MallocExtension::instance()-SetMemoryReleaseRate(10)把釋放速率調(diào)高再調(diào)ReleaseFreeMemory。另外即使歸還了RSS也不一定立刻降因?yàn)镺S的頁回收有延遲??梢杂?proc/self/smaps確認(rèn)對(duì)應(yīng)映射段的RSS是否真的降了。5.3 踩坑記錄/proc讀取的權(quán)限與命名空間在容器里讀/proc/self/status沒問題但讀/proc/[其他pid]/status可能因?yàn)闄?quán)限或PID命名空間隔離而失敗。如果你的監(jiān)控程序要采集其他進(jìn)程要么用/proc/self在目標(biāo)進(jìn)程內(nèi)部采集要么確保有足夠的權(quán)限和正確的命名空間視圖。還有一個(gè)坑/proc/self是個(gè)符號(hào)鏈接指向當(dāng)前進(jìn)程的pid目錄。如果你fork了子進(jìn)程子進(jìn)程里的/proc/self指向的是子進(jìn)程自己不是父進(jìn)程。這個(gè)在寫多進(jìn)程服務(wù)時(shí)要注意。5.4 踩坑記錄tcmalloc與第三方庫(kù)的沖突有些第三方庫(kù)自己帶了內(nèi)存分配器或者靜態(tài)鏈接了jemalloc這時(shí)候再LD_PRELOAD tcmalloc會(huì)沖突表現(xiàn)為隨機(jī)崩潰或統(tǒng)計(jì)數(shù)字完全不對(duì)。解決辦法是統(tǒng)一分配器要么全用tcmalloc要么全用jemalloc不要混。另外tcmalloc在fork之后子進(jìn)程里的統(tǒng)計(jì)會(huì)重置因?yàn)榫€程緩存是per-thread的。如果服務(wù)用fork模型父進(jìn)程的統(tǒng)計(jì)不能直接套到子進(jìn)程上。5.5 一個(gè)實(shí)用的監(jiān)控腳本思路我實(shí)際項(xiàng)目里是這么做的起一個(gè)獨(dú)立線程每5秒采集一次/proc/self/status的VmRSS和GetNumericProperty的allocated_bytes寫入環(huán)形緩沖區(qū)。當(dāng)VmRSS超過閾值時(shí)把最近60秒的數(shù)據(jù)和一次完整的GetStats快照dump到日志。這樣既不影響主流程性能又能在出問題時(shí)拿到現(xiàn)場(chǎng)。采集頻率不要太高5秒足夠。GetStats本身有鎖高頻調(diào)用會(huì)影響分配性能。/proc讀取雖然無鎖但內(nèi)核生成這些字符串也有開銷。6. 我個(gè)人在實(shí)際操作中的體會(huì)這兩種方式我都在生產(chǎn)環(huán)境用過很長(zhǎng)時(shí)間最大的體會(huì)是不要指望單一指標(biāo)能說明問題。RSS高不一定是泄漏allocated高也不一定是泄漏可能是正常的業(yè)務(wù)增長(zhǎng)關(guān)鍵看趨勢(shì)和比例。我一般會(huì)同時(shí)看三個(gè)數(shù)VmRSS、allocated_bytes、pageheap_free_bytes。如果allocated穩(wěn)定但RSS漲那是緩存如果allocated漲且RSS同步漲那要警惕如果allocated漲但RSS不漲可能是分配了但沒實(shí)際寫入比如大塊malloc后沒memset。還有個(gè)小技巧排查內(nèi)存問題時(shí)先echo 3 /proc/sys/vm/drop_caches清一下PageCache排除文件緩存干擾再看RSS。這個(gè)操作在測(cè)試環(huán)境做生產(chǎn)環(huán)境慎用因?yàn)闀?huì)短暫影響IO性能。最后說一句gperftools的GetStats輸出格式在不同版本間有差異寫解析代碼時(shí)不要硬編碼行號(hào)要按關(guān)鍵字匹配。我吃過這個(gè)虧升級(jí)了一次庫(kù)版本解析全亂了。