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