本文針對 linux 下的 C++ 程序的內存洩漏的檢測方法及其實現進行探討。其中包括 C++ 中的 new 和 delete 的基本原理,內存檢測子系統的實現原理和具體方法,以及內存洩漏檢測的高級話題。作為內存檢測子系統實現的一部分,提供了一個具有更好的使用特性的互斥體(Mutex)類。
1.開發背景
在 windows 下使用 VC 編程時,我們通常需要 DEBUG 模式下運行程序,而後調試器將在退出程序時,打印出程序運行過程中在堆上分配而沒有釋放的內存信息,其中包括代碼文件名、行號以及內存大小。該功能是 MFC Framework 提供的內置機制,封裝在其類結構體系內部。
在 linux 或者 unix 下,我們的 C++ 程序缺乏相應的手段來檢測內存信息,而只能使用 top 指令觀察進程的動態內存總額。而且程序退出時,我們無法獲知任何內存洩漏信息。為了更好的輔助在 linux 下程序開發,我們在我們的類庫項目中設計並實現了一個內存檢測子系統。下文將簡述 C++ 中的 new 和 delete 的基本原理,並講述了內存檢測子系統的實現原理、實現中的技巧,並對內存洩漏檢測的高級話題進行了討論。
2.New和delete的原理
當 我們在程序中寫下 new 和 delete 時,我們實際上調用的是 C++ 語言內置的 new operator 和 delete operator。所謂語言內置就是說我們不能更改其含義,它的功能總是一致的。以 new operator 為例,它總是先分配足夠的內存,而後再調用相應的類型的構造函數初始化該內存。而 delete operator 總是先調用該類型的析構函數,而後釋放內存(圖1)。我們能夠施加影響力的事實上就是 new operator 和 delete operator 執行過程中分配和釋放內存的方法。
new operator 為分配內存所調用的函數名字是 operator new,其通常的形式是 void * operator new(size_t size); 其返回值類型是 void*,因為這個函數返回一個未經處理(raw)的指針,未初始化的內存。參數 size 確定分配多少內存,你能增加額外的參數重載函數 operator new,但是第一個參數類型必須是 size_t。
delete operator 為釋放內存所調用的函數名字是 operator delete,其通常的形式是 void operator delete(void *memoryToBeDeallocated);它釋放傳入的參數所指向的一片內存區。
這 裡有一個問題,就是當我們調用 new operator 分配內存時,有一個 size 參數表明需要分配多大的內存。但是當調用 delete operator 時,卻沒有類似的參數,那麼 delete operator 如何能夠知道需要釋放該指針指向的內存塊的大小呢?答案是:對於系統自有的數據類型,語言本身就能區分內存塊的大小,而對於自定義數據類型(如我們自定義 的類),則 operator new 和 operator delete 之間需要互相傳遞信息。
當我們使用 operator new 為一個自定義類型對象分配內存時,實際上我們得到的內存要比實際對象的內存大一些,這些內存除了要存儲對象數據外,還需要記錄這片內存的大小,此方法稱為 cookie。這一點上的實現依據不同的編譯器不同。(例如 MFC 選擇在所分配內存的頭部存儲對象實際數據,而後面的部分存儲邊界標志和內存大小信息。g++ 則采用在所分配內存的頭 4 個自己存儲相關信息,而後面的內存存儲對象實際數據。)當我們使用 delete operator 進行內存釋放操作時,delete operator 就可以根據這些信息正確的釋放指針所指向的內存塊。
以上論述的是對於單個對象的內存分配/釋放,當我們為數組分配/釋放 內存時,雖然我們仍然使用 new operator 和 delete operator,但是其內部行為卻有不同:new operator 調用了operator new 的數組版的兄弟- operator new[],而後針對每一個數組成員調用構造函數。而 delete operator 先對每一個數組成員調用析構函數,而後調用 operator delete[] 來釋放內存。需要注意的是,當我們創建或釋放由自定義數據類型所構成的數組時,編譯器為了能夠標識出在 operator delete[] 中所需釋放的內存塊的大小,也使用了編譯器相關的 cookie 技術。
綜上所述,如果我們想檢測內存洩漏,就必須對程序中的內存分配和釋 放情況進行記錄和分析,也就是說我們需要重載 operator new/operator new[];operator delete/operator delete[] 四個全局函數,以截獲我們所需檢驗的內存操作信息。
3.內存檢測的基本實現原理
上 文提到要想檢測內存洩漏,就必須對程序中的內存分配和釋放情況進行記錄,所能夠采取的辦法就是重載所有形式的operator new 和 operator delete,截獲 new operator 和 delete operator 執行過程中的內存操作信息。下面列出的就是重載形式
void* operator new( size_t nSize, char* pszFileName, int nLineNum )
void* operator new[]( size_t nSize, char* pszFileName, int nLineNum )
void operator delete( void *ptr )
void operator delete[]( void *ptr )
我們為 operator new 定義了一個新的版本,除了必須的 size_t nSize 參數外,還增加了文件名和行號,這裡的文件名和行號就是這次 new operator 操作符被調用時所在的文件名和行號,這個信息將在發現內存洩漏時輸出,以幫助用戶定位洩漏具體位置。對於 operator delete,因為無法為之定義新的版本,我們直接覆蓋了全局的 operator delete 的兩個版本。
在重載的 operator new 函數版本中,我們將調用全局的 operator new 的相應的版本並將相應的 size_t 參數傳入,而後,我們將全局 operator new 返回的指針值以及該次分配所在的文件名和行號信息記錄下來,這裡所采用的數據結構是一個 STL 的 map,以指針值為 key 值。當 operator delete 被調用時,如果調用方式正確的話(調用方式不正確的情況將在後面詳細描述),我們就能以傳入的指針值在 map 中找到相應的數據項並將之刪除,而後調用 free 將指針所指向的內存塊釋放。當程序退出的時候,map 中的剩余的數據項就是我們企圖檢測的內存洩漏信息--已經在堆上分配但是尚未釋放的分配信息。
以上就是內存檢測實現的基本原理,現在還有兩個基本問題沒有解決:
1) 如何取得內存分配代碼所在的文件名和行號,並讓 new operator 將之傳遞給我們重載的 operator new。
2) 我們何時創建用於存儲內存數據的 map 數據結構,如何管理,何時打印內存洩漏信息。
先 解決問題1。首先我們可以利用 C 的預編譯宏 __FILE__ 和 __LINE__,這兩個宏將在編譯時在指定位置展開為該文件的文件名和該行的行號。而後我們需要將缺省的全局 new operator 替換為我們自定義的能夠傳入文件名和行號的版本,我們在子系統頭文件 MemRecord.h 中定義:
#define DEBUG_NEW new(__FILE__, __LINE__ )
而後在所有需要使用內存檢測的客戶程序的所有的 cpp 文件的開頭加入
#include "MemRecord.h"
#define new DEBUG_NEW
就可以將客戶源文件中的對於全局缺省的 new operator 的調用替換為 new (__FILE__,__LINE__) 調用,而該形式的new operator將調用我們的operator new (size_t nSize, char* pszFileName, int nLineNum),其中 nSize 是由 new operator 計算並傳入的,而 new 調用點的文件名和行號是由我們自定義版本的 new operator 傳入的。我們建議在所有用戶自己的源代碼文件中都加入上述宏,如果有的文件中使用內存檢測子系統而有的沒有,則子系統將可能因無法監控整個系統而輸出一些 洩漏警告。
再說第二個問題。我們用於管理客戶信息的這個 map 必須在客戶程序第一次調用 new operator 或者 delete operator 之前被創建,而且在最後一個 new operator 和 delete operator 調用之後進行洩漏信息的打印,也就是說它需要先於客戶程序而出生,而在客戶程序退出之後進行分析。能夠包容客戶程序生命周期的確有一人–全局對象 (appMemory)。我們可以設計一個類來封裝這個 map 以及這對它的插入刪除操作,然後構造這個類的一個全局對象(appMemory),在全局對象(appMemory)的構造函數中創建並初始化這個數據結 構,而在其析構函數中對數據結構中剩余數據進行分析和輸出。Operator new 中將調用這個全局對象(appMemory)的 insert 接口將指針、文件名、行號、內存塊大小等信息以指針值為 key 記錄到 map 中,在 operator delete 中調用 erase 接口將對應指針值的 map 中的數據項刪除,注意不要忘了對 map 的訪問需要進行互斥同步,因為同一時間可能會有多個線程進行堆上的內存操作。
好 啦,內存檢測的基本功能已經具備了。但是不要忘了,我們為了檢測內存洩漏,在全局的 operator new 增加了一層間接性,同時為了保證對數據結構的安全訪問增加了互斥,這些都會降低程序運行的效率。因此我們需要讓用戶能夠方便的 enable 和 disable 這個內存檢測功能,畢竟內存洩漏的檢測應該在程序的調試和測試階段完成。我們可以使用條件編譯的特性,在用戶被檢測文件中使用如下宏定義:
#include "MemRecord.h"
#if defined( MEM_DEBUG )
#define new DEBUG_NEW
#endif
當用戶需要使用內存檢測時,可以使用如下命令對被檢測文件進行編譯
g++ -c -DMEM_DEBUG xxxxxx.cpp
就可以 enable 內存檢測功能,而用戶程序正式發布時,可以去掉 -DMEM_DEBUG 編譯開關來 disable 內存檢測功能,消除內存檢測帶來的效率影響。
圖2所示為使用內存檢測功能後,內存洩漏代碼的執行以及檢測結果
圖2
4.錯誤方式刪除帶來的問題
以上我們已經構建了一個具備基本內存洩漏檢測功能的子系統,下面讓我們來看一下關於內存洩漏方面的一些稍微高級一點的話題。
首 先,在我們編制 c++ 應用時,有時需要在堆上創建單個對象,有時則需要創建對象的數組。關於 new 和 delete 原理的敘述我們可以知道,對於單個對象和對象數組來說,內存分配和刪除的動作是大不相同的,我們應該總是正確的使用彼此搭配的 new 和 delete 形式。但是在某些情況下,我們很容易犯錯誤,比如如下代碼:
class Test {};
……
Test* pAry = new Test[10];//創建了一個擁有 10 個 Test 對象的數組
Test* pObj = new Test;//創建了一個單對象
……
delete []pObj;//本應使用單對象形式 delete pObj 進行內存釋放,卻錯誤的使用了數
//組形式
delete pAry;//本應使用數組形式 delete []pAry 進行內存釋放,卻錯誤的使用了單對
//象的形式
不匹配的 new 和 delete 會導致什麼問題呢?C++ 標准對此的解答是”未定義”,就是說沒有人向你保證會發生什麼,但是有一點可以肯定:大多不是好事情–在某些編譯器形成的代碼中,程序可能會崩潰,而另 外一些編譯器形成的代碼中,程序運行可能毫無問題,但是可能導致內存洩漏。
既然知道形式不匹配的 new 和 delete 會帶來的問題,我們就需要對這種現象進行毫不留情的揭露,畢竟我們重載了所有形式的內存操作 operator new,operator new[],operator delete,operator delete[]。
我們首先想到的是,當用戶調用特定方式(單對象或者數 組方式)的 operator new 來分配內存時,我們可以在指向該內存的指針相關的數據結構中,增加一項用於描述其分配方式。當用戶調用不同形式的 operator delete 的時候,我們在 map 中找到與該指針相對應的數據結構,然後比較分配方式和釋放方式是否匹配,匹配則在 map 中正常刪除該數據結構,不匹配則將該數據結構轉移到一個所謂 “ErrorDelete” 的 list 中,在程序最終退出的時候和內存洩漏信息一起打印。
上面這種方法是最順理成章的,但是在實際應用中效果卻不好。原因有兩個,第一個原因我們 上面已經提到了:當 new 和 delete 形式不匹配時,其結果”未定義”。如果我們運氣實在太差–程序在執行不匹配的 delete 時崩潰了,我們的全局對象(appMemory)中存儲的數據也將不復存在,不會打印出任何信息。第二個原因與編譯器相關,前面提到過,當編譯器處理自定 義數據類型或者自定義數據類型數組的 new 和 delete 操作符的時候,通常使用編譯器相關的 cookie 技術。這種 cookie 技術在編譯器中可能的實現方式是:new operator 先計算容納所有對象所需的內存大小,而後再加上它為記錄 cookie 所需要的內存量,再將總容量傳給operator new 進行內存分配。當 operator new 返回所需的內存塊後,new operator 將在調用相應次數的構造函數初始化有效數據的同時,記錄 cookie 信息。而後將指向有效數據的指針返回給用戶。也就是說我們重載的 operator new 所申請到並記錄下來的指針與 new operator 返回給調用者的指針不一定一致(圖3)。當調用者將 new operator 返回的指針傳給 delete operator 進行內存釋放時,如果其調用形式相匹配,則相應形式的 delete operator 會作出相反的處理,即調用相應次數的析構函數,再通過指向有效數據的指針位置找出包含 cookie 的整塊內存地址,並將其傳給 operator delete 釋放內存。如果調用形式不匹配,delete operator 就不會做上述運算,而直接將指向有效數據的指針(而不是真正指向整塊內存的指針)傳入 operator delete。因為我們在 operator new 中記錄的是我們所分配的整塊內存的指針,而現在傳入 operator delete 的卻不是,所以就無法在全局對象(appMemory)所記錄的數據中找到相應的內存分配信息。
圖3
綜上所述,當 new 和 delete 的調用形式不匹配時,由於程序有可能崩潰或者內存子系統找不到相應的內存分配信息,在程序最終打印出 “ErrorDelete” 的方式只能檢測到某些”幸運”的不匹配現象。但我們總得做點兒什麼,不能讓這種危害極大的錯誤從我們眼前溜走,既然不能秋後算帳,我們就實時輸出一個 warning 信息來提醒用戶。什麼時候拋出一個 warning 呢?很簡單,當我們發現在 operator delete 或 operator delete[] 被調用的時候,我們無法在全局對象(appMemory)的 map 中找到與傳入的指針值相對應的內存分配信息,我們就認為應該提醒用戶。
既然決定要輸出warning信息,那麼現在的問題就是:我們如何描 述我們的warning信息才能更便於用戶定位到不匹配刪除錯誤呢?答案:在 warning 信息中打印本次 delete 調用的文件名和行號信息。這可有點困難了,因為對於 operator delete 我們不能向對象 operator new 一樣做出一個帶附加信息的重載版本,我們只能在保持其接口原貌的情況下,重新定義其實現,所以我們的 operator delete 中能夠得到的輸入只有指針值。在 new/delete 調用形式不匹配的情況下,我們很有可能無法在全局對象(appMemory)的 map 中找到原來的 new 調用的分配信息。怎麼辦呢?萬不得已,只好使用全局變量了。我們在檢測子系統的實現文件中定義了兩個全局變量 (DELETE_FILE,DELETE_LINE)記錄 operator delete 被調用時的文件名和行號,同時為了保證並發的 delete 操作對這兩個變量訪問同步,還使用了一個 mutex(至於為什麼是 CCommonMutex 而不是一個 pthread_mutex_t,在”實現上的問題”一節會詳細論述,在這裡它的作用就是一個 mutex)。
char DELETE_FILE[ FILENAME_LENGTH ] = {0};
int DELETE_LINE = 0;
CCommonMutex globalLock;
而後,在我們的檢測子系統的頭文件中定義了如下形式的 DEBUG_DELETE
extern char DELETE_FILE[ FILENAME_LENGTH ];
extern int DELETE_LINE;
extern CCommonMutex globalLock;//在後面解釋
#define DEBUG_DELETE globalLock.Lock(); \
if (DELETE_LINE != 0) BuildStack();\ (//見第六節解釋)
strncpy( DELETE_FILE, __FILE__,FILENAME_LENGTH - 1 );\
DELETE_FILE[ FILENAME_LENGTH - 1 ]= '\0'; \
DELETE_LINE = __LINE__; \
delete
在用戶被檢測文件中原來的宏定義中添加一條:
#include "MemRecord.h"
#if defined( MEM_DEBUG )
#define new DEBUG_NEW
#define delete DEBUG_DELETE
#endif
這樣,在用戶被檢測文件調用 delete operator 之前,將先獲得互斥鎖,然後使用調用點文件名和行號對相應的全局變量(DELETE_FILE,DELETE_LINE)進行賦值,而後調用 delete operator。當 delete operator 最終調用我們定義的 operator delete 的時候,在獲得此次調用的文件名和行號信息後,對文件名和行號全局變量(DELETE_FILE,DELETE_LINE)重新初始化並打開互斥鎖,讓下 一個掛在互斥鎖上的 delete operator 得以執行。
在對 delete operator 作出如上修改以後,當我們發現無法經由 delete operator 傳入的指針找到對應的內存分配信息的時候,就打印包括該次調用的文件名和行號的 warning。
天下沒有十全十美的事情,既然我們提供了一種針對錯誤方式刪除的提醒方法,我們就需要考慮以下幾種異常情況:
1. 用戶使用的第三方庫函數中有內存分配和釋放操作。或者用戶的被檢測進程中進行內存分配和釋放的實現文件沒有使用我們的宏定義。 由於我們替換了全局的 operator delete,這種情況下的用戶調用的 delete 也會被我們截獲。用戶並沒有使用我們定義的DEBUG_NEW 宏,所以我們無法在我們的全局對象(appMemory)數據結構中找到對應的內存分配信息,但是由於它也沒有使用DEBUG_DELETE,我們為 delete 定義的兩個全局 DELETE_FILE 和 DELETE_LINE 都不會有值,因此可以不打印 warning。
2. 用戶的一個實現文件調用了 new 進行內存分配工作,但是該文件並沒有使用我們定義的 DEBUG_NEW 宏。同時用戶的另一個實現文件中的代碼負責調用 delete 來刪除前者分配的內存,但不巧的是,這個文件使用了 DEBUG_DELETE 宏。這種情況下內存檢測子系統會報告 warning,並打印出 delete 調用的文件名和行號。
3. 與第二種情況相反,用戶的一個實現文件調用了 new 進行內存分配工作,並使用我們定義的 DEBUG_NEW 宏。同時用戶的另一個實現文件中的代碼負責調用 delete 來刪除前者分配的內存,但該文件沒有使用 DEBUG_DELETE 宏。這種情況下,因為我們能夠找到這個內存分配的原始信息,所以不會打印 warning。
4. 當出現嵌套 delete(定義可見”實現上的問題”)的情況下,以上第一和第三種情況都有可能打印出不正確的 warning 信息,詳細分析可見”實現上的問題”一節。
你可能覺得這樣的 warning 太隨意了,有誤導之嫌。怎麼說呢?作為一個檢測子系統,對待有可能的錯誤我們所采取的原則是:寧可誤報,不可漏報。請大家”有則改之,無則加勉”。
5.動態內存洩漏信息的檢測
上 面我們所講述的內存洩漏的檢測能夠在程序整個生命周期結束時,打印出在程序運行過程中已經在堆上分配但是沒有釋放的內存分配信息,程序員可以由此找到程序 中”顯式”的內存洩漏點並加以改正。但是如果程序在結束之前能夠將自己所分配的所有內存都釋放掉,是不是就可以說這個程序不存在內存洩漏呢?答案:否!在 編程實踐中,我們發現了另外兩種危害性更大的”隱式”內存洩漏,其表現就是在程序退出時,沒有任何內存洩漏的現象,但是在程序運行過程中,內存占用量卻不 斷增加,直到使整個系統崩潰。
1. 程序的一個線程不斷分配內存,並將指向內存的指針保存在一個數據存儲中(如 list),但是在程序運行過程中,一直沒有任何線程進行內存釋放。當程序退出的時候,該數據存儲中的指針值所指向的內存塊被依次釋放。
2. 程序的N個線程進行內存分配,並將指針傳遞給一個數據存儲,由M個線程從數據存儲進行數據處理和內存釋放。由於 N 遠大於M,或者M個線程數據處理的時間過長,導致內存分配的速度遠大於內存被釋放的速度。但是在程序退出的時候,數據存儲中的指針值所指向的內存塊被依次 釋放。
之所以說他危害性更大,是因為很不容易這種問題找出來,程序可能連續運行幾個十幾個小時沒有問題,從而通過了不嚴密的系統測試。但是如果在實際環境中 7×24 小時運行,系統將不定時的崩潰,而且崩潰的原因從 log 和程序表象上都查不出原因。
為了將這種問題也挑落馬下,我們增加了一個動態檢測模塊 MemSnapShot,用於在程序運行過程中,每隔一定的時間間隔就對程序當前的內存總使用情況和內存分配情況進行統計,以使用戶能夠對程序的動態內存分配狀況進行監視。
當 客戶使用 MemSnapShot 進程監視一個運行中的進程時,被監視進程的內存子系統將把內存分配和釋放的信息實時傳送給MemSnapShot。MemSnapShot 則每隔一定的時間間隔就對所接收到的信息進行統計,計算該進程總的內存使用量,同時以調用new進行內存分配的文件名和行號為索引值,計算每個內存分配動 作所分配而未釋放的內存總量。這樣一來,如果在連續多個時間間隔的統計結果中,如果某文件的某行所分配的內存總量不斷增長而始終沒有到達一個平衡點甚至回 落,那它一定是我們上面所說到的兩種問題之一。
在實現上,內存檢測子系統的全局對象(appMemory)的構造函數中以自己的當前 PID 為基礎 key 值創建一個消息隊列,並在operator new 和 operator delete 被調用的時候將相應的信息寫入消息隊列。MemSnapShot 進程啟動時需要輸入被檢測進程的 PID,而後通過該 PID 組裝 key 值並找到被檢測進程創建的消息隊列,並開始讀入消息隊列中的數據進行分析統計。當得到operator new 的信息時,記錄內存分配信息,當收到 operator delete 消息時,刪除相應的內存分配信息。同時啟動一個分析線程,每隔一定的時間間隔就計算一下當前的以分配而尚未釋放的內存信息,並以內存的分配位置為關鍵字進 行統計,查看在同一位置(相同文件名和行號)所分配的內存總量和其占進程總內存量的百分比。
圖4 是一個正在運行的 MemSnapShot 程序,它所監視的進程的動態內存分配情況如圖所示: