天天看點

一種高效的 C++ 固定記憶體塊配置設定器

 自定義固定記憶體塊配置設定器用于解決兩種類型的記憶體問題。第一,全局堆記憶體的配置設定和釋放非常慢而且是不确定的。你不能确定記憶體管理需要消耗多長時間。第二,降低由堆記憶體碎片(對于執行關鍵操作的系統尤為重要)造成的記憶體配置設定失敗的可能性。

即使不是執行關鍵操作的系統,一些嵌入式系統也需要被設計成需要運作數周甚至數年而不重新開機。取決于記憶體配置設定的模式和堆記憶體的實作方式,長時間的使用堆記憶體可能導緻堆記憶體錯誤。

典型的解決方案是預先靜态聲明所有對象的記憶體,進而擺脫動态申請記憶體。然而,由于對象即使沒有被使用,也已經存在并占據一部分記憶體,靜态配置設定記憶體的方式會浪費記憶體存儲。此外,使用動态記憶體配置設定方式實作的系統提供更為自然的設計架構,而不像靜态記憶體配置設定需要事先配置設定所有對象。

固定記憶體塊配置設定器并不是一種新的方法。人們已經設計過多種自定義記憶體配置設定器很長時間了。這裡,我提供的是我在很多項目中成功使用的,一種簡單的c++記憶體配置設定器的實作。

這種配置設定器的實作具有如下特點:

比全局堆記憶體速度快

消除堆記憶體碎片錯誤

不需要額外的記憶體存儲(除了需要幾個位元組的靜态記憶體)

易于使用

代碼量很小

這裡将提供一個申請、釋放記憶體的,包含上面所提到特點的簡單類。

回收記憶體存儲

記憶體管理模式的基本哲學是在對象記憶體配置設定時能夠回收記憶體。一旦在記憶體中建立了一個對象,它所占用的記憶體就不能被重新配置設定。同時,記憶體要能夠回收,允許相同類型的對象重用這部分記憶體。我實作了一個名為allocator的類來展示這些技巧。

當應用程式使用allocator類進行删除時,對象占用的記憶體空間被釋放以備重用,但卻不會立即釋放給記憶體管理器,這些記憶體保留在就一個稱之為“釋放清單”的連結清單中,并再次配置設定給相同類型的對象。對每個記憶體配置設定的請求,allocaor類首先檢查“釋放清單”中是否存在待釋放的記憶體。隻有“釋放清單”中沒有可用的記憶體空間時才會配置設定新的記憶體。根據所需的allocator類的行為,記憶體存儲以三種操作模式使用全局堆記憶體或者靜态記憶體池。

堆記憶體

堆記憶體池

靜态記憶體池

堆記憶體 vs. 記憶體池

allocator類在“釋放清單”為空時,能夠從堆記憶體或者記憶體池中申請新記憶體。如果使用記憶體池,你必須事先确定好對象的數量。確定記憶體池足夠容納所有需要使用的對象。另一方面,使用堆記憶體沒有數量大小的限制——可以構造記憶體允許的盡可能多的對象。

堆記憶體模式在全局堆記憶體上為對象配置設定記憶體。釋放操作将這塊記憶體放入“釋放了清單”以備重用。當“釋放清單”為空時,需要在堆記憶體上建立新記憶體。這種方式提供了動态記憶體的配置設定和釋放,優點是記憶體塊可以在運作時動态增加,缺點是記憶體塊建立期間是不确定的,可能建立失敗。

堆記憶體池模式從全局堆記憶體建立一個記憶體池。當allocator類對象建立時,使用new操作符建立記憶體池。然後使用記憶體池中的記憶體塊進行記憶體配置設定。

靜态記憶體池模式使用從靜态記憶體中配置設定的記憶體池。靜态記憶體池由使用者進行配置設定而不是由allocator對象進行建立。

堆記憶體池模式和靜态記憶體池模式提供了記憶體操作的連續使用,因為記憶體配置設定器不需要配置設定單獨的記憶體塊。這樣配置設定記憶體的過程是十分快速且具有确定性的。

類設計

類的接口很簡單。allocate()傳回指向記憶體塊的指針,deallocate()釋放記憶體以備重用。構造函數需要設定對象的大小,并且如果使用記憶體池,需要配置設定記憶體池空間。

