如何在 Linux 下檢測記憶體洩漏

bluepeach發表於2021-09-09

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 執行過程中的記憶體操作資訊。下面列出的就是過載形式

我們為 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 中定義:

而後在所有需要使用記憶體檢測的客戶程式的所有的 cpp 檔案的開頭加入

就可以將客戶原始檔中的對於全域性預設的 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 這個記憶體檢測功能,畢竟記憶體洩漏的檢測應該在程式的除錯和測試階段完成。我們可以使用條件編譯的特性,在使用者被檢測檔案中使用如下巨集定義:

當使用者需要使用記憶體檢測時,可以使用如下命令對被檢測檔案進行編譯

就可以 enable 記憶體檢測功能,而使用者程式正式釋出時,可以去掉 -DMEM_DEBUG 編譯開關來 disable 記憶體檢測功能,消除記憶體檢測帶來的效率影響。

圖2所示為使用記憶體檢測功能後,記憶體洩漏程式碼的執行以及檢測結果

如何在 Linux 下檢測記憶體洩漏
圖2

4.錯誤方式刪除帶來的問題

以上我們已經構建了一個具備基本記憶體洩漏檢測功能的子系統,下面讓我們來看一下關於記憶體洩漏方面的一些稍微高階一點的話題。

首先,在我們編制 c++ 應用時,有時需要在堆上建立單個物件,有時則需要建立物件的陣列。關於 new 和 delete 原理的敘述我們可以知道,對於單個物件和物件陣列來說,記憶體分配和刪除的動作是大不相同的,我們應該總是正確的使用彼此搭配的 new 和 delete 形式。但是在某些情況下,我們很容易犯錯誤,比如如下程式碼:

不匹配的 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)所記錄的資料中找到相應的記憶體分配資訊。

如何在 Linux 下檢測記憶體洩漏
圖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)。

而後,在我們的檢測子系統的標頭檔案中定義瞭如下形式的 DEBUG_DELETE

在使用者被檢測檔案中原來的巨集定義中新增一條:

這樣,在使用者被檢測檔案呼叫 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 程式,它所監視的程式的動態記憶體分配情況如圖所示:

如何在 Linux 下檢測記憶體洩漏
圖四

在支援 MemSnapShot 過程中的實現上的唯一技巧是–對於被檢測程式異常退出狀況的處理。因為被檢測程式中的記憶體檢測子系統建立了用於程式間傳輸資料的訊息佇列,它是一個核心資源,其生命週期與核心相同,一旦建立,除非顯式的進行刪除或系統重啟,否則將不被釋放。

不錯,我們可以在記憶體檢測子系統中的全域性物件(appMemory)的解構函式中完成對訊息佇列的刪除,但是如果被檢測程式非正常退出(CTRL+C,段錯誤崩潰等),訊息佇列可就沒人管了。那麼我們可以不可以在全域性物件(appMemory)的建構函式中使用 signal 系統呼叫註冊 SIGINT,SIGSEGV 等系統訊號處理函式,並在處理函式中刪除訊息佇列呢?還是不行,因為被檢測程式完全有可能註冊自己的對應的訊號處理函式,這樣就會替換我們的訊號處理函式。最終我們採取的方法是利用 fork 產生一個孤兒程式,並利用這個程式監視被檢測程式的生存狀況,如果被檢測程式已經退出(無論正常退出還是異常退出),則試圖刪除被檢測程式所建立的訊息佇列。下面簡述其實現原理:

在全域性物件(appMemory)建構函式中,建立訊息佇列成功以後,我們呼叫 fork 建立一個子程式,而後該子程式再次呼叫 fork 建立孫子程式,並退出,從而使孫子程式變為一個”孤兒”程式(之所以使用孤兒程式是因為我們需要切斷被檢測程式與我們建立的程式之間的訊號聯絡)。孫子程式利用父程式(被檢測程式)的全域性物件(appMemory)得到其 PID 和剛剛建立的訊息佇列的標識,並傳遞給呼叫 exec 函式產生的一個新的程式映象–MemCleaner。

MemCleaner 程式僅僅呼叫 kill(pid, 0);函式來檢視被檢測程式的生存狀態,如果被檢測程式不存在了(正常或者異常退出),則 kill 函式返回非 0 值,此時我們就動手清除可能存在的訊息佇列。

6.實現上的問題:巢狀delete

在”錯誤方式刪除帶來的問題”一節中,我們對 delete operator 動了個小手術–增加了兩個全域性變數(DELETE_FILE,DELETE_LINE)用於記錄本次 delete 操作所在的檔名和行號,並且為了同步對全域性變數(DELETE_FILE,DELETE_LINE)的訪問,增加了一個全域性的互斥鎖。在一開始,我們使用的是 pthread_mutex_t,但是在測試中,我們發現 pthread_mutex_t 在本應用環境中的侷限性。

例如如下程式碼:

在上述程式碼中,main 函式中的一句 delete pA 我們稱之為”巢狀刪除”,即我們 delete A 物件的時候,在A物件的析構執行了另一個 delete B 的動作。當使用者使用我們的記憶體檢測子系統時,delete pA 的動作應轉化為以下動作:

在這一過程中,有兩個技術問題,一個是 mutex 的可重入問題,一個是巢狀刪除時 對全域性變數(DELETE_FILE,DELETE_LINE)現場保護的問題。

