眾所周知,C/C++執行效率高,但難以駕馭,開車一時爽,但稍不留神容易翻車。估計每個C/C++程式設計師都遭受過記憶體洩漏的困擾。本文提供一種通過wrap malloc查詢memory leak的思路,使得你翻車的時候能夠自救,而不至於車毀人亡。
什麼是記憶體洩漏?記憶體洩漏就是動態申請的記憶體丟失引用,造成沒有辦法回收它(我知道槓jing要說程序退出前系統會統一回收),相當於在人身上軋個口子,傷口一直流血不止,關鍵這口子還不知道軋哪兒。
記憶體洩漏對於客戶端應用可能不是什麼大事,而對於長久執行的伺服器程式則可能是致命的。
隱式釋放Java等傻瓜式程式語言會自動管理記憶體回收,你只管用,系統會通過引用計數技術跟蹤動態分配的每塊記憶體,在合適的時機自動釋放掉,這種方式叫隱式釋放,相當於車的自動擋。
顯式釋放而C/C++需要顯式的釋放,也就是開發者需要確保malloc/free配對,確保每塊申請的記憶體都恰當的釋放掉,相當於車的手動擋。有很多手段可以避免記憶體洩漏,比如RAII、比如智慧指標(大多基於引用計數)、比如記憶體池。C/C++程式設計師也是蠻拼的,一直在跟記憶體洩漏做殊死搏鬥。
理論上,只要我們足夠小心,在每次申請的時候,都牢記釋放,那麼這個世界就清淨了。但現實往往沒有這般美好,比如拋異常了,釋放記憶體的語句執行不到,比如模組之間的指標傳遞,又或者某菜鳥程式設計師不小心埋了顆雷,所以,我們必須直面真實的世界,那就是我們會遭遇記憶體洩漏。
怎麼查記憶體洩漏?我們可以review程式碼,double check,結對程式設計,但從海量程式碼裡找到隱藏的問題,這如同大海撈針,談何容易啊?兄弟。
所以,我們需要藉助工具,比如valgrind,但這些找記憶體洩漏的工具,往往對你使用動態記憶體的方式有某種期待,或者說約束,比如常駐記憶體的物件會被誤報出來,然後真正有用的資訊會被掩蓋在誤報的汪洋大海里,所以,很多時候,valgrind根本解決不了日常專案中的問題,並沒什麼卵用。
很多著名的開源專案,為了能用valgrind跑,都大張旗鼓的修改原始碼,從而使得專案符合valgrind的要求,用vargrind跑過沒有任何報警叫valgrind乾淨,這倒也不失為一個一勞永逸的辦法。
既然這些個玩意兒都中看不中用,所以,求人不如求己,還得自力更生,多大點事兒。
operator new/delete過載和hook malloc/free可以通過operator new/delete,operator new[]/delete[]過載,但這裡有很細緻的功夫,你需要全面了解,而不是貿然行動,建議看看Effective C++,對operator new系列操作符過載有專門的闡述。
你也可以hook malloc、free等c程式設計介面。
你還可以開啟ptmalloc的除錯功能,它有時候也能管點用。
什麼是動態記憶體分配器?動態記憶體分配器是介於kernel跟應用程式之間的一個函式庫,linux glibc提供的動態記憶體分配器叫ptmalloc,因為抱了linux的大腿,故而是應用最廣泛的動態記憶體分配器。
從kernel角度看,動態記憶體分配器屬於應用程式層;而從應用程式的角度看,動態記憶體分配器屬於系統層。到底屬於哪一層,這取決於你的身份和角度。
應用程式可以通過mmap系統呼叫直接向系統申請動態記憶體,也可以通過動態記憶體分配器的malloc介面分配記憶體,而動態記憶體分配器會通過sbrk、mmap向系統分配記憶體,所以應用程式通過free釋放的記憶體,並不一定會真正返還給系統,它也有可能被動態記憶體分配器快取起來。
所以當你malloc/free配對得很好,但通過top命令去看程序的記憶體佔用,還是很高,你不必感到驚訝。
google有自己的動態記憶體分配器tcmalloc,另外jemalloc也是著名的動態記憶體分配器,他們有不同的效能表現,也有不同的快取和分配策略。你可以用它們替換linux系統glibc自帶的ptmalloc。
new/delete跟malloc/free的關係new是c++的用法,比如Foo *f = new Foo,其實它分為3步。
通過operator new()分配sizeof(Foo)的記憶體,最終通過malloc分配。在新分配的記憶體上構建Foo物件。返回新構建的物件地址。new=分配記憶體+構造+返回,而delete則是等於析構+free。
所以搞定malloc、free就是從根本上搞定動態記憶體分配。
chunk每次通過malloc返回的一塊記憶體叫一個chunk,動態記憶體分配器是這樣定義的,後面我們都這樣稱呼。
wrap mallocgcc支援wrap,即通過傳遞-Wl,--wrap,malloc的方式,可以改變呼叫malloc的行為,把對malloc的呼叫連結到自定義的__wrap_malloc(size_t)函式,而我們可以在__wrap_malloc(size_t)函式的實現中通過__real_malloc(size_t)真正分配記憶體,而後我們可以做搞點小動作。
同樣,我們可以wrap free。
malloc跟free是配對的,當然也有其他相關API,比如calloc、realloc、valloc,這些都是細節,根本上還是malloc和free,比如realloc就是malloc + free的組合。
怎麼去定位記憶體洩漏呢?我們會malloc各種不同size的chunk,也就是每種不同size的chunk會有不同數量,如果我們能夠跟蹤每種size的chunk數量,那就可以知道哪種size的chunk在洩漏。很簡單,如果該size的chunk數量一直在增長,那它很可能洩漏。
光知道某種size的chunk洩漏了還不夠,我們得知道是哪個呼叫路徑上導致該size的chunk被分配,從而去檢查是不是正確釋放了。
怎麼跟蹤到每種size的chunk數量?我們可以維護一個全域性 unsigned int malloc_map[1024 * 1024]陣列,該陣列的下標就是chunk的size,malloc_map[size]的值就對應到該size的chunk分配量。
這等於維護了一個chunk size到chunk count的對映表,它足夠快,而且可以覆蓋到0 ~ 1M大小的chunk的範圍,它已經足夠大了,試想一次分配一兆的塊已經很恐怖了,可以覆蓋到大部分場景。
那大於1M的塊怎麼辦呢?我們可以通過log的方式記錄下來。
在__wrap_malloc裡,++malloc_map[size]
在__wrap_free裡,--malloc_map[size]
如此一來,我們便通過malloc_map記錄了各size的chunk的分配量。
如何知道釋放的chunk的size?不對,free(void *p)只有一個引數,我如何知道釋放的chunk的size呢?怎麼辦?
我們通過在__wrap_malloc(size_t)的時候,分配8+size的chunk,也就是額外分配8位元組,用起始的8位元組儲存該chunk的size,然後返回的是(char*)chunk + 8,也就是偏移8個位元組地址,返回給呼叫malloc的應用程式。
這樣在free的時候,傳入引數void* p,我們把p往前移動8個位元組,解引用就能得到該chunk的大小,而該大小值就是之前在__wrap_malloc的時候設定的size。
好了,我們真正做到記錄各size的chunk數量了,它就存在於malloc_map[1M]的陣列中,假設64個位元組的chunk一直在被分配而沒有被正確回收,最終會表現在malloc_map[size]數值一直在增長,我們覺得該size的chunk很有可能洩漏,那怎麼定位到是哪裡呼叫過來的呢?
如何記錄呼叫鏈?我們可以維護一個toplist陣列,該陣列假設有10個元素,它儲存的是chunk數最大的10種size,這個很容易做到,通過對malloc_map取top 10就行。
然後我們在__wrap_malloc(size_t)裡,測試該size是不是toplist之一,如果是的話,那我們通過glibc的backtrace把呼叫堆疊dump到log檔案裡去。
注意:這裡不能再分配記憶體,所以你只能使用backtrace,而不能使用backtrace_symbols,這樣你只能得到呼叫堆疊的符號地址,而不是符號名。
如何把符號地址轉換成符號名,也就是對應到程式碼行呢?答案是addr2line。
addr2lineaddr2line工具可以做到,你可以追查到呼叫鏈,進而定位到記憶體洩漏的問題。
至此,恭喜你,你已經get到了整個核心思想。
當然,實際專案中,我們做的更多,我們不僅僅記錄了toplist size,還記錄了各size chunk的增量toplist,會記錄大塊的malloc/free,會wrap更多的API。
總結通過wrap malloc/free + backtrace + addr2line,你就可以定位到記憶體洩漏了。
美好的時間過得太快,又是時候說byebye!