遊戲設計模式——內存池管理


前言:對C++遊戲程序員來講,內存管理是一件至關頭疼的問題。由於C++是將內存赤裸裸的交給程序員,而不像Java/C#有gc機制。程序員

好處是這樣對於高性能要求的遊戲程序,原生的內存分配能夠避免gc機制的臃腫操做,從而大大提升性能。
壞處是C++程序員得時時警戒內存問題:編程

內存泄露問題

do{
  T* object = new T();
}while(0);

上面的例子中。忘記回收內存,函數退棧致使丟失了object指針,就再也找回不了new的內存地址,這時內存一直就會被佔用着。設計模式

內存泄漏很容易理解,不做多講。數組

內存碎片問題

因爲對堆內存的分配/釋放的順序是隨機的,致使申請的內存塊隨機分佈於原始內存,假若分佈不是連續的(隨機順序每每致使多個內存塊都是相隔開的),那麼便會產生「洞」。緩存

隨着時間推移,堆內存愈來愈多出現這些「洞」,致使可用的自由內存塊被拆分紅多個小內存塊。
這就致使即便有足夠的自由內存,分配請求仍然可能會失敗。架構

內存頁切換問題

虛擬內存系統把不連續的物理內存塊(即內存頁)映射至虛擬地址空間,使內存頁對於應用程序來講看上去是連續的。
在支持虛擬內存的操做系統上,屢次使用原生C/C++內存分配,有可能其中幾回是一個內存頁,幾回是第二個內存頁,又有幾回是第三個內存頁的內容....在重複共同使用這些內存的時候有可能致使昂貴的切換內存頁開銷。函數

一些本世代遊戲機雖然技術上支持虛擬內存,但因爲其致使的開銷,多數遊戲引擎不會使用虛擬內存。性能

內存池(Memory Pool)

對C++內存分配進行適合當前程序的封裝就顯得尤其重要,這樣C++程序員就能在封裝完內存機制後減小大量心思警戒內存問題。
而如何封裝還能高效的使用內存,就成了一門學問——內存池管理。ui

而內存池是什麼:

預先經過new或者malloc(原生的內存分配函數)分配好一個大塊內存(挖好池子),而後提供這塊內存池的再分配函數。
當程序員須要分配小塊堆內存時,能夠向這個內存池請求分配小內存。

  • 因爲內存池自己每每內存比較大,因此內存池自己的分配釋放不易產生內存碎片。
  • 即便程序員因爲操做失誤致使內存池內部出現內存碎片或者內存泄漏問題,可是整個內存池自己只要正確釋放,內存問題就不會向外擴張。
  • 一次性分配好大內存,儘量減小了屢次使用原生C/C++複雜的內存分配操做開銷(由於相對來講自定義的再分配操做每每開銷很小)。

那麼接下來就是內存池如何再分配內存給程序員使用的問題了:

堆棧分配器 Stack-based Allocators


堆棧分配器,也就是以相似堆棧的形式分配/釋放內存。

它的實現是很是簡單的,只要維護一個頂端指針。指針如下的內存是已分配的,以上的內存是未分配的。
每次須要分配內存,只需將頂端指針移上相應的位移大小。可是它的資源釋放必須得按堆棧的順序退棧回滾,把頂端指針一步步下移。

它的分配/釋放操做是極爲高效的,基本上只需簡單地移動頂端指針(其實還有簡單地記錄回滾位置)。
此外爲了讓頂端指針正確回滾,再分配內存的時候還得額外分配一個記錄用於記錄回滾的位置。

class StackAllocator{
private:
  uint32_t top;     //頂端指針
  void* pool;  //內存池
public:
  //給定總大小,構建一個堆棧分配方式的內存池
  StackAllocator(uint32_t statckSize_bytes);
  //從頂端指針分配一個新的內存塊,並記錄新的回滾位置標記
  void* alloc(uint32_t size_bytes);
  //從頂端指針回滾到以前的標記位置
  void free();
  //清空整個堆棧
  void clear();
  // ...
};