所謂 mutex 的可重入問題,是指在同一個執行緒上下文中,連續對同一個 mutex 呼叫了多次 lock,然後連續呼叫了多次 unlock。這就是說我們的應用方式要求互斥鎖有如下特性:

1. 要求在同一個執行緒上下文中,能夠多次持有同一個互斥體。並且只有在同一執行緒上下文中呼叫相同次數的 unlock 才能放棄對互斥體的佔有。

2. 對於不同執行緒上下文持有互斥體的企圖,同一時間只有一個執行緒能夠持有互斥體,並且只有在其釋放互斥體之後,其他執行緒才能持有該互斥體。

Pthread_mutex_t 互斥體不具有以上特性,即使在同一上下文中,第二次呼叫 pthread_mutex_lock 將會掛起。因此,我們必須實現出自己的互斥體。在這裡我們使用 semaphore 的特性實現了一個符合上述特性描述的互斥體 CCommonMutex(原始碼見附件)。

為了支援特性 2,在這個 CCommonMutex 類中,封裝了一個 semaphore,並在建構函式中令其資源值為 1,初始值為1。當呼叫 CCommonMutex::lock 介面時,呼叫 sem_wait 得到 semaphore,使訊號量的資源為 0 從而讓其他呼叫 lock 介面的執行緒掛起。當呼叫介面 CCommonMutex::unlock 時,呼叫 sem_post 使訊號量資源恢復為 1,讓其他掛起的執行緒中的一個持有訊號量。

同時為了支援特性 1,在這個 CCommonMutex 增加了對於當前執行緒 pid 的判斷和當前執行緒訪問計數。當執行緒第一次呼叫 lock 介面時,我們呼叫 sem_wait 的同時,記錄當前的 Pid 到成員變數 m_pid,並置訪問計數為 1,同一執行緒(m_pid == getpid())其後的多次呼叫將只進行計數而不掛起。當呼叫 unlock 介面時,如果計數不為 1,則只需遞減訪問計數,直到遞減訪問計數為 1 才進行清除 pid、呼叫 sem_post。(具體程式碼可見附件)

巢狀刪除時對全域性變數(DELETE_FILE,DELETE_LINE)現場保護的問題是指,上述步驟中在 A 的解構函式中呼叫 delete m_pB 時,對全域性變數(DELETE_FILE,DELETE_LINE)檔名和行號的賦值將覆蓋主程式中呼叫 delete pA 時對全域性變數(DELETE_FILE,DELETE_LINE)的賦值,造成了在執行 operator delete A 時,delete pA 的資訊全部丟失。

要想對這些全域性資訊進行現場保護,最好用的就是堆疊了,在這裡我們使用了 STL 提供的 stack 容器。在 DEBUG_DELETE 巨集定義中,對全域性變數(DELETE_FILE,DELETE_LINE)賦值之前,我們先判斷是否前面已經有人對他們賦過值了–觀察行號變數是否等於 0,如果不為 0,則應該將已有的資訊壓棧(呼叫一個全域性函式 BuildStack() 將當前的全域性檔名和行號資料壓入一個全域性堆疊globalStack),而後再對全域性變數(DELETE_FILE,DELETE_LINE)賦值,再呼叫 delete operator。而在記憶體子系統的全域性物件(appMemory)提供的 erase 介面裡面,如果判斷傳入的檔名和行號為 0,則說明我們所需要的資料有可能被巢狀刪除覆蓋了,所以需要從堆疊中彈出相應的資料進行處理。

現在巢狀刪除中的問題基本解決了,但是當巢狀刪除與 “錯誤方式刪除帶來的問題”一節的最後所描述的第一和第三種異常情況同時出現的時候,由於使用者的 delete 呼叫沒有通過我們定義的 DEBUG_DELETE 巨集,上述機制可能出現問題。其根本原因是我們利用stack 保留了經由我們的 DEBUG_DELETE 巨集記錄的 delete 資訊的現場,以便在 operator delete 和全域性物件(appMemory)的 erase 介面中使用,但是使用者的沒經過 DEBUG_DELETE 巨集的 delete 操作卻未曾進行壓棧操作而直接呼叫了 operator delete,有可能將不屬於這次操作的 delete 資訊彈出,破壞了堆疊資訊的順序和有效性。那麼,當我們因為無法找到這次及其後續的 delete 操作所對應的記憶體分配資訊的時候,可能會列印出錯誤的 warning 資訊。

展望

以上就是我們所實現的記憶體洩漏檢測子系統的原理和技術方案,第一版的原始碼在附件中,已經經過了較嚴格的系統測試。但是限於我們的 C++ 知識水平和程式設計功底,在實現過程中肯定還有沒有注意到的地方甚至是缺陷,希望能夠得到大家的指正,我的 email 是 hcode@21cn.com

在我們所實現的記憶體檢測子系統基礎上,可以繼續搭建記憶體分配優化子系統,從而形成一個完整的記憶體子系統。一種記憶體分配優化子系統的實現方案是一次性分配大塊的記憶體,並使用特定的資料結構管理之,當記憶體分配請求到來時,使用特定演算法從這塊大記憶體中劃定所需的一塊給使用者使用,而使用者使用完畢,在將其劃為空閒記憶體。這種記憶體優化方式將記憶體分配釋放轉換為簡單的資料處理,極大的減少了記憶體申請和釋放所耗費的時間。

相關文章