遊戲設計模式——內存池管理
前言:對C++遊戲程序員來說,內存管理是一件相當頭疼的問題。因為C++是將內存赤裸裸的交給程序員,而不像Java/C#有gc機制。
好處是這樣對於高性能要求的遊戲程序,原生的內存分配可以避免gc機制的臃腫操作,從而大大提高性能。
壞處是C++程序員得時時警惕內存問題:
內存泄露問題
do{
T* object = new T();
}while(0);
上面的例子中。忘記回收內存,函數退棧導致丟失了object指針,就再也找回不了new的內存地址,這時內存一直就會被占用著。
內存泄漏很容易理解,不作多講。
內存碎片問題
由於對堆內存的分配/釋放的順序是隨機的,導致申請的內存塊隨機分布於原始內存,倘若分布不是連續的(隨機順序往往導致多個內存塊都是相隔開的),那麽便會產生“洞”。
隨著時間推移,堆內存越來越多出現這些“洞”,導致可用的自由內存塊被拆分成多個小內存塊。
這就導致即使有足夠的自由內存,分配請求仍然可能會失敗。
內存頁切換問題
虛擬內存系統把不連續的物理內存塊(即內存頁)映射至虛擬地址空間,使內存頁對於應用程序來說看上去是連續的。
在支持虛擬內存的操作系統上,多次使用原生C/C++內存分配,有可能其中幾次是一個內存頁,幾次是第二個內存頁,又有幾次是第三個內存頁的內容....在重復共同使用這些內存的時候有可能導致昂貴的切換內存頁開銷。
一些本世代遊戲機雖然技術上支持虛擬內存,但由於其導致的開銷,多數遊戲引擎不會使用虛擬內存。
內存池(Memory Pool)
對C++內存分配進行適合當前程序的封裝就顯得尤為重要,這樣C++程序員就能在封裝完內存機制後減少大量心思警惕內存問題。
而內存池是什麽:
預先通過new或者malloc(原生的內存分配函數)分配好一個大塊內存(挖好池子),然後提供這塊內存池的再分配函數。
當程序員需要分配小塊堆內存時,可以向這個內存池請求分配小內存。
- 由於內存池本身往往內存比較大,所以內存池本身的分配釋放不易產生內存碎片。
- 即使程序員由於操作失誤導致內存池內部出現內存碎片或者內存泄漏問題,但是整個內存池本身只要正確釋放,內存問題就不會向外擴張。
- 一次性分配好大內存,盡可能減少了多次分配可能導致的過多物理內存頁,從而減少了切換內存頁開銷。
那麽接下來就是內存池如何再分配內存給程序員使用的問題了:
堆棧分配器 Stack-based Allocators
堆棧分配器,也就是以類似堆棧的形式分配/釋放內存。
它的實現是非常簡單的,只要維護一個頂端指針。指針以下的內存是已分配的,以上的內存是未分配的。
每次需要分配內存,只需將頂端指針移上相應的位移大小。但是它的資源釋放必須得按堆棧的順序退棧回滾,把頂端指針一步步下移。
它的分配/釋放操作是極為高效的,基本上只需簡單地移動頂端指針(其實還有簡單地記錄回滾位置)。
此外為了讓頂端指針正確回滾,再分配內存的時候還得額外分配一個記錄用於記錄回滾的位置。
class StackAllocator{
private:
U32 top; //頂端指針
void* pool; //內存池
public:
//給定總大小,構建一個堆棧分配方式的內存池
StackAllocator(U32 statckSize_bytes);
//從頂端指針分配一個新的內存塊,並記錄新的回滾位置標記
void* alloc(U32 size_bytes);
//從頂端指針回滾到之前的標記位置
void free();
//清空整個堆棧
void clear();
// ...
};
由於它每次分配都得額外記錄了回滾位置,所以相對比較適合 較大內存對象的分配/釋放。
許多遊戲都有裝載/卸載遊戲關卡對象的功能,使用堆棧分配器的內存池往往效果不錯。
適用場景:按堆棧順序分配&釋放的對象。
此外部分遊戲引擎使用的是 雙端堆棧分配器(Double-ended Stack),這樣可以從兩端入棧退棧資源:
一端用於加載及卸載遊戲關卡內存,另一端用於分配臨時內存塊。
單幀和雙緩沖內存分配 Single Frame Memory & Double-buffered Frame Memory
單幀內存分配器:分配內存僅在當前幀有效。
雙緩沖內存分配器:分配內存可在本幀/下一幀(兩幀)有效。
需要分配只在當前幀(或兩幀內) 有效的臨時對象時,單幀和雙緩沖內存分配器是不二之選。因為使用它們,你可以不用在意對象的內存釋放問題:
它們會在一幀後簡單地將內存池頂端指針重新指向內存塊的起始地址,這樣就能極為高效地每幀清理這些內存。
//單幀內存分配
class SingleFrameAllocator{
private:
StackAllocator mStack; //一個堆棧分配的內存池
public:
//給定總大小,構建一個單幀分配的內存池
SingleFrameAllocator(U32 statckSize_bytes);
//從底層的堆棧內存分配池中分配一個新的內存塊
void* alloc(U32 size_bytes);
//遊戲循環每幀需調用該函數用於清空堆棧內存池
void clear();
//單幀內存分配沒有也不需要單獨釋放內存的函數
//void free();
// ...
};
//雙緩沖內存分配
class DoubleBufferedAllocator{
private:
U32 mCurStack; //mCurStack值應總是為0或1,通過邏輯取反來切換
StackAllocator mStack[2]; //兩個堆棧分配的內存池
public:
//給定總大小,構建兩個堆棧分配方式的內存池
DoubleBufferedAllocator(U32 statckSize_bytes);
//從當前堆棧內存池分配一個新的內存塊
void* alloc(U32 size_bytes);
//遊戲循環每幀需調用該函數用於清空另一個堆棧內存池,並且切換mCurStack
void clear();
//雙緩沖內存分配沒有也不需要單獨釋放內存的函數
//void free();
// ...
};
適用場景:需要分配只在當前幀(或兩幀內) 有效的臨時對象。
對象池 Object Pool
對象池,是一個存放內存相同大小對象結構的內存池。
例如粒子對象池存放同種粒子對象,怪物對象池存放同種怪物對象...
template<class T>
class ObjectPool{
private:
U32 top; //頂端指針索引
std::vector<U32> freeMarks;//存儲已釋放的索引
T* pool; //內存池
public:
//給定總大小,構建一個對象池
ObjectPool(U32 statckSize_bytes);
//先從freeMarks查找已釋放空閑的內存塊
//若無空閑,則從頂端指針分配一個新的對象內存塊,上移頂端指針
T* alloc();
//通過指針釋放對應的對象內存塊,再添加已釋放索引到freeMarks
void free(T* ptr);
//清空整個對象池
void clear();
// ...
};
對於遍歷同種對象列表,對象池更加容易命中cpu緩存。
另外在遊戲引擎裏,每幀都要進行同種組件遍歷更新,所以說組件比較適合用對象池存儲。
類似的在遊戲邏輯裏,還有大量同種類怪物都很適合用對象池來存儲。
適用場景:需要分配較多同類型的對象。
可整理碎片的內存池
若要分配/釋放不同大小的對象(不可用對象池),而且生命周期還不止一兩幀(不可單幀和雙緩沖內存分配器),而且還是隨機次序進行(不可堆棧分配器)。
那麽可以考慮實現可整理內存碎片的功能。
重定向指針
若使用可整理碎片的內存池,一般分配函數應該返還一個封裝好的智能指針(即指向一個原生指針的指針)。這樣當移動復制內存的時候,給智能指針裏指向新復制好的內存地址。
不過,需要註意的是,這種智能指針的調用會有兩次指針跳轉的開銷。
分攤碎片整理成本
碎片整理還有個比較苦惱開銷較大的操作:復制移動內存塊。
所以為了避免一次性大開銷(容易造成卡頓),我們無需一次性將所有碎片全部整理,可以將該成本平均分攤至N幀完成。
例如可以設定一幀最多可以進行K次內存塊移動(通常是個小數目),這樣可以預計大概若幹幀便可以把所有碎片全部整理完,而且也不會對遊戲造成卡頓的影響(畢竟開銷平攤給每幀)。
頑皮狗的引擎中,重定向整理碎片的內存池只應用於遊戲對象上,而遊戲對象一般很小,從不會超過數千字節。
適用場景:不適用對象池/單幀雙幀/堆棧分配的對象,並且整個內存池允許數據總量應該偏小,因為碎片整理是需要付出一定代價的。
額外
- 盡量使用棧內存:這樣就可以盡量把內存交給棧管理,而無需考慮堆內存分配的各種問題。
- 慎用STL的智能指針:其使用效率一般不如自定義的好,而且也相對上面自定義內存機制來說更易引發內存碎片問題。若一定要使用,請保證你深入了解STL智能指針並且審慎對待。
各種分配方式的內存池是可以且推薦 嵌套使用的。
例如一種可行的內存分配搭配方式是:
雙端堆棧分配器作為程序裏最大的內存池,它一端分配單幀或雙緩沖內存池用於存放幀內臨時變量,另一端分配另一個堆棧分配池,該堆棧分配池有含有對象池。
遊戲設計模式系列-其他文章:https://www.cnblogs.com/KillerAery/category/1307176.html
遊戲設計模式——內存池管理