寫服務(wù)端的,內(nèi)存是一個繞不過的問題,而用C++寫的,這個問題就顯得更嚴重。進程的內(nèi)存持續(xù)上漲,有可能是正常的內(nèi)存占用,也有可能是內(nèi)存碎片,而C++寫的,還有可能是內(nèi)存泄漏,那就需要一些方法來檢測到底是哪些問題引起的
1. 內(nèi)存占用
首先從top這個指令說起
Tasks: 80 total, 1 running, 79 sleeping, 0 stopped, 0 zombie
%Cpu(s): 0.3 us, 0.7 sy, 0.0 ni, 92.7 id, 6.3 wa, 0.0 hi, 0.0 si, 0.0 st
KiB Mem : 2052544 total, 1453600 free, 162408 used, 436536 buff/cache
KiB Swap: 782332 total, 782332 free, 0 used. 1708652 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
179 root 20 0 0 0 0 S 0.3 0.0 0:00.27 [jbd2/dm-0-+
493 mongodb 20 0 1102144 78548 36688 S 0.3 3.8 0:26.07 /usr/bin/mo+
636 mysql 20 0 653808 75932 15548 S 0.3 3.7 0:03.55 /usr/sbin/m+
與進程內(nèi)存相關(guān)的兩個指標:VIRT Virtual Memory,虛擬內(nèi)存、RES Resident Memory,常駐內(nèi)存,通常叫物理內(nèi)存。虛擬內(nèi)存,是指整個進程申請的內(nèi)存,包括程序本身的占內(nèi)存、new或者malloc分配的內(nèi)存等等。物理內(nèi)存,就是這個進程在主板上內(nèi)存條那里占用了多少內(nèi)存。那為什么會有虛擬內(nèi)存這個東西,C++不是可以操作硬件么,為什么不直接使用物理內(nèi)存?這得簡單了解一下操作系統(tǒng)的內(nèi)存管理。
現(xiàn)代的計算機都會同時運行N個程序,有N多個進程,這些進程都是獨立在運行。如果直接使用物理內(nèi)存,那就會產(chǎn)生一個問題,進程A申請了內(nèi)存,進程B也要申請一塊內(nèi)存,但進程B并不知道進程A的存在,就沒法保證進程B使用的內(nèi)存進程A沒在用。因此linux下使用內(nèi)核來管理這些資源,所有進程都只是向內(nèi)核申請,由內(nèi)核管理物理內(nèi)存。而一個進程,可能多次申請、釋放內(nèi)存,或者程序直接當(dāng)?shù)魶]有釋放內(nèi)存,內(nèi)核為了解決這些復(fù)雜的問題,用一個列表維護了進程分配的內(nèi)存,這就叫虛擬內(nèi)存,然后把虛擬內(nèi)存映射到物理內(nèi)存,這就完成了整個內(nèi)存的管理。而且,內(nèi)核對內(nèi)存的映射做了優(yōu)化,用到時才映射,如下面的圖中,進程A的new2這塊內(nèi)存分配了以后,一直沒使用,也就不會映射到物理內(nèi)存。有很多程序,利用了這個特性。例如,在socket收發(fā)時,我們可以分配很大的一塊內(nèi)存(比如16M),避免頻繁分配緩沖區(qū),但實際這個socket可能收到的數(shù)據(jù)塊最大只有16k,那內(nèi)核是不會直接映射16M物理內(nèi)存的,這樣既方便了我們寫程序,但又沒浪費物理內(nèi)存。
下面寫個程序來驗證這個問題
#include < cstring >
#include < iostream >
int main()
{
#define PAUSE(msg) std::cout < < msg < < std::endl; std::cin > > p
char p;
size_t size = 1024 * 1024 *100;
char *l = new char[size];
PAUSE("new");
memset(l, 1, size / 2);
PAUSE("using half large");
memset(l, 1, size);
PAUSE("using whole large");
delete []l;
PAUSE("del");
return 0;
}
在每次暫停時,top的輸出結(jié)果(RES 1588 54328 105600 3348),說明memset的時候,內(nèi)核才會映射物理內(nèi)存。
new
進程號 USER PR NI VIRT RES SHR %CPU %MEM TIME+ COMMAND
25295 root 20 0 108280 1588 1436 S 0.0 0.0 0:00.00 ./a.out
using half large
25295 root 20 0 108280 54328 3096 S 0.0 0.7 0:00.05 ./a.out
using whole large
25295 root 20 0 108280 105600 3156 S 0.0 1.4 0:00.12 ./a.out
del
25295 root 20 0 5876 3348 3156 S 0.0 0.0 0:00.13 ./a.out
所以,通過top查看進程內(nèi)存時,如果發(fā)現(xiàn)VIRT占用很大,說明這個程序用new或者malloc等分配了很多內(nèi)存,但如果RES不是很大,那就不要慌,可能這只是程序的一個緩存優(yōu)化(當(dāng)然也有可能是寫這個程序的人用new分配內(nèi)存時很不合理,分配的值遠大于使用值),實際程序運行占用的物理內(nèi)存并不大。但如果RES也很高,那可能就有點慌了。
2. 內(nèi)存泄漏
內(nèi)存泄漏是導(dǎo)致進程內(nèi)存持續(xù)上漲最常見的原因,而這是C++中常見但不好處理的問題,一個維護多年的大項目,代碼不知道由多少個人寫的,想找出哪個指針的內(nèi)存沒釋放,談何容易。解決這個問題沒有什么通用快捷的辦法,只能根據(jù)實際業(yè)務(wù)處理。
第一,從業(yè)務(wù)上,能不能重現(xiàn)內(nèi)存泄漏。例如我們做游戲的,假如玩家不停地登錄,就會導(dǎo)致內(nèi)存不斷上漲,那說明問題就在登錄流程,把整個流程拆分,一個個屏蔽測試,最終找出問題。
第二,從部署上,能不能定位內(nèi)存泄漏。例如,最近更新了一個版本,發(fā)現(xiàn)內(nèi)存占用變得很高,那就可以確定,是這個版本的修改出了問題。一個版本的代碼量終究是有限的,查找起來也比較容易。
第三,使用valgrind memcheck。如果能夠復(fù)現(xiàn)內(nèi)存泄漏,但無法定位是哪個邏輯,那可以用valgrind memcheck。復(fù)現(xiàn)內(nèi)存泄漏,這個通常比較難實現(xiàn),一般是線下測試無法復(fù)現(xiàn),線上用戶量大,運行久了才會復(fù)現(xiàn),而valgrind會導(dǎo)致程序運行很慢,無法支撐線上測試,因此這個選項通常不太適用于線上。
第四,使用Visual Leak Detector。valgrind是linux下的,如果程序可以跨平臺,或者只在win下,那么可以試試這個,這個和valgrind一樣,需要復(fù)現(xiàn)泄漏才能得到堆棧,因此也是用于線下調(diào)試比較多。
第五,重載new、delete。像我之前的博客里寫的,可以簡單地加個計數(shù),用于平時預(yù)防泄漏,也可更深入一些,記錄內(nèi)存的分配,得到內(nèi)存漏泄的堆棧,但是這個是否能支撐線上debug,我持懷疑態(tài)度。
第六,使用自己的內(nèi)存分配函數(shù),每一個內(nèi)存分配,都使用自己的函數(shù),每一個STL的容器,都傳入自己的分配器,然后分別記錄這些內(nèi)存分配的大小。這個方法看起來很不現(xiàn)實,但我確實見過在實際的項目中使用,對內(nèi)存統(tǒng)計、查找有很大的幫助,而且支持在線上debug。查找內(nèi)存,只需要打印下每個分配器分配的內(nèi)存大小基本上可以得到結(jié)論是哪個分配器出問題。唯一的問題是它增加了開發(fā)難度,而且不能像valgrind那樣不需要修改原程序即可使用。
第七,使用valgrind massif。valgrind memcheck需要復(fù)現(xiàn)內(nèi)存泄漏,所以不容易找出問題。它會定時記錄分配內(nèi)存的各個堆棧以及分配內(nèi)存的量,當(dāng)出現(xiàn)內(nèi)存泄漏時,根據(jù)分配內(nèi)存的量檢查下各個堆棧,應(yīng)該是可以找到問題的。massif也會導(dǎo)致程序運行慢,但比memcheck要快,能不能在線上debug,這個依然得看具體情況
第八,使用第三方內(nèi)存分配器,如jemalloc。并不是說使用第三方內(nèi)存分配器就解決問題了,而是jemalloc自帶了一大堆工具,其中jeprof可以得到內(nèi)存的大小以及堆棧等信息,對查找內(nèi)存泄漏有很大幫助。不過開啟prof后,效率如何,能不能在線上使用,我倒是沒測試過。
3. 內(nèi)存碎片
假如找不到內(nèi)存泄漏,也許本來就沒有內(nèi)存泄漏,這時不妨考慮下內(nèi)存碎片的問題。這里以linux下的ptmalloc為例(其他的分配器我就不懂了),說下內(nèi)存分配。假如一個進程,依次分配了內(nèi)存塊m1(1k)、m2(10b)、
m3(1k),然后釋放了m2,那整個內(nèi)存看起來是這樣子的:
我們可以看到,m1、m2、m3是按順序分配的,當(dāng)m2被釋放時,那中間就空了一塊了。那空的這一塊怎么辦,是把它還給系統(tǒng)了嗎?這個問題就很復(fù)雜了,涉及到ptmalloc的整個分配機制,這里不打算詳細說,建議看華庭(莊明強) - ptmalloc2源代碼分析。簡單來講,就是ptmalloc會暫把釋放的內(nèi)存按大小用鏈表存起來,比如10b的,放到fast bin那個鏈表,大一點的,放small bin的第一個鏈表,再大一點,放small bin的第二個鏈表,... 放進去的內(nèi)存,直到第再次用到時取出。
隨著程序運行,放進鏈表的內(nèi)存可能會越來越多,但是卻很少取出(可能是程序釋放后沒有再申請,也可能是申請的大小和鏈表里的大小不合適,比如鏈表里有個10b的,但是程序申請了1k),那這些小內(nèi)存就會越來越多,進程占用的內(nèi)存也會越來越多,但實際使用的內(nèi)存不多。那如何檢測這種情況呢?
方法一,使用
malloc_stats。malloc_stats是一個glibc的函數(shù),因此可以在gdb調(diào)用
gdb -p 16021
call malloc_stats()
Arena 0:
system bytes = 1359872
in use bytes = 954224
Arena 1:
system bytes = 135168
in use bytes = 3488
Arena 2:
system bytes = 135168
in use bytes = 20784
Arena 3:
system bytes = 139264
in use bytes = 120080
Total (incl. mmap):
system bytes = 1769472
in use bytes = 1098576
max mmap regions = 0
max mmap bytes = 0
- Arena N表示多個分配域,一般一個線程一個
- system bytes 當(dāng)前申請的內(nèi)存總數(shù)
- in use bytes 當(dāng)前使用的內(nèi)存總數(shù)
- max mmap regions 使用mmap分配了多少塊內(nèi)存(大內(nèi)存用mmap分配,大于128K,可由M_MMAP_THRESHOLD選項調(diào)節(jié))
- max mmap bytes 使用mmap分配了多少內(nèi)存
這里,system bytes減去in use bytes就可以得到當(dāng)前進程緩存了多少內(nèi)存。不過malloc_stats是一個很老的接口了,里面的變量都是用的int,如果你的程序占用內(nèi)存比較大,這里可能會溢出。
方法二,使用使用malloc_info
gdb -p 16021
call malloc_info(0, stdout)
< malloc version="1" >
< heap nr="0" >
< sizes >
< size from="17" to="32" total="3104" count="97"/ >
< size from="33" to="48" total="11136" count="232"/ >
< size from="49" to="64" total="12288" count="192"/ >
< size from="65" to="80" total="14640" count="183"/ >
< size from="81" to="96" total="4896" count="51"/ >
< size from="97" to="112" total="1232" count="11"/ >
< size from="113" to="128" total="7296" count="57"/ >
< size from="33" to="33" total="13299" count="403"/ >
< size from="97" to="97" total="97" count="1"/ >
< size from="7281" to="7281" total="7281" count="1"/ >
< size from="32833" to="32833" total="32833" count="1"/ >
< unsorted from="145" to="8753" total="166107" count="155"/ >
< /sizes >
< total type="fast" count="823" size="54592"/ >
< total type="rest" count="561" size="219617"/ >
< system type="current" size="1359872"/ >
< system type="max" size="1376256"/ >
< aspace type="total" size="1359872"/ >
< aspace type="mprotect" size="1359872"/ >
< /heap >
< heap nr="1" >
< sizes >
< size from="33" to="48" total="48" count="1"/ >
< unsorted from="4673" to="4705" total="9378" count="2"/ >
< /sizes >
< total type="fast" count="1" size="48"/ >
< total type="rest" count="2" size="9378"/ >
< system type="current" size="135168"/ >
< system type="max" size="135168"/ >
< aspace type="total" size="135168"/ >
< aspace type="mprotect" size="135168"/ >
< /heap >
< heap nr="2" >
< sizes >
< size from="33" to="48" total="48" count="1"/ >
< size from="113" to="128" total="128" count="1"/ >
< size from="65" to="65" total="65" count="1"/ >
< unsorted from="81" to="3233" total="10054" count="6"/ >
< /sizes >
< total type="fast" count="2" size="176"/ >
< total type="rest" count="7" size="10119"/ >
< system type="current" size="135168"/ >
< system type="max" size="135168"/ >
< aspace type="total" size="135168"/ >
< aspace type="mprotect" size="135168"/ >
< /heap >
< heap nr="3" >
< sizes >
< size from="65" to="80" total="80" count="1"/ >
< /sizes >
< total type="fast" count="1" size="80"/ >
< total type="rest" count="0" size="0"/ >
< system type="current" size="139264"/ >
< system type="max" size="139264"/ >
< aspace type="total" size="139264"/ >
< aspace type="mprotect" size="139264"/ >
< /heap >
< total type="fast" count="827" size="54896"/ >
< total type="rest" count="570" size="239114"/ >
< total type="mmap" count="0" size="0"/ >
< system type="current" size="1769472"/ >
< system type="max" size="1785856"/ >
< aspace type="total" size="1769472"/ >
< aspace type="mprotect" size="1769472"/ >
< /malloc >
- nr即arena,通常一個線程一個
- 上面說了,大小在一定范圍內(nèi)的內(nèi)存,會放到一個鏈表里,這就是其中一個鏈表。from是內(nèi)存下限,to是上限,上面的意思是內(nèi)存分配在 [17,32]范圍內(nèi)的空閑內(nèi)存總共有97個,占3104字節(jié)內(nèi)存。在這個區(qū)間內(nèi)的內(nèi)存申請都會被對齊為32,故total = to * count
- 即fastbin這鏈表當(dāng)前有2個空閑內(nèi)存塊,大小為176
除fastbin以外,所有鏈表空閑的內(nèi)存數(shù)量,以及內(nèi)存大小。因此fast和rest加起來,應(yīng)該和當(dāng)前arena里所有的size一致,如
< heap nr="2" >
< sizes >
< size from="33" to="48" total="48" count="1"/ >
< size from="113" to="128" total="128" count="1"/ >
< size from="65" to="65" total="65" count="1"/ >
< unsorted from="81" to="3233" total="10054" count="6"/ >
< /sizes >
< total type="fast" count="2" size="176"/ >
< total type="rest" count="7" size="10119"/ >
前兩個to大小為48和128為fast bin,數(shù)量為2,剩下的都為rest,與下面的fast和reset對應(yīng)。
- 使用mmap分配的當(dāng)前在使用塊數(shù)(count)和當(dāng)前在用的內(nèi)存大小(size)(低版本glibc無此字段,如centos6上的glibc 2.12)
- 當(dāng)前已經(jīng)申請的內(nèi)存大小
- 歷史上申請的內(nèi)存大小(包括已經(jīng)歸還給操作系統(tǒng)的)
- total和mprotect看源碼沒看出是什么東西
到這里可以看到,假如一個進程fast和reset里的數(shù)量很多,那么說明這個進程其實緩存了很多內(nèi)存。另外這里都是直接用gdb attach到一個進程直接調(diào)用函數(shù),打印到stdout。如果需要查看的程序被關(guān)掉了stdout或者重定向了stdout(很多服務(wù)器進程都這么做),那可能看不見了,或者信息不是打印到當(dāng)前終端。
4. 內(nèi)存利用率
如果一個進程占用的內(nèi)存遠高于預(yù)期,但沒有持續(xù)上漲,還需要考慮下是不是內(nèi)存使用率的問題。當(dāng)使用new分配一塊內(nèi)存時,系統(tǒng)需要為這次分配記錄大小、地址,分配的內(nèi)存也需要對齊,假如分配的內(nèi)存很小(比如說1b),那系統(tǒng)最終需要消耗的內(nèi)存是遠大于1b的。比如
#include < cstring >
#include < iostream >
int main()
{
#define PAUSE(msg) std::cout < < msg < < std::endl; std::cin > > p
char p = NULL;
size_t total = 0;
while (total < 1024 * 1024 * 1024)
{
size_t size = rand() % 16;
total += size;
char *p = new char[size];
}
PAUSE("pause");
這個程序每次分配小于16字節(jié)的內(nèi)存,直到總分配量到1G,然而,在我的系統(tǒng)里(ubuntu 20.04),這個程序跑起來占用的內(nèi)存就多得多
進程號 USER PR NI VIRT RES SHR %CPU %MEM TIME+ COMMAND
4174 root 20 0 4479488 4.3g 1616 S 0.0 59.0 0:15.97 ./a.out
已經(jīng)達到了4.3G,顯然內(nèi)存利用率只有1/4不到。你也許會說這種分配小內(nèi)存的情況不多,但其實不是的。舉個例子,做關(guān)鍵字搜索時,會用到二叉搜索樹,每一個樹的節(jié)點對應(yīng)一個字符,比如"abcd“就需要分配4個節(jié)點,但是每個節(jié)點其實很小。假如關(guān)鍵字很多(上百萬還是很常見的),那這個問題就比較嚴重。這時候就應(yīng)該使用valgrind massif來看下,到底是哪個地方分配的內(nèi)存,然后根據(jù)邏輯優(yōu)化即可。
-
程序
+關(guān)注
關(guān)注
116文章
3776瀏覽量
80848 -
C++
+關(guān)注
關(guān)注
22文章
2104瀏覽量
73489 -
內(nèi)存管理
+關(guān)注
關(guān)注
0文章
168瀏覽量
14125 -
進程
+關(guān)注
關(guān)注
0文章
201瀏覽量
13947
發(fā)布評論請先 登錄
相關(guān)推薦
評論