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

2017-01-16    分類:C/C++開發、程式設計開發、首頁精華0人評論發表於2017-01-16

本文由碼農網 – 蘇文鵬原創翻譯,轉載請看清文末的轉載要求,歡迎參與我們的付費投稿計劃

簡介

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

即使不是執行關鍵操作的系統,一些嵌入式系統也需要被設計成需要執行數週甚至數年而不重啟。取決於記憶體分配的模式和堆記憶體的實現方式,長時間的使用堆記憶體可能導致堆記憶體錯誤。

典型的解決方案是預先靜態宣告所有物件的記憶體,從而擺脫動態申請記憶體。然而,由於物件即使沒有被使用,也已經存在並佔據一部分記憶體,靜態分配記憶體的方式會浪費記憶體儲存。此外,使用動態記憶體分配方式實現的系統提供更為自然的設計框架,而不像靜態記憶體分配需要事先分配所有物件。

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

這種分配器的實現具有如下特點:

  • 比全域性堆記憶體速度快
  • 消除堆記憶體碎片錯誤
  • 不需要額外的記憶體儲存(除了需要幾個位元組的靜態記憶體)
  • 易於使用
  • 程式碼量很小

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

閱讀完此文後,請同時閱讀Replace malloc/free with a Fast Fixed Block Memory Allocator,檢視如何使用分配器Allocator替代CRT(C/C++ Runtime Library)。

回收記憶體儲存

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

當應用程式使用Allocator類進行刪除時,物件佔用的記憶體空間被釋放以備重用,但卻不會立即釋放給記憶體管理器,這些記憶體保留在就一個稱之為“釋放列表”的連結串列中,並再次分配給相同型別的物件。對每個記憶體分配的請求,Allocaor類首先檢查“釋放列表”中是否存在待釋放的記憶體。只有“釋放列表”中沒有可用的記憶體空間時才會分配新的記憶體。根據所需的Allocator類的行為,記憶體儲存以三種操作模式使用全域性堆記憶體或者靜態記憶體池。

  • 1.堆記憶體
  • 2.堆記憶體池
  • 3.靜態記憶體池

堆記憶體 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<MyClass, 20> 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大小的記憶體塊來測試分配和釋放記憶體的速度。測試的演算法詳見附件中的程式碼。

Allocator Mode Run Benchmark Time (mS)
Global Heap Debug Heap 1 1640
Global Heap Debug Heap 2 1864
Global Heap Debug Heap 3 1855
Global Heap Release Heap 1 55
Global Heap Release Heap 2 47
Global Heap Release Heap 3 47
Allocator Static Pool 1 19
Allocator Static Pool 2 7
Allocator Static Pool 3 7
Allocator Heap Blocks 1 30
Allocator Heap Blocks 2 7
Allocator Heap Blocks 3 7

使用除錯模式執行時,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位元組。下面是結果:

Allocator Mode Run Benchmark Time (mS)
Global Heap Release 1 11.6
Global Heap Release 2 11.6
Global Heap Release 3 11.6
Allocator Static Pool 1 0.85
Allocator Static Pool 2 0.79
Allocator Static Pool 3 0.79
Allocator Heap Blocks 1 1.19
Allocator Heap Blocks 2 0.79
Allocator Heap Blocks 3 0.79

基於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函式沒返回,例如無盡的迴圈或者斷言,呼叫這個函式不起任何作用。使用固定記憶體池時這無濟於事。

進一步閱讀

請閱讀相關文章:使用快速固定大小分配器替換malloc/free來檢視如何使用Allocator更為快速的替換C++執行工具中的malloc和free函式。

下載原始碼:Download Allocator.zip – 5.4 KB

譯文連結:http://www.codeceo.com/article/efficient-cpp-memory-allocator.html
英文原文:An Efficient C++ Fixed Block Memory Allocator
翻譯作者:碼農網 – 蘇文鵬
轉載必須在正文中標註並保留原文連結、譯文連結和譯者等資訊。]

相關文章