類的構造函數中的參數用于決定記憶體塊配置設定的位置。size參數控制固定記憶體塊的大小。objects參數設定申請記憶體塊的個數,其值為0表示從堆記憶體中申請新記憶體塊,非0表示使用記憶體池方式(堆記憶體池或者靜态記憶體池)配置設定對象執行個體空間。memory參數是指向靜态記憶體的指針。如果memory等于0并且objects非零,allocator将從堆記憶體中建立一個記憶體池。靜态記憶體池記憶體大小必須是size*object位元組。name參數為記憶體配置設定器命名,用于收集配置設定器使用資訊。

class allocator  

{  

public:  

allocator(size_t size, uint objects=0, char* memory=null, const char* name=null);  

... 

下面的例子展示三種配置設定器模式中的構造函數是如何指派的。

// heap blocks mode with unlimited 100 byte blocks  

allocator allocatorheapblocks(100);  

// heap pool mode with 20, 100 byte blocks  

allocator allocatorheappool(100, 20);  

// static pool mode with 20, 100 byte blocks  

char staticmemorypool[100 * 20];  

allocator allocatorstaticpool(100, 20, staticmemorypool); 

為了簡化靜态記憶體池方法,提供allocatorpool<>模闆類。模闆的第一個參數設定申請記憶體對象類型,第二個參數設定申請對象的數量。

// static pool mode with 20 myclass sized blocks  

allocatorpool allocatorstaticpool2; 

deallocate()将記憶體位址放入“棧”中。這個“棧”的實作方式類似于單項連結清單(“釋放清單”),但是隻能添加、移除頭部的對象,其行為類似棧的特性。使用“棧”使得配置設定、釋放操作更為快速,因為不需要全連結清單周遊而隻需要壓入和彈出操作。

void* memory1 = allocatorheapblocks.allocate(100); 

這樣便在不增加額外存儲的情況下,将記憶體塊連結在“釋放清單”中。例如,當我們使用全局operate new時,首先申請記憶體,然後調用構造函數。delete的過程與此相反,首先調用析構函數,然後釋放掉記憶體。調用完析構函數後,在記憶體釋放給堆之前,這塊記憶體不再被原有的對象使用,而是放到“釋放清單”中以備重用。由于allocator類需要儲存已經釋放的記憶體塊,在使用delete操作符時,我們将“釋放清單”中的下一個指針指向這個被delete的對象記憶體位址。當應用程式再次使用這塊記憶體時,指針被覆寫為對象的位址。通過這種方法,就不需要預先執行個體化記憶體空間。

使用釋放對象的記憶體來将記憶體塊連接配接在一起意味着對象的記憶體空間需要足夠容納一個指針占用記憶體空間的大小。構造函數初始化清單中的代碼保證了最小記憶體塊大小不會小于指針占用記憶體塊的大小。

類的析構函數通過釋放堆記憶體池或者周遊“釋放清單”并逐個釋放記憶體塊來實作記憶體的釋放。由于allocator類對象常被用作是static的,那麼allocator對象的釋放是在程式結束時。對于大多數嵌入式裝置,應用隻在人們拔斷電源時才會結束。是以,對于這種嵌入式裝置,析構函數的作用就顯無所謂了。

如果使用堆記憶體塊模式,除非所有配置設定的記憶體被連結在“釋放清單”,應用結束時配置設定的記憶體塊不能被釋放。是以,所有對象應該在程式結束時被“删除”(指放入“釋放清單”)。這似乎是記憶體洩漏,也帶來了一個有趣的問題。allocator應該跟蹤正在使用和已經釋放的記憶體塊嗎?答案是否定的。以為一旦一塊記憶體通過指針被應用所使用,那麼應用程式有責任在程式結束前通過調用deallocate()傳回該記憶體塊指針給allocator。這樣的話,我麼隻需要跟蹤釋放的記憶體塊。

代碼的使用

allocator易于使用,是以建立宏來自動在用戶端類中實作接口。宏提供一個靜态類型的allocator執行個體和兩個成員函數:操作符new和操作符delete。通過重寫new和delete操作符,allocator截取并處理所有的用戶端類的記憶體配置設定行為。

declare_allocator宏提供頭檔案接口,并且應該在類定義時将其包含在内,如下面這樣:

#include "allocator.h"  

class myclass  

declare_allocator  

// remaining class definition  

}; 

