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