valgrind 排查記憶體錯誤
導讀
Valgrind 最為開發者熟知和廣泛使用的工具莫過於 Memcheck,它是檢查 c/c 程式記憶體錯誤的神器,報告結果非常之精準。
本文主要分享作者在使用該神器解決記憶體問題的過程中積累的一些實戰經驗,希望幫助你快速定位問題甚至在編碼階段就規避這些問題。
Memcheck 可以檢查哪些記憶體錯誤?
Memcheck 可以檢查 c/c 程式中常見的以下問題:
- 記憶體洩漏,包括程序執行過程中的洩漏和程序結束前的洩漏。
- 訪問不應該訪問的記憶體,即記憶體非法讀寫。
- 變數未初始化,即使用未定義的值。
- 不正確的釋放堆記憶體,比如 double free 或者 malloc/new/new[] 與 free/delete/delete[] 不匹配。
- 記憶體塊重疊,比如使用 memcpy 函式時源地址和目標地址發生重疊。
- 向記憶體分配函式的 size 引數傳遞非法值(fishy value),比如,負值。
其中,問題 1 中的記憶體洩漏一般是比較好定位與解決的,但是作者在實際專案開發中遇到過 still reachable 錯誤掩蓋 definitely lost 錯誤的情況,這就加大了定位記憶體洩漏點的難度。問題 2 和 3 屬於出現頻率較高的一類記憶體錯誤,它們往往會引發程式 crash,這類錯誤必須要高度重視,且一定要解決。問題 4、5、6 也屬於典型的記憶體錯誤,使用 Memcheck 可以很快的定位並解決這些問題。
對於 c/c 開發者來說,如果不能及時發現並消除這些記憶體隱患,那麼,偶爾的 crash、難以診斷的 coredump 將會是揮之不去的噩夢。而且這些記憶體問題可能很難通過一己之力去定位,尤其是當程式的程式碼量龐大、邏輯抽象且複雜的時候,更是會讓人焦頭爛額。此時,Memcheck 就是輔助我們解決這堆記憶體問題的神器。
使用 Memcheck 解決問題的原則
當使用 Memcheck 工具輸出程式的記憶體檢查報告後,我們該如何著手去解決報告中的問題呢?作者根據長期使用積累的經驗,總結了如下四個原則。
原則 1,記憶體非法讀寫錯誤一定要解決
這類錯誤在檢查報告中以 Invalid read/write of size x 的格式輸出。這類錯誤出現的場景主要有三種:
- 動態分配的記憶體已經被釋放,然而開發者還在對這塊無效的記憶體進行讀寫操作。
比如懸掛指標,即基類指標指向的子物件已經被釋放,然而卻繼續使用該基類指標呼叫其方法。
- 動態分配的記憶體未被釋放,然而訪問這塊記憶體發生越界。
比如拷貝字串時忘記結尾的字元 /0。
比如 memcpy(dst, src, len);,src 記憶體大小為 1024 B,然而 len 的值為 1025。
- 訪問棧空間越界(即堆疊溢位)
比如對陣列的越界訪問。
其中,場景 1 的出現頻率較高。因此,當我們處理 Invalid read/write 這類記憶體讀寫錯誤時,一個較為高效的解決思路是:首先要考慮的是非法讀寫的 block(記憶體塊)是否在讀寫之前已經因為程式的某些異常處理被釋放了,然後仔細的審查程式碼來驗證這種可能性。如果排除了記憶體釋放的可能,我們再看是否存在記憶體訪問越界的可能,然後繼續去驗證。
在這個過程中,我們要充分閱讀 Memcheck 輸出的 Invalid read/write 的詳細資訊。比如,非法讀寫的記憶體塊是在哪裡分配的?在哪裡釋放的?又是在哪裡非法讀寫的? 將這些線索結合到具體的專案程式碼中,幫助我們更高效的解決問題。
忽略這類錯誤將會給自己的程式帶來巨大的隱患,最壞的結果是程式 crash,這對於伺服器來說是致命的。
記得有一次使用 c 11 的範圍迴圈語法遍歷刪除 map 中的元素,Memcheck 檢查出了紅黑樹節點寫記憶體錯誤。當時以為錯誤出現在 STL 庫底層,且程式改動很小,便忽略了這個錯誤,熟不知底層的錯誤正是由於上層程式碼引起。後來在壓測中發現程式頻繁 crash,正是因為該錯誤導致。幸虧當時服務程式沒有上線,否則後果不敢想象。所以,這類錯誤一定要解決,作為服務端開發者,再謹慎也不為過。
最後,我們來演示一下這類錯誤,程式碼如下:
void foo() { char* buffer = (char*)malloc(5); strcpy(buffer, "01234"); cout << "buffer[5]=" << buffer[5] << endl; free(buffer); }
在 foo 函式中動態分配了 5 個位元組大小的記憶體塊,隨後拷貝字串 "01234" 到這塊記憶體,但是忽略了字串的結尾字元 /0,最終將 6 位元組大小的字串寫入到 5 位元組大小的記憶體空間,導致記憶體寫越界,Memcheck 報錯為 Invalid write of size 2。
最後一行程式碼在列印 buffer[5] 時發生記憶體讀越界,即字元陣列越界訪問,Memcheck 報錯為 Invalid read of size 1。
這裡只演示了部分記憶體非法讀寫的場景,其它的諸多記憶體非法讀寫的場景,讀者可自己嘗試編碼復現。
原則 2,變數未初始化錯誤一定要解決
這類錯誤在檢查報告中以 Use of uninitialised value of size x 或者 Conditional jump or move depends on uninitialised value(s) 的格式輸出。即程式中使用了未初始化的變數或者從上層未初始化的變數中逐層傳遞下來的未定義的值。
一般來講,這類錯誤都是變數定義後未初始化導致。所以,一定要養成變數定義並同時初始化的良好的程式設計習慣,將這類錯誤扼殺在搖籃裡。其次,如果檢查報告出現這類錯誤,那麼千萬不要忽略這個錯誤,一定要及時修復,及時止損。
作者曾經因為沒有將指標變數初始化為空,導致它成為野指標,各種指標判空邏輯均對它無效,從而造成了程式各種匪夷所思的 crash,花了很多天時間才最終定位該問題。所以,不要給自己找麻煩。
如果很難確定這類錯誤的根本原因,可以嘗試使用 --track-origins yes 跟蹤未初始化變數的問題,來獲取額外的資訊。不過這會使得 Memcheck 執行得更慢,但是得到的額外資訊通常可以節省很多時間來找出未初始化的值從哪裡來。
最後我們來演示一下這類錯誤,程式碼如下:
void foo(int y) { cout << y << endl; } int main() { int x; foo(x); return0; }
在 main 函式中定義了一個沒有被初始化的變數 x,接下來傳入 foo 函式,該函式的功能是列印傳入的引數。由於變數 y 的值依賴於 x,所以 y 的值是未定義的,此時列印變數 y 相當於間接使用了未初始化的變數,Memcheck 會報告這類錯誤。
原則 3,開啟 -show-reachable=yes 命令列選項
強烈建議在執行 Memcheck 時增加 -show-reachable=yes 命令列選項,它可以幫我們檢查全域性指標、static 靜態指標相關的記憶體洩漏問題。
強烈建議在程序結束時,正確而優雅的釋放所有資源,包括關閉定時器和套接字、釋放全域性或者靜態物件、回收執行緒資源等。培養嚴謹的程式設計風格。
為何一定要開啟 reachable 命令列選項呢?別急,在原因揭曉之前,我們先來了解一下記憶體洩漏的定義以及 Memcheck 工具報告的四種記憶體洩漏形式。
究竟如何定義記憶體洩漏?
作者認為記憶體洩漏有如下兩種場景:
- 記憶體已經分配,但是在程序結束之前沒有被優雅的釋放。
也就是說,在程序結束之前的那一刻,程序依然擁有指向該記憶體塊的指標,指標並未丟失,仍然可以獲取並訪問(still reachable)。
具有程序級別的生命週期的靜態指標或者全域性指標指向的記憶體塊沒有在程序結束前被釋放是造成這種場景下的記憶體洩漏的主要原因。
- 記憶體已經分配,但是在程序執行過程中不能被正常釋放。
此時,程序不再擁有指向該記憶體塊的指標,指標丟失。這種場景是為 c/c 開發者所熟知的真正意義上的“記憶體洩漏”。造成這種場景下的記憶體洩漏的原因主要有:
- 開發者在編碼過程中忘記了釋放記憶體。
- 記憶體釋放操作在某些異常處理邏輯之後,而這些異常處理邏輯在 return 之前並未做好記憶體釋放的工作。
- 一些需要實時快取的資料雖然在連線建立時能被正常釋放,但是在連線斷開時卻並未做好資源清理工作,比如流媒體服務中的重傳快取、gop 快取。
Memcheck 輸出的四種記憶體洩漏形式
記憶體檢查報告按照丟失位元組數從小到大排序展示。下面來認識下 Memcheck 工具輸出的檢查報告中的四種記憶體洩漏形式:
- definitely lost,指標確認丟失。
當程序在執行或者程序結束時,如果一塊動態分配的記憶體沒有被釋放,並且程式中已經找不到能夠正常訪問這塊記憶體的指標,則會報這個錯誤。也就是說指標已丟失,但是記憶體未釋放,這是真正的需要被關注的記憶體洩漏,需要儘快修復。
- indirectly lost,指標間接丟失。
當使用了含有指標成員的類或結構時可能會報這個錯誤。這類錯誤無需直接修復,他們總是與 definitely lost 一起出現,只要修復definitely lost 即可。
- possibly lost,指標可能丟失。
當程序結束時,如果一塊動態分配的記憶體沒有被釋放,且通過程式內的指標均無法訪問這塊記憶體的起始地址,但是可以訪問這塊記憶體的部分資料時,那麼指向該記憶體塊的指標可能丟失。也就是說原本指向記憶體起始地址的指標被重新指向了這塊記憶體的中間的某個地址(即非起始地址)則會報這個錯誤。
大多數情況下應視為與 definitely lost 一樣需要儘快修復,除非這是你有意而為之,並且你可以讓已經指向記憶體非起始地址的指標經過某些運算重新指向這塊記憶體的起始地址並釋放它。
- still reachable,仍然可以獲取指標並訪問記憶體。
指標未丟失,記憶體未釋放。如果程式是正常結束的,那麼這類報錯一般不會造成程式 crash,一般可以忽略掉。
這類指標基本上是靜態指標或者全域性指標,所以這些 still reachable 的記憶體塊通常是隻分配一次,並且具有程序級別的生命週期,正如 valgrind 官方手冊描述的那樣:
these blocks are usually one-time allocations, references to which are kept throughout the duration of the process's lifetime.
綜上,對於這四種不同的記憶體洩漏形式,我們應該按照 definitely lost、possibly lost、still reachable 的順序依次解決。
still reachable 是記憶體洩漏嗎?
其實,這種場景下的洩漏在嚴格意義上來講也許並不能稱之為記憶體洩漏,因為在程序執行過程中並沒有洩漏問題。
雖然記憶體在程序結束之前確實未被釋放, 但是指向這塊記憶體的指標是 reachable 的,作業系統會獲取這些指標並幫助我們釋放記憶體。
但是,請注意,still reachable 可能會掩蓋真正的記憶體洩漏 definitely lost,這就是作者為何強烈建議開啟 reachable 命令列選項的原因。
作者曾經遇到過一個非常隱祕的記憶體洩漏問題:某次檢視線上服務實體記憶體佔用達到了 2G,開始以為是底層 jemalloc 未將記憶體歸還作業系統導致,再加之 Memcheck 並未報出 definitely lost 錯誤,所以並沒有認為是記憶體洩漏。過了一週,再次檢視發現記憶體佔用已經超過了 10G,這次毋庸置疑,絕對是記憶體洩漏了,但是 Memcheck 仍然檢測不出哪裡洩漏。最終不得已開啟了 reachable 選項,讓 Memcheck 報告出所有的 still reachable 資訊,逐一排查這些可疑資訊,終於定位了記憶體洩漏的點:原來是拉流快取的資料包未在使用者停止拉流後釋放。後來,再次回顧這次解決記憶體洩漏的過程,發現逐一排查 still reachable 資訊定位問題實在是效率低下,況且這次記憶體洩漏為何沒有被報告出 definitely lost 錯誤?這是個問題。最終,將資料快取結構的上層全域性指標在程序退出時主動釋放,結果這一次的記憶體檢查報告不僅精確的定位到了記憶體洩露的地方,而且也沒有了 still reachable 的錯誤。
所以,作者強烈建議養成在程序結束之前優雅的釋放掉靜態/全域性指標、做好資源的清理工作的良好程式設計習慣,並在使用 Memcheck 時開啟 reachable 引數,竭盡所能的消滅 still reachable 報錯,這樣不僅能暴露 definitely lost 錯誤,檢查報告看起來也會清爽很多。
原則 4,周密思考!保證 Memcheck 測試到程式的每一個邏輯分支
在執行 Memcheck 之前,我們要周密的思考,列舉出所有重要的測試場景,確保最大化的發揮 Memcheck 的作用。比如下面這幾種測試場景就很重要:
- 弱網場景下是否進行了測試?
實驗室環境總是比較理想的,也許 Memcheck 測試不出程式應對弱網環境的邏輯漏洞,所以,在丟包、延遲、亂序的弱網環境下使用 Memcheck 才能真正的暴露問題。
- 程序結束前的資源清理和釋放邏輯是否進行了測試?
也就是說,你的程式是否具有捕捉並處理訊號的能力?比如,捕捉並處理了 SIGINT 或者 SIGTERM 訊號,那麼當執行 ctrl c 後,Memcheck 就可以在程序結束前檢查訊號處理函式的處理邏輯。
如果程式在退出邏輯中未對一些資源(記憶體,套接字,定時器,io 事件等)做釋放,那麼Memcheck 會檢查到這些錯誤,也許是 still reachable 錯誤,上文已經提到,這個錯誤建議解決。
- 程序執行時的一些異常處理邏輯是否測試到位?
比如對於流媒體服務來講,停止推拉流、推拉流失敗、回源失敗等相關的邏輯是否被測試到。
Memcheck 四種指標丟失情形的程式碼演示
definitely lost 與 still reachable 程式碼演示
首先,我們先演示絕對丟失和 still reachable 這兩種情況。
void test01() { char* p = newchar[1024]; } void test02() { staticchar* p = newchar[1024]; } int main() { test01(); test02(); return0; }
在 test01 中,new 出來的陣列賦值給區域性指標變數 p,test01 測試結束後,區域性變數 p 丟失,記憶體未被釋放,造成記憶體洩漏,Memcheck 會報告 definitely lost 錯誤。
在 test02 中,new 出來的陣列賦值給具有程序級生命週期的靜態指標變數 p,test02 測試結束後直到 main 函式返回前,靜態指標 p 依然可以獲取到,但是記憶體並未在程序結束前釋放,Memcheck 會報告 still reachable 錯誤。
indirectly lost 程式碼演示
接下來演示間接丟失的情況。
class Object { public: Object() { _p = newchar[1024]; } ~Object() { if(_p) delete _p; } private: char* _p = nullptr; }; void test03() { Object* obj = new Object(); }; int main() { test03(); return0; }
在 test03 中,我們 new 了一個 Object 型別的區域性物件指標 obj,它的成員 _p 指向動態分配的陣列,test03 測試結束後,區域性變數 obj 丟失,記憶體未被釋放且其內部成員 _p 指標也間接丟失,沒有被釋放。Memcheck 會報告 definitely lost 和 indirectly lost 錯誤。
possibly lost 程式碼演示
接下來演示可能丟失的情況。
void test04() { char* data = newchar[1024]; staticchar* p = data 1; } int main() { test04(); return0; }
在 test04 中,我們 new 一個陣列並返回給區域性變數 data,隨後宣告靜態指標 p 並指向陣列第二個元素的地址,test04 測試結束後直到 main 函式返回前,靜態指標 p 仍然可獲得,但是 p 已經不再指向陣列的起始地址。Memcheck 認為指向這塊記憶體的指標可能已經丟失,會報告 possibly lost 錯誤。
接下來,我們在 test04 函式中增加一行程式碼 p = data;。
void test04() { char* data = newchar[1024]; staticchar* p = data 1; p = data; }
此時,靜態指標 p 重新指向了陣列的起始地址,所以 Memcheck 不會再報告 possibly lost 錯誤。但是 Memcheck 會報告 still reachable 錯誤,這是因為靜態指標指向的陣列空間沒有被釋放,在測試程序結束前仍然可以獲取到導致,只要再加一行 delete [] data 或者 delete [] p 即可解決。
最後,我們在 test04 函式中再增加一行程式碼 p = nullptr;。
void test04() { char* data = newchar[1024]; staticchar* p = data 1; p = data; p = nullptr; }
現在,Memcheck 又會輸出什麼呢?答案是輸出 definitely lost 錯誤。因為 p 為空指標,不指向任何已分配的記憶體塊,且沒有指向陣列的非起始地址,所以不會有 still reachable 和 possibly lost 這兩種錯誤。
此時,只有區域性指標 data 指向陣列首地址,但是在 test04 函式測試結束之前我們並沒有釋放這塊記憶體,所以 test04 測試結束後區域性指標 data 確認丟失,程式出現記憶體洩漏。
still reachable 掩蓋 definitely lost 程式碼演示
最後來演示未釋放全域性或者靜態指標導致 still reachable 掩蓋了 definitely lost 報錯的情況。
下面的程式碼就是模擬的上文提到那次隱祕的線上服務記憶體洩漏問題。簡單描述一下程式碼邏輯:首先有一個 RtcStreamMgr 型別的全域性指標,該類的內部成員是一個流名到資料包快取佇列的對映。接下來構造一個流名為 666,資料包快取佇列大小為 1 的鍵值對並插入到 map。最後來模擬刪除 map 中流名為 666 的元素時忘記了 delete 其對應資料包快取佇列的場景。
class RtcPacket { public: RtcPacket(int seq, int len) : _seq(seq), _len(len) {} ~RtcPacket() {} private: int _seq; int _len; }; class RtcStreamMgr { public: std::map<std::string, std::list< std::shared_ptr>*> rtc_packet_map; }; auto g_stream_mgr = new RtcStreamMgr(); void test05() { // 構造快取資料包的map std::shared_ptrpacket(new RtcPacket(1, 1024)); autolist = newstd::list< std::shared_ptr>(); list->push_back(packet); g_stream_mgr->rtc_packet_map["666"] = list; // 刪除map元素,但未刪除該元素對應的動態記憶體 auto it = g_stream_mgr->rtc_packet_map.find("666"); g_stream_mgr->rtc_packet_map.erase(it); } int main() { test05(); return0 }
首先,刪除 map 元素時未釋放其對應的動態記憶體,顯然,這會造成記憶體洩漏。其次,全域性物件 g_stream_mgr 也是動態分配的記憶體,但是由於其生命週期是程序級,所以很多開發者不會在程序退出前去主動釋放它,即使在原則上我們確實該釋放它。然而,問題出現了:
- 當在程序退出前不主動釋放全域性物件 g_stream_mgr 時,Memcheck 輸出的都是 still reachable 錯誤。
這使得大多數開發者認為自己的程式並沒有真正的記憶體洩漏問題,於是不會仔細閱讀大篇幅的 reacable 報錯,也就無法解決記憶體洩漏問題。
- 當在程序退出前主動釋放全域性物件 g_stream_mgr 時,Memcheck 不再輸出 still reachable 錯誤,而是精確的輸出了 definitely lost 錯誤。
這使得開發者一眼便定位到了記憶體洩漏問題並輕鬆的解決它。
所以這就是上文提到的問題:在某些場景下,still reachable 報錯會掩蓋掉 definitely lost 報錯,從而加大記憶體洩漏問題的排查難度。
不過這個掩蓋的問題作者只在工作的開發機(CentOS,gcc 4.8.4,glibc 2.12,valgrind 3.11.0)上覆現過,當為寫這篇文章準備再次復現時(因為某些原因,之前復現過的開發機被回收了,只能在其他機器上覆現)卻無論如何也無法復現,回天乏術。
不過這也是個好訊息,這意味著無論是否主動釋放全域性或者靜態指標,都能精準定位到真正的記憶體洩漏問題。
最後,完整的記憶體洩漏演示程式碼[1]已經提交到了我的 github,你可以下載並親自動手去驗證。
Valgrind 的編譯與使用
最後,說一下如何使用 valgrind,非常簡單。首先通過 wget 命令下載 valgrind。
wget http://valgrind.org/downloads/valgrind-3.16.1.tar.bz2
接著執行 ./configure && make && make install,完成編譯與安裝。最後執行 valgrind,只需要執行下面的命令即可。
valgrind --tool=memcheck --leak-check=full --show-reachable=yes --log-file=path_of_log path_of_bin
也可以不指定 --took=memcheck,因為 Memcheck 是預設工具。
在執行 valgrind 時可能並不會一帆風順,可能會出現如下報錯:
valgrind: the 'impossible' happened: LibVEX called failure_exit().
遇到這種情況時,在執行時增加命令列選項 --vex-guest-max-insns=2 即可解決問題。
也有可能會出現如下報錯:
valgrind: failed to start tool 'memcheck' for platform 'amd64-linux': No such file or directory
遇到這種情況時,我們需要執行 autogen.sh 指令碼,之後再重新編譯並安裝 valgrind。
另外,還有幾點需要說明:
- 在使用前需要保證你的可執行檔案已經在編譯時增加了產生除錯資訊的命令列引數 -g,否則檢查報告不會輸出問題程式碼的具體行數。
- 根據 Valgrind 的官方文件,它會導致可執行檔案的速度減慢 20 至 30 倍。所以一般來講,Valgrind 是無法應用到壓力測試的場景之中的。
- 結束 Memcheck 檢查的做法一般是傳送 SIGINT 訊號,即 ctrl c。不要傳送 SIGKILL 訊號結束程序,否則無法生成檢查報告。
關於 Memcheck 輸出資訊與相關命令列的更詳盡且權威的介紹以及 Memcheck 的檢測原理,可以閱讀 valgrind-memcheck 官方手冊[2] 。
最後,希望大家編寫的程式能夠輸出和下圖一樣的 Memcheck 檢查報告:no leaks,no errors。
完美的 memcheck 檢查報告
至此,本文結束,感謝閱讀。
參考資料
[1]
valgrind_memcheck.cpp: https://github.com/yujitai/valgrind_test
[2]
Memcheck: a memory error detector: https://www.valgrind.org/docs/manual/mc-manual.html