操作符new函數調用allocator建立類執行個體所需要的記憶體空間。記憶體配置設定後,根據定義,操作符new調用該類的構造函數。重寫的new隻修改了記憶體的配置設定任務。構造函數的調用由語言保證。删除對象時,系統首先調用析構函數,然後調用執行操作符delete函數。操作符delete使用deallocate()函數将記憶體塊加入到“釋放清單”中。

盡管沒有明确聲明,操作符delete是靜态函數(靜态函數才能調用靜态成員)。是以它不能被聲明為virtual。這樣看上去通過基類的指針删除對象不能達到删除真實對象的目的。畢竟,調用基類指針的靜态函數隻會調用基類的成員函數,而不是其真實類型的成員函數。然而,我們知道,調用操作符delete時首先調用析構函數。修飾為virtual的析構函數會實際調用子類的析構函數。類的析構函數執行完後,子類的操作符delete函數被調用。是以實際上,由于虛析構函數的調用,重寫的操作符delete會在子類中調用。是以,使用基類指針删除對象時,基類對象的析構函數必須聲明為virtual。否則,将會不能正确調用析構函數和操作符delete。

implement_allocator宏是接口的源檔案實作部分,并應該放置于源檔案中。

implement_allocator(myclass, 0, 0) 

使用上述宏後,可以如下面一樣建立并銷毀類的執行個體,同僚循環使用釋放的記憶體空間。

myclass* myclass = new myclass();  

delete myclass; 

allocator類支援單繼承和多繼承。例如,derived類繼承base類,如下代碼是正确的。

base* base = new derived;  

delete base; 

運作時

運作時,allocator初始化時“釋放清單”中沒有可重用的記憶體塊。是以,第一次調用allocate()将從記憶體池或者堆中擷取記憶體空間。随着程式的執行,系統不斷使用對象會造成配置設定器的波動。并且隻有當釋放清單無法提供記憶體時,新記憶體才會被申請和建立。最終,系統使用對象的執行個體會固定,是以每次記憶體配置設定将會使用已經存在的記憶體空間二不是再從記憶體池或者堆中申請。

與使用記憶體管理器配置設定所有對象記憶體相比,allocator配置設定器更加高效。記憶體配置設定時,記憶體指針僅僅是從“釋放清單”中彈出,速度非常快。記憶體釋放時也僅僅是将記憶體指針放入到“釋放清單”當中,速度也十分快。

基準測試

在windows pc上使用allocator和全局堆記憶體的對比性能測試顯示出allocator的高性能。測試配置設定和釋放20000個4096和2048大小的記憶體塊來測試配置設定和釋放記憶體的速度。測試的算法詳見附件中的代碼。

一種高效的 C++ 固定記憶體塊配置設定器

使用調試模式執行時,windows使用調試堆記憶體。調試堆記憶體添加額外的安全檢查降低了性能。釋出堆記憶體性能更好,因為不使用安全檢查。通過在visual studio工程選項中,設定【調試】-【環境】中_no_debug_heap=1來禁止調試記憶體模式。

全局調試堆記憶體模式需要平均1.8秒,是最慢的。釋放對記憶體模式50毫秒左右,稍快。基準測試的場景非常簡單,實際情況下,不同大小的記憶體塊和随機的申請、釋放可能産生不同的結果。然而,最簡單的也最能說明問題。記憶體管理器比allocator記憶體配置設定器慢,并且很大程度上依賴于平台的實作能力。

記憶體配置設定器allocator使用靜态記憶體模式不依賴于堆記憶體的配置設定。一旦“釋放清單”中含有記憶體塊後,其執行時間大約為7毫秒。第一次耗時19毫秒用于将記憶體池中的記憶體防止到allocator配置設定器中管理。