因爲它每次分配都得額外記錄了回滾位置,因此相對比較適合 較大內存對象的分配/釋放。
許多遊戲都有裝載/卸載遊戲關卡對象的功能,使用堆棧分配器的內存池每每效果不錯。

適用場景:按堆棧順序分配&釋放的對象。

此外部分遊戲引擎使用的是 雙端堆棧分配器(Double-ended Stack),這樣能夠從兩端入棧退棧資源:
一端用於加載及卸載遊戲關卡內存,另外一端用於分配臨時內存塊。

單幀和雙緩衝內存分配 Single Frame Memory & Double-buffered Frame Memory


單幀內存分配器:分配內存僅在當前幀有效。
雙緩衝內存分配器:分配內存可在本幀/下一幀(兩幀)有效。

須要分配只在當前幀(或兩幀內) 有效的臨時對象時,單幀和雙緩衝內存分配器是不二之選。由於使用它們,你能夠不用在乎對象的內存釋放問題:
它們會在一幀後簡單地將內存池頂端指針從新指向內存塊的起始地址,這樣就能極爲高效地每幀清理這些內存。

//單幀內存分配
class SingleFrameAllocator{
private:
  StackAllocator mStack;  //一個堆棧分配的內存池
public:
  //給定總大小,構建一個單幀分配的內存池
  SingleFrameAllocator(uint32_t statckSize_bytes);
  //從底層的堆棧內存分配池中分配一個新的內存塊
  void* alloc(uint32_t size_bytes);
  //遊戲循環每幀需調用該函數用於清空堆棧內存池
  void clear();
  //單幀內存分配沒有也不須要單獨釋放內存的函數
  //void free();
  // ...
};
//雙緩衝內存分配
class DoubleBufferedAllocator{
private:
  uint32_t mCurStack;            //mCurStack值應老是爲0或1,經過邏輯取反來切換
  StackAllocator mStack[2]; //兩個堆棧分配的內存池
public:
  //給定總大小,構建兩個堆棧分配方式的內存池
  DoubleBufferedAllocator(uint32_t statckSize_bytes);
  //從當前堆棧內存池分配一個新的內存塊
  void* alloc(uint32_t size_bytes);
  //遊戲循環每幀需調用該函數用於清空另外一個堆棧內存池,而且切換mCurStack
  void clear();
  //雙緩衝內存分配沒有也不須要單獨釋放內存的函數
  //void free();
  // ...
};

適用場景:須要分配只在當前幀(或兩幀內) 有效的臨時對象。

對象池 Object Pool


對象池,是一個存放內存相同大小對象結構的內存池。
例如粒子對象池存放同種粒子對象,怪物對象池存放同種怪物對象...

template<class T>
class ObjectPool{
private:
  uint32_t top;  //頂端指針索引
  std::vector<uint32_t> freeMarks;//存儲已釋放的索引
  T* pool;  //內存池
public:
  //給定總大小,構建一個對象池
  ObjectPool(uint32_t statckSize_bytes);
  //先從freeMarks查找已釋放空閒的內存塊
  //若無空閒,則從頂端指針分配一個新的對象內存塊,上移頂端指針
  T* alloc();
  //經過指針釋放對應的對象內存塊,再添加已釋放索引到freeMarks
  void free(T* ptr);
  //清空整個對象池
  void clear();
  // ...
};

對於遍歷同種對象列表,對象池更加容易命中cpu緩存。

另外在遊戲引擎裏,每幀都要進行同種組件遍歷更新,因此說組件比較適合用對象池存儲。
相似的在遊戲邏輯裏,還有大量同種類怪物都很適合用對象池來存儲。

適用場景:須要分配較多同類型的對象。

前些天,有人問我「對象池如何高效的釋放」。
這裏我就提一下兩個作法:

  1. Lazy Delete技巧:使用一個數組存放釋放索引標記(或者使用其餘方法標記釋放區域)。如上面給的代碼示例,讓對象釋放,其實只是讓目標對象內存塊標記爲已釋放,當下次須要分配新的對象,則能夠在這些標記對應的內存塊區域進行覆蓋。
  2. 移動尾部對象內存到釋放區域:池內維護一個隊尾索引。每次分配就讓新對象覆蓋隊尾後的一塊區域,再讓隊尾索引後移;每次釋放把隊尾最對應的對象移動到被釋放的區域,而後讓隊尾索引前移(不過要稍微注意一下邊界問題)

