原文出處: 洪琨???
1.開發(fā)背景
在 windows 下使用 VC 編程時,我們通常需要 DEBUG 模式下運行程序,而后調試器將在退出程序時,打印出程序運行過程中在堆上分配而沒有釋放的內存信息,其中包括代碼文件名、行號以及內存大小。該功能是 MFC Framework 提供的內置機制,封裝在其類結構體系內部。
在 linux 或者 unix 下,我們的 C++ 程序缺乏相應的手段來檢測內存信息,而只能使用 top 指令觀察進程的動態(tài)內存總額。而且程序退出時,我們無法獲知任何內存泄漏信息。為了更好的輔助在 linux 下程序開發(fā),我們在我們的類庫項目中設計并實現(xiàn)了一個內存檢測子系統(tǒng)。下文將簡述 C++ 中的 new 和 delete 的基本原理,并講述了內存檢測子系統(tǒng)的實現(xiàn)原理、實現(xiàn)中的技巧,并對內存泄漏檢測的高級話題進行了討論。
2.New和delete的原理
當我們在程序中寫下 new 和 delete 時,我們實際上調用的是 C++ 語言內置的 new operator 和 delete operator。所謂語言內置就是說我們不能更改其含義,它的功能總是一致的。以 new operator 為例,它總是先分配足夠的內存,而后再調用相應的類型的構造函數(shù)初始化該內存。而 delete operator 總是先調用該類型的析構函數(shù),而后釋放內存(圖1)。我們能夠施加影響力的事實上就是 new operator 和 delete operator 執(zhí)行過程中分配和釋放內存的方法。
new operator 為分配內存所調用的函數(shù)名字是 operator new,其通常的形式是 void * operator new(size_t size); 其返回值類型是 void*,因為這個函數(shù)返回一個未經處理(raw)的指針,未初始化的內存。參數(shù) size 確定分配多少內存,你能增加額外的參數(shù)重載函數(shù) operator new,但是第一個參數(shù)類型必須是 size_t。
delete operator 為釋放內存所調用的函數(shù)名字是 operator delete,其通常的形式是 void operator delete(void *memoryToBeDeallocated);它釋放傳入的參數(shù)所指向的一片內存區(qū)。
這里有一個問題,就是當我們調用 new operator 分配內存時,有一個 size 參數(shù)表明需要分配多大的內存。但是當調用 delete operator 時,卻沒有類似的參數(shù),那么 delete operator 如何能夠知道需要釋放該指針指向的內存塊的大小呢?答案是:對于系統(tǒng)自有的數(shù)據(jù)類型,語言本身就能區(qū)分內存塊的大小,而對于自定義數(shù)據(jù)類型(如我們自定義的類),則 operator new 和 operator delete 之間需要互相傳遞信息。
當我們使用 operator new 為一個自定義類型對象分配內存時,實際上我們得到的內存要比實際對象的內存大一些,這些內存除了要存儲對象數(shù)據(jù)外,還需要記錄這片內存的大小,此方法稱為 cookie。這一點上的實現(xiàn)依據(jù)不同的編譯器不同。(例如 MFC 選擇在所分配內存的頭部存儲對象實際數(shù)據(jù),而后面的部分存儲邊界標志和內存大小信息。g++ 則采用在所分配內存的頭 4 個自己存儲相關信息,而后面的內存存儲對象實際數(shù)據(jù)。)當我們使用 delete operator 進行內存釋放操作時,delete operator 就可以根據(jù)這些信息正確的釋放指針所指向的內存塊。
以上論述的是對于單個對象的內存分配/釋放,當我們?yōu)閿?shù)組分配/釋放內存時,雖然我們仍然使用 new operator 和 delete operator,但是其內部行為卻有不同:new operator 調用了operator new 的數(shù)組版的兄弟- operator new[],而后針對每一個數(shù)組成員調用構造函數(shù)。而 delete operator 先對每一個數(shù)組成員調用析構函數(shù),而后調用 operator delete[] 來釋放內存。需要注意的是,當我們創(chuàng)建或釋放由自定義數(shù)據(jù)類型所構成的數(shù)組時,編譯器為了能夠標識出在 operator delete[] 中所需釋放的內存塊的大小,也使用了編譯器相關的 cookie 技術。
綜上所述,如果我們想檢測內存泄漏,就必須對程序中的內存分配和釋放情況進行記錄和分析,也就是說我們需要重載 operator new/operator new[];operator delete/operator delete[] 四個全局函數(shù),以截獲我們所需檢驗的內存操作信息。
3.內存檢測的基本實現(xiàn)原理
上文提到要想檢測內存泄漏,就必須對程序中的內存分配和釋放情況進行記錄,所能夠采取的辦法就是重載所有形式的operator new 和 operator delete,截獲 new operator 和 delete operator 執(zhí)行過程中的內存操作信息。下面列出的就是重載形式
我們?yōu)?operator new 定義了一個新的版本,除了必須的 size_t nSize 參數(shù)外,還增加了文件名和行號,這里的文件名和行號就是這次 new operator 操作符被調用時所在的文件名和行號,這個信息將在發(fā)現(xiàn)內存泄漏時輸出,以幫助用戶定位泄漏具體位置。對于 operator delete,因為無法為之定義新的版本,我們直接覆蓋了全局的 operator delete 的兩個版本。
在重載的 operator new 函數(shù)版本中,我們將調用全局的 operator new 的相應的版本并將相應的 size_t 參數(shù)傳入,而后,我們將全局 operator new 返回的指針值以及該次分配所在的文件名和行號信息記錄下來,這里所采用的數(shù)據(jù)結構是一個 STL 的 map,以指針值為 key 值。當 operator delete 被調用時,如果調用方式正確的話(調用方式不正確的情況將在后面詳細描述),我們就能以傳入的指針值在 map 中找到相應的數(shù)據(jù)項并將之刪除,而后調用 free 將指針所指向的內存塊釋放。當程序退出的時候,map 中的剩余的數(shù)據(jù)項就是我們企圖檢測的內存泄漏信息--已經在堆上分配但是尚未釋放的分配信息。
以上就是內存檢測實現(xiàn)的基本原理,現(xiàn)在還有兩個基本問題沒有解決:
1) 如何取得內存分配代碼所在的文件名和行號,并讓 new operator 將之傳遞給我們重載的 operator new。
2) 我們何時創(chuàng)建用于存儲內存數(shù)據(jù)的 map 數(shù)據(jù)結構,如何管理,何時打印內存泄漏信息。
先解決問題1。首先我們可以利用 C 的預編譯宏 __FILE__ 和 __LINE__,這兩個宏將在編譯時在指定位置展開為該文件的文件名和該行的行號。而后我們需要將缺省的全局 new operator 替換為我們自定義的能夠傳入文件名和行號的版本,我們在子系統(tǒng)頭文件 MemRecord.h 中定義:
而后在所有需要使用內存檢測的客戶程序的所有的 cpp 文件的開頭加入
就可以將客戶源文件中的對于全局缺省的 new operator 的調用替換為 new (__FILE__,__LINE__) 調用,而該形式的new operator將調用我們的operator new (size_t nSize, char* pszFileName, int nLineNum),其中 nSize 是由 new operator 計算并傳入的,而 new 調用點的文件名和行號是由我們自定義版本的 new operator 傳入的。我們建議在所有用戶自己的源代碼文件中都加入上述宏,如果有的文件中使用內存檢測子系統(tǒng)而有的沒有,則子系統(tǒng)將可能因無法監(jiān)控整個系統(tǒng)而輸出一些泄漏警告。
再說第二個問題。我們用于管理客戶信息的這個 map 必須在客戶程序第一次調用 new operator 或者 delete operator 之前被創(chuàng)建,而且在最后一個 new operator 和 delete operator 調用之后進行泄漏信息的打印,也就是說它需要先于客戶程序而出生,而在客戶程序退出之后進行分析。能夠包容客戶程序生命周期的確有一人–全局對象(appMemory)。我們可以設計一個類來封裝這個 map 以及這對它的插入刪除操作,然后構造這個類的一個全局對象(appMemory),在全局對象(appMemory)的構造函數(shù)中創(chuàng)建并初始化這個數(shù)據(jù)結構,而在其析構函數(shù)中對數(shù)據(jù)結構中剩余數(shù)據(jù)進行分析和輸出。Operator new 中將調用這個全局對象(appMemory)的 insert 接口將指針、文件名、行號、內存塊大小等信息以指針值為 key 記錄到 map 中,在 operator delete 中調用 erase 接口將對應指針值的 map 中的數(shù)據(jù)項刪除,注意不要忘了對 map 的訪問需要進行互斥同步,因為同一時間可能會有多個線程進行堆上的內存操作。
好啦,內存檢測的基本功能已經具備了。但是不要忘了,我們?yōu)榱藱z測內存泄漏,在全局的 operator new 增加了一層間接性,同時為了保證對數(shù)據(jù)結構的安全訪問增加了互斥,這些都會降低程序運行的效率。因此我們需要讓用戶能夠方便的 enable 和 disable 這個內存檢測功能,畢竟內存泄漏的檢測應該在程序的調試和測試階段完成。我們可以使用條件編譯的特性,在用戶被檢測文件中使用如下宏定義:
當用戶需要使用內存檢測時,可以使用如下命令對被檢測文件進行編譯
就可以 enable 內存檢測功能,而用戶程序正式發(fā)布時,可以去掉 -DMEM_DEBUG 編譯開關來 disable 內存檢測功能,消除內存檢測帶來的效率影響。
圖2所示為使用內存檢測功能后,內存泄漏代碼的執(zhí)行以及檢測結果
圖2
4.錯誤方式刪除帶來的問題
以上我們已經構建了一個具備基本內存泄漏檢測功能的子系統(tǒng),下面讓我們來看一下關于內存泄漏方面的一些稍微高級一點的話題。
首先,在我們編制 c++ 應用時,有時需要在堆上創(chuàng)建單個對象,有時則需要創(chuàng)建對象的數(shù)組。關于 new 和 delete 原理的敘述我們可以知道,對于單個對象和對象數(shù)組來說,內存分配和刪除的動作是大不相同的,我們應該總是正確的使用彼此搭配的 new 和 delete 形式。但是在某些情況下,我們很容易犯錯誤,比如如下代碼:
不匹配的 new 和 delete 會導致什么問題呢?C++ 標準對此的解答是”未定義”,就是說沒有人向你保證會發(fā)生什么,但是有一點可以肯定:大多不是好事情–在某些編譯器形成的代碼中,程序可能會崩潰,而另外一些編譯器形成的代碼中,程序運行可能毫無問題,但是可能導致內存泄漏。
既然知道形式不匹配的 new 和 delete 會帶來的問題,我們就需要對這種現(xiàn)象進行毫不留情的揭露,畢竟我們重載了所有形式的內存操作 operator new,operator new[],operator delete,operator delete[]。
我們首先想到的是,當用戶調用特定方式(單對象或者數(shù)組方式)的 operator new 來分配內存時,我們可以在指向該內存的指針相關的數(shù)據(jù)結構中,增加一項用于描述其分配方式。當用戶調用不同形式的 operator delete 的時候,我們在 map 中找到與該指針相對應的數(shù)據(jù)結構,然后比較分配方式和釋放方式是否匹配,匹配則在 map 中正常刪除該數(shù)據(jù)結構,不匹配則將該數(shù)據(jù)結構轉移到一個所謂 “ErrorDelete” 的 list 中,在程序最終退出的時候和內存泄漏信息一起打印。
上面這種方法是最順理成章的,但是在實際應用中效果卻不好。原因有兩個,第一個原因我們上面已經提到了:當 new 和 delete 形式不匹配時,其結果”未定義”。如果我們運氣實在太差–程序在執(zhí)行不匹配的 delete 時崩潰了,我們的全局對象(appMemory)中存儲的數(shù)據(jù)也將不復存在,不會打印出任何信息。第二個原因與編譯器相關,前面提到過,當編譯器處理自定義數(shù)據(jù)類型或者自定義數(shù)據(jù)類型數(shù)組的 new 和 delete 操作符的時候,通常使用編譯器相關的 cookie 技術。這種 cookie 技術在編譯器中可能的實現(xiàn)方式是:new operator 先計算容納所有對象所需的內存大小,而后再加上它為記錄 cookie 所需要的內存量,再將總容量傳給operator new 進行內存分配。當 operator new 返回所需的內存塊后,new operator 將在調用相應次數(shù)的構造函數(shù)初始化有效數(shù)據(jù)的同時,記錄 cookie 信息。而后將指向有效數(shù)據(jù)的指針返回給用戶。也就是說我們重載的 operator new 所申請到并記錄下來的指針與 new operator 返回給調用者的指針不一定一致(圖3)。當調用者將 new operator 返回的指針傳給 delete operator 進行內存釋放時,如果其調用形式相匹配,則相應形式的 delete operator 會作出相反的處理,即調用相應次數(shù)的析構函數(shù),再通過指向有效數(shù)據(jù)的指針位置找出包含 cookie 的整塊內存地址,并將其傳給 operator delete 釋放內存。如果調用形式不匹配,delete operator 就不會做上述運算,而直接將指向有效數(shù)據(jù)的指針(而不是真正指向整塊內存的指針)傳入 operator delete。因為我們在 operator new 中記錄的是我們所分配的整塊內存的指針,而現(xiàn)在傳入 operator delete 的卻不是,所以就無法在全局對象(appMemory)所記錄的數(shù)據(jù)中找到相應的內存分配信息。
圖3
綜上所述,當 new 和 delete 的調用形式不匹配時,由于程序有可能崩潰或者內存子系統(tǒng)找不到相應的內存分配信息,在程序最終打印出 “ErrorDelete” 的方式只能檢測到某些”幸運”的不匹配現(xiàn)象。但我們總得做點兒什么,不能讓這種危害極大的錯誤從我們眼前溜走,既然不能秋后算帳,我們就實時輸出一個 warning 信息來提醒用戶。什么時候拋出一個 warning 呢?很簡單,當我們發(fā)現(xiàn)在 operator delete 或 operator delete[] 被調用的時候,我們無法在全局對象(appMemory)的 map 中找到與傳入的指針值相對應的內存分配信息,我們就認為應該提醒用戶。
既然決定要輸出warning信息,那么現(xiàn)在的問題就是:我們如何描述我們的warning信息才能更便于用戶定位到不匹配刪除錯誤呢?答案:在 warning 信息中打印本次 delete 調用的文件名和行號信息。這可有點困難了,因為對于 operator delete 我們不能向對象 operator new 一樣做出一個帶附加信息的重載版本,我們只能在保持其接口原貌的情況下,重新定義其實現(xiàn),所以我們的 operator delete 中能夠得到的輸入只有指針值。在 new/delete 調用形式不匹配的情況下,我們很有可能無法在全局對象(appMemory)的 map 中找到原來的 new 調用的分配信息。怎么辦呢?萬不得已,只好使用全局變量了。我們在檢測子系統(tǒng)的實現(xiàn)文件中定義了兩個全局變量(DELETE_FILE,DELETE_LINE)記錄 operator delete 被調用時的文件名和行號,同時為了保證并發(fā)的 delete 操作對這兩個變量訪問同步,還使用了一個 mutex(至于為什么是 CCommonMutex 而不是一個 pthread_mutex_t,在”實現(xiàn)上的問題”一節(jié)會詳細論述,在這里它的作用就是一個 mutex)。
而后,在我們的檢測子系統(tǒng)的頭文件中定義了如下形式的 DEBUG_DELETE
在用戶被檢測文件中原來的宏定義中添加一條:
這樣,在用戶被檢測文件調用 delete operator 之前,將先獲得互斥鎖,然后使用調用點文件名和行號對相應的全局變量(DELETE_FILE,DELETE_LINE)進行賦值,而后調用 delete operator。當 delete operator 最終調用我們定義的 operator delete 的時候,在獲得此次調用的文件名和行號信息后,對文件名和行號全局變量(DELETE_FILE,DELETE_LINE)重新初始化并打開互斥鎖,讓下一個掛在互斥鎖上的 delete operator 得以執(zhí)行。
在對 delete operator 作出如上修改以后,當我們發(fā)現(xiàn)無法經由 delete operator 傳入的指針找到對應的內存分配信息的時候,就打印包括該次調用的文件名和行號的 warning。
天下沒有十全十美的事情,既然我們提供了一種針對錯誤方式刪除的提醒方法,我們就需要考慮以下幾種異常情況:
1. 用戶使用的第三方庫函數(shù)中有內存分配和釋放操作。或者用戶的被檢測進程中進行內存分配和釋放的實現(xiàn)文件沒有使用我們的宏定義。 由于我們替換了全局的 operator delete,這種情況下的用戶調用的 delete 也會被我們截獲。用戶并沒有使用我們定義的DEBUG_NEW 宏,所以我們無法在我們的全局對象(appMemory)數(shù)據(jù)結構中找到對應的內存分配信息,但是由于它也沒有使用DEBUG_DELETE,我們?yōu)?delete 定義的兩個全局 DELETE_FILE 和 DELETE_LINE 都不會有值,因此可以不打印 warning。
2. 用戶的一個實現(xiàn)文件調用了 new 進行內存分配工作,但是該文件并沒有使用我們定義的 DEBUG_NEW 宏。同時用戶的另一個實現(xiàn)文件中的代碼負責調用 delete 來刪除前者分配的內存,但不巧的是,這個文件使用了 DEBUG_DELETE 宏。這種情況下內存檢測子系統(tǒng)會報告 warning,并打印出 delete 調用的文件名和行號。
3. 與第二種情況相反,用戶的一個實現(xiàn)文件調用了 new 進行內存分配工作,并使用我們定義的 DEBUG_NEW 宏。同時用戶的另一個實現(xiàn)文件中的代碼負責調用 delete 來刪除前者分配的內存,但該文件沒有使用 DEBUG_DELETE 宏。這種情況下,因為我們能夠找到這個內存分配的原始信息,所以不會打印 warning。
4. 當出現(xiàn)嵌套 delete(定義可見”實現(xiàn)上的問題”)的情況下,以上第一和第三種情況都有可能打印出不正確的 warning 信息,詳細分析可見”實現(xiàn)上的問題”一節(jié)。
你可能覺得這樣的 warning 太隨意了,有誤導之嫌。怎么說呢?作為一個檢測子系統(tǒng),對待有可能的錯誤我們所采取的原則是:寧可誤報,不可漏報。請大家”有則改之,無則加勉”。
5.動態(tài)內存泄漏信息的檢測
上面我們所講述的內存泄漏的檢測能夠在程序整個生命周期結束時,打印出在程序運行過程中已經在堆上分配但是沒有釋放的內存分配信息,程序員可以由此找到程序中”顯式”的內存泄漏點并加以改正。但是如果程序在結束之前能夠將自己所分配的所有內存都釋放掉,是不是就可以說這個程序不存在內存泄漏呢?答案:否!在編程實踐中,我們發(fā)現(xiàn)了另外兩種危害性更大的”隱式”內存泄漏,其表現(xiàn)就是在程序退出時,沒有任何內存泄漏的現(xiàn)象,但是在程序運行過程中,內存占用量卻不斷增加,直到使整個系統(tǒng)崩潰。
1. 程序的一個線程不斷分配內存,并將指向內存的指針保存在一個數(shù)據(jù)存儲中(如 list),但是在程序運行過程中,一直沒有任何線程進行內存釋放。當程序退出的時候,該數(shù)據(jù)存儲中的指針值所指向的內存塊被依次釋放。
2. 程序的N個線程進行內存分配,并將指針傳遞給一個數(shù)據(jù)存儲,由M個線程從數(shù)據(jù)存儲進行數(shù)據(jù)處理和內存釋放。由于 N 遠大于M,或者M個線程數(shù)據(jù)處理的時間過長,導致內存分配的速度遠大于內存被釋放的速度。但是在程序退出的時候,數(shù)據(jù)存儲中的指針值所指向的內存塊被依次釋放。
之所以說他危害性更大,是因為很不容易這種問題找出來,程序可能連續(xù)運行幾個十幾個小時沒有問題,從而通過了不嚴密的系統(tǒng)測試。但是如果在實際環(huán)境中 7×24 小時運行,系統(tǒng)將不定時的崩潰,而且崩潰的原因從 log 和程序表象上都查不出原因。
為了將這種問題也挑落馬下,我們增加了一個動態(tài)檢測模塊 MemSnapShot,用于在程序運行過程中,每隔一定的時間間隔就對程序當前的內存總使用情況和內存分配情況進行統(tǒng)計,以使用戶能夠對程序的動態(tài)內存分配狀況進行監(jiān)視。
當客戶使用 MemSnapShot 進程監(jiān)視一個運行中的進程時,被監(jiān)視進程的內存子系統(tǒng)將把內存分配和釋放的信息實時傳送給MemSnapShot。MemSnapShot 則每隔一定的時間間隔就對所接收到的信息進行統(tǒng)計,計算該進程總的內存使用量,同時以調用new進行內存分配的文件名和行號為索引值,計算每個內存分配動作所分配而未釋放的內存總量。這樣一來,如果在連續(xù)多個時間間隔的統(tǒng)計結果中,如果某文件的某行所分配的內存總量不斷增長而始終沒有到達一個平衡點甚至回落,那它一定是我們上面所說到的兩種問題之一。
在實現(xiàn)上,內存檢測子系統(tǒng)的全局對象(appMemory)的構造函數(shù)中以自己的當前 PID 為基礎 key 值創(chuàng)建一個消息隊列,并在operator new 和 operator delete 被調用的時候將相應的信息寫入消息隊列。MemSnapShot 進程啟動時需要輸入被檢測進程的 PID,而后通過該 PID 組裝 key 值并找到被檢測進程創(chuàng)建的消息隊列,并開始讀入消息隊列中的數(shù)據(jù)進行分析統(tǒng)計。當?shù)玫給perator new 的信息時,記錄內存分配信息,當收到 operator delete 消息時,刪除相應的內存分配信息。同時啟動一個分析線程,每隔一定的時間間隔就計算一下當前的以分配而尚未釋放的內存信息,并以內存的分配位置為關鍵字進行統(tǒng)計,查看在同一位置(相同文件名和行號)所分配的內存總量和其占進程總內存量的百分比。
圖4 是一個正在運行的 MemSnapShot 程序,它所監(jiān)視的進程的動態(tài)內存分配情況如圖所示:
圖四
在支持 MemSnapShot 過程中的實現(xiàn)上的唯一技巧是–對于被檢測進程異常退出狀況的處理。因為被檢測進程中的內存檢測子系統(tǒng)創(chuàng)建了用于進程間傳輸數(shù)據(jù)的消息隊列,它是一個核心資源,其生命周期與內核相同,一旦創(chuàng)建,除非顯式的進行刪除或系統(tǒng)重啟,否則將不被釋放。
不錯,我們可以在內存檢測子系統(tǒng)中的全局對象(appMemory)的析構函數(shù)中完成對消息隊列的刪除,但是如果被檢測進程非正常退出(CTRL+C,段錯誤崩潰等),消息隊列可就沒人管了。那么我們可以不可以在全局對象(appMemory)的構造函數(shù)中使用 signal 系統(tǒng)調用注冊 SIGINT,SIGSEGV 等系統(tǒng)信號處理函數(shù),并在處理函數(shù)中刪除消息隊列呢?還是不行,因為被檢測進程完全有可能注冊自己的對應的信號處理函數(shù),這樣就會替換我們的信號處理函數(shù)。最終我們采取的方法是利用 fork 產生一個孤兒進程,并利用這個進程監(jiān)視被檢測進程的生存狀況,如果被檢測進程已經退出(無論正常退出還是異常退出),則試圖刪除被檢測進程所創(chuàng)建的消息隊列。下面簡述其實現(xiàn)原理:
在全局對象(appMemory)構造函數(shù)中,創(chuàng)建消息隊列成功以后,我們調用 fork 創(chuàng)建一個子進程,而后該子進程再次調用 fork 創(chuàng)建孫子進程,并退出,從而使孫子進程變?yōu)橐粋€”孤兒”進程(之所以使用孤兒進程是因為我們需要切斷被檢測進程與我們創(chuàng)建的進程之間的信號聯(lián)系)。孫子進程利用父進程(被檢測進程)的全局對象(appMemory)得到其 PID 和剛剛創(chuàng)建的消息隊列的標識,并傳遞給調用 exec 函數(shù)產生的一個新的程序映象–MemCleaner。
MemCleaner 程序僅僅調用 kill(pid, 0);函數(shù)來查看被檢測進程的生存狀態(tài),如果被檢測進程不存在了(正?;蛘弋惓M顺觯瑒t kill 函數(shù)返回非 0 值,此時我們就動手清除可能存在的消息隊列。
6.實現(xiàn)上的問題:嵌套delete
在”錯誤方式刪除帶來的問題”一節(jié)中,我們對 delete operator 動了個小手術–增加了兩個全局變量(DELETE_FILE,DELETE_LINE)用于記錄本次 delete 操作所在的文件名和行號,并且為了同步對全局變量(DELETE_FILE,DELETE_LINE)的訪問,增加了一個全局的互斥鎖。在一開始,我們使用的是 pthread_mutex_t,但是在測試中,我們發(fā)現(xiàn) pthread_mutex_t 在本應用環(huán)境中的局限性。
例如如下代碼:
在上述代碼中,main 函數(shù)中的一句 delete pA 我們稱之為”嵌套刪除”,即我們 delete A 對象的時候,在A對象的析構執(zhí)行了另一個 delete B 的動作。當用戶使用我們的內存檢測子系統(tǒng)時,delete pA 的動作應轉化為以下動作:
在這一過程中,有兩個技術問題,一個是 mutex 的可重入問題,一個是嵌套刪除時 對全局變量(DELETE_FILE,DELETE_LINE)現(xiàn)場保護的問題。
所謂 mutex 的可重入問題,是指在同一個線程上下文中,連續(xù)對同一個 mutex 調用了多次 lock,然后連續(xù)調用了多次 unlock。這就是說我們的應用方式要求互斥鎖有如下特性:
1. 要求在同一個線程上下文中,能夠多次持有同一個互斥體。并且只有在同一線程上下文中調用相同次數(shù)的 unlock 才能放棄對互斥體的占有。
2. 對于不同線程上下文持有互斥體的企圖,同一時間只有一個線程能夠持有互斥體,并且只有在其釋放互斥體之后,其他線程才能持有該互斥體。
Pthread_mutex_t 互斥體不具有以上特性,即使在同一上下文中,第二次調用 pthread_mutex_lock 將會掛起。因此,我們必須實現(xiàn)出自己的互斥體。在這里我們使用 semaphore 的特性實現(xiàn)了一個符合上述特性描述的互斥體 CCommonMutex(源代碼見附件)。
為了支持特性 2,在這個 CCommonMutex 類中,封裝了一個 semaphore,并在構造函數(shù)中令其資源值為 1,初始值為1。當調用 CCommonMutex::lock 接口時,調用 sem_wait 得到 semaphore,使信號量的資源為 0 從而讓其他調用 lock 接口的線程掛起。當調用接口 CCommonMutex::unlock 時,調用 sem_post 使信號量資源恢復為 1,讓其他掛起的線程中的一個持有信號量。
同時為了支持特性 1,在這個 CCommonMutex 增加了對于當前線程 pid 的判斷和當前線程訪問計數(shù)。當線程第一次調用 lock 接口時,我們調用 sem_wait 的同時,記錄當前的 Pid 到成員變量 m_pid,并置訪問計數(shù)為 1,同一線程(m_pid == getpid())其后的多次調用將只進行計數(shù)而不掛起。當調用 unlock 接口時,如果計數(shù)不為 1,則只需遞減訪問計數(shù),直到遞減訪問計數(shù)為 1 才進行清除 pid、調用 sem_post。(具體代碼可見附件)
嵌套刪除時對全局變量(DELETE_FILE,DELETE_LINE)現(xiàn)場保護的問題是指,上述步驟中在 A 的析構函數(shù)中調用 delete m_pB 時,對全局變量(DELETE_FILE,DELETE_LINE)文件名和行號的賦值將覆蓋主程序中調用 delete pA 時對全局變量(DELETE_FILE,DELETE_LINE)的賦值,造成了在執(zhí)行 operator delete A 時,delete pA 的信息全部丟失。
要想對這些全局信息進行現(xiàn)場保護,最好用的就是堆棧了,在這里我們使用了 STL 提供的 stack 容器。在 DEBUG_DELETE 宏定義中,對全局變量(DELETE_FILE,DELETE_LINE)賦值之前,我們先判斷是否前面已經有人對他們賦過值了–觀察行號變量是否等于 0,如果不為 0,則應該將已有的信息壓棧(調用一個全局函數(shù) BuildStack() 將當前的全局文件名和行號數(shù)據(jù)壓入一個全局堆棧globalStack),而后再對全局變量(DELETE_FILE,DELETE_LINE)賦值,再調用 delete operator。而在內存子系統(tǒng)的全局對象(appMemory)提供的 erase 接口里面,如果判斷傳入的文件名和行號為 0,則說明我們所需要的數(shù)據(jù)有可能被嵌套刪除覆蓋了,所以需要從堆棧中彈出相應的數(shù)據(jù)進行處理。
現(xiàn)在嵌套刪除中的問題基本解決了,但是當嵌套刪除與 “錯誤方式刪除帶來的問題”一節(jié)的最后所描述的第一和第三種異常情況同時出現(xiàn)的時候,由于用戶的 delete 調用沒有通過我們定義的 DEBUG_DELETE 宏,上述機制可能出現(xiàn)問題。其根本原因是我們利用stack 保留了經由我們的 DEBUG_DELETE 宏記錄的 delete 信息的現(xiàn)場,以便在 operator delete 和全局對象(appMemory)的 erase 接口中使用,但是用戶的沒經過 DEBUG_DELETE 宏的 delete 操作卻未曾進行壓棧操作而直接調用了 operator delete,有可能將不屬于這次操作的 delete 信息彈出,破壞了堆棧信息的順序和有效性。那么,當我們因為無法找到這次及其后續(xù)的 delete 操作所對應的內存分配信息的時候,可能會打印出錯誤的 warning 信息。
展望
以上就是我們所實現(xiàn)的內存泄漏檢測子系統(tǒng)的原理和技術方案,第一版的源代碼在附件中,已經經過了較嚴格的系統(tǒng)測試。但是限于我們的 C++ 知識水平和編程功底,在實現(xiàn)過程中肯定還有沒有注意到的地方甚至是缺陷,希望能夠得到大家的指正。
在我們所實現(xiàn)的內存檢測子系統(tǒng)基礎上,可以繼續(xù)搭建內存分配優(yōu)化子系統(tǒng),從而形成一個完整的內存子系統(tǒng)。一種內存分配優(yōu)化子系統(tǒng)的實現(xiàn)方案是一次性分配大塊的內存,并使用特定的數(shù)據(jù)結構管理之,當內存分配請求到來時,使用特定算法從這塊大內存中劃定所需的一塊給用戶使用,而用戶使用完畢,在將其劃為空閑內存。這種內存優(yōu)化方式將內存分配釋放轉換為簡單的數(shù)據(jù)處理,極大的減少了內存申請和釋放所耗費的時間。
評論
查看更多