aloocator使用堆記憶體模式時,當“釋放清單”中有可重用的記憶體後,其速度與靜态記憶體模式一樣快。堆記憶體模式依賴于全局堆來擷取記憶體塊,但是循環利用“釋放清單”中的記憶體。第一次需要申請堆記憶體,耗時30毫秒。由于重用“釋放清單”中的記憶體,之後的申請僅需要7毫秒。

上面的基準測試結果表示,allocator記憶體配置設定器更加高效,擁有7倍于windows全局釋出堆記憶體模式的速度。

對于嵌入式系統,我使用keil在arm stm32f4 cpu(168hz)上運作相同測試。由于資源限制,我将最大記憶體塊數量降低到500,單個記憶體塊大小降低到32和16位元組。下面是結果:

一種高效的 C++ 固定記憶體塊配置設定器

基于arm的基準測試顯示,使用allocator配置設定器的類性能快15倍。這個結果會讓keil堆記憶體的表現相形見绌。基準測試配置設定500個16位元組大小的記憶體塊進行測試。每個16位元組大小的記憶體删除後申請500個32位元組大小的記憶體塊。全局堆記憶體耗時11.6毫秒,而且,在記憶體碎片化後,記憶體管理器可能會在沒有安全檢查的情況下耗時更大。

配置設定器決議

第一個決定是你是否需要使用配置設定器。如果你的項目不關心執行的速度和是否需要容錯,那麼你可能不需要自定義的配置設定器,全局堆配置設定管理器足夠用了。

另一方面,如果你需要考慮執行速度和容錯管理,配置設定器會起到作用。你需要根據項目的需要選擇配置設定器的模式。重要任務系統的設計可能強制要求使用全局堆記憶體。而動态配置設定記憶體可能更高效,設計更優雅。這種情況下,你可以在調試開發時使用堆記憶體模式擷取記憶體使用參數,然後釋出時切換到靜态記憶體池模式避免記憶體配置設定帶來的性能消耗。一些編譯時的宏可用于模式的切換。

另外,堆記憶體模式可能對應用更适合。該模式利用堆來擷取新記憶體,同時阻止了堆碎片錯誤。當“釋放清單”連結足夠的記憶體塊後更能加快記憶體的配置設定效率。

在源代碼中沒有實作的涉及多線程的問題不在本文的讨論範圍内。運作系統一會後,可以友善地使用getlockcount函數和getname函數擷取記憶體塊數量和名稱。這些度量參數提供關于記憶體配置設定的資訊。盡量多申請點記憶體,以便給配置設定盤一些彈性來避免記憶體耗盡。

調試記憶體洩漏

調試記憶體洩漏非常困難,原因是堆記憶體就像一個黑盒,對于配置設定對象的類型和大小是不可見的。使用allocator,由于allocator跟蹤記錄記憶體塊的總數,記憶體洩漏檢查變得簡單一點。對每個配置設定器執行個體重複輸出(例如輸出到終端)getblockcount和getname并比對它們的不同能讓我們更好的了解配置設定器對記憶體的配置設定。

錯誤處理

c++中使用new_handler函數處理記憶體配置設定錯誤。如果記憶體管理器在申請記憶體時發生錯誤,使用者的錯誤處理函數就會被調用。通過将使用者的錯誤處理函數位址複制給new_handler,記憶體管理器就能調用使用者自定義的錯誤處理程式。為了讓allocator類的錯誤處理機制與記憶體管理器保持一緻,配置設定器也通過new_handler調用錯誤處理函數,集中處理所有的記憶體配置設定錯誤。

static void out_of_memory()  

// new-handler function called by allocator when pool is out of memory  

assert(0);  

}  

int _tmain(int argc, _tchar* argv[])  

std::set_new_handler(out_of_memory);  

限制

配置設定器類不支援數組對象的記憶體配置設定。為每一個對象建立分開的記憶體是無法保證的,因為new的多次調用不保證記憶體塊的連續,但這又是數組所需要的。是以allocator隻支援固定大小記憶體塊的配置設定,對象數組不支援。

移植問題

allocator在靜态記憶體池耗盡時調用new_handle指向的函數,這對于某些系統不合适。假設new_handle函數沒傳回,例如無盡的循環或者斷言,調用這個函數不起任何作用。使用固定記憶體池時這無濟于事。

進一步閱讀

 作者:佚名

來源:51cto