我的很推薦使用第二個作法,由於它可以更加容易命中cpu緩存,除非存放的對象很大致使移動內存的開銷很大或者過於頻繁的釋放操做。

更詳細的緣由分析可參考個人另外一篇博客裏的粒子數組的部分: 遊戲設計模式——面向數據編程思想 - KillerAery - 博客園

小塊內存分配器 Small Memory Allocator


對象池的一個問題是,可能使用了過多的類型,致使產生過多的對象池類型。
假如這種遊戲對象類型共享池的方式轉變成相同內存大小類型共享池的方式,這樣就能夠顯著減小池的類型。

小塊內存分配器,每每容納的是小於或等於必定內存大小的任意類型對象。

創建一組小塊內存分配器,分別對應存儲8,16,32,64,128,256字節的元素,
這樣就足以應付大量的內存佔用小且類型各異的對象。

內存佔用大的類型數量每每不多,由於大類型每每組合於多個小類型。
所以這種分配方式很是適合內存佔用小的對象,才稱其爲小塊內存分配器。

對於大塊內存的申請分配,每每都是交付給其餘分配器來處理。

而由於小塊內存分配器允許對象小於分配器存儲的元素大小,因此會浪費一些內存。
然而相對於解決內存碎片問題,這種浪費絕對是值得的。

可整理碎片的內存池


若要分配/釋放不一樣大小的對象(不可用對象池),並且生命週期還不止一兩幀(不可單幀和雙緩衝內存分配器),並且仍是隨機次序進行(不可堆棧分配器)。
那麼能夠考慮實現可整理內存碎片的功能。

重定向指針

若使用可整理碎片的內存池,通常分配函數應該返還一個封裝好的智能指針(即指向一個原生指針的指針)。這樣當移動複製內存的時候,給智能指針裏指向新複製好的內存地址。

不過,須要注意的是,這種智能指針的調用會有兩次指針跳轉的開銷。

分攤碎片整理成本

碎片整理還有個比較苦惱開銷較大的操做:複製移動內存塊。
因此爲了不一次性大開銷(容易形成卡頓),咱們無需一次性將全部碎片所有整理,能夠將該成本平均分攤至N幀完成。
例如能夠設定一幀最多能夠進行K次內存塊移動(一般是個小數目),這樣能夠預計大概若干幀即可以把全部碎片所有整理完,並且也不會對遊戲形成卡頓的影響(畢竟開銷平攤給每幀)。

頑皮狗的引擎中,重定向整理碎片的內存池只應用於遊戲對象上,而遊戲對象通常很小,從不會超過數千字節。

適用場景:不適用對象池/單幀雙幀/堆棧分配的對象,而且整個內存池容許數據總量應該偏小,由於碎片整理是須要付出必定代價的。

額外


  • 儘可能使用棧內存:這樣就能夠儘可能把內存交給棧管理,而無需考慮堆內存分配的各類問題。
  • 慎用STL的智能指針:其使用效率通常不如自定義的好,並且也相對上面自定義內存機制來講更易引起內存碎片問題。若必定要使用,請保證你深刻了解STL智能指針而且審慎對待。
  • 各類分配方式的內存池是能夠且推薦 嵌套使用的。例如一種可行的內存分配搭配方式是:雙端堆棧分配器做爲程序裏最大的內存池,它一端分配單幀或雙緩衝內存池用於存放幀內臨時變量,另外一端分配另外一個堆棧分配池,該堆棧分配池有含有對象池。

參考


  • 《遊戲引擎架構(Game Engine Architecture)》 Jason Gregory

遊戲設計模式系列-其餘文章:https://www.cnblogs.com/KillerAery/category/1307176.html

相關文章
相關標籤/搜索