游戏架构设计:内存池管理


内存

前言:对C++游戏程序员来说,内存管理是一件相当头疼的问题。因为C++是将内存赤裸裸的交给程序员,而不像Java/C#有gc机制。

好处是这样对于高性能要求的游戏程序,原生的内存分配可以避免gc机制的臃肿操作,从而大大提高性能。
坏处是C++程序员得时时警惕内存问题:

内存泄露问题

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

上面的例子中。忘记回收内存,函数退栈导致丢失了object指针,就再也找回不了new的内存地址,这时内存一直就会被占用着。

内存泄漏很容易理解,不作多讲。

内存碎片问题

由于对堆内存的分配/释放的顺序是随机的,导致申请的内存块随机分布于原始内存,倘若分布不是连续的(随机顺序往往导致多个内存块都是相隔开的),那么便会产生“洞”。

随着时间推移,堆内存越来越多出现这些“洞”,导致可用的自由内存块被拆分成多个小内存块。
这就导致即使有足够的自由内存,分配请求仍然可能会失败。

内存页切换问题

虚拟内存系统把不连续的物理内存块(即内存页)映射至虚拟地址空间,使内存页对于应用程序来说看上去是连续的。
在支持虚拟内存的操作系统上,多次使用原生C/C++内存分配,有可能其导致每次分配的页面相距较远(即不连续)....在重复使用这些内存的时候有可能导致昂贵的切换页开销。
而一次大内存分配,更容易把连续的多个页面分配出来,从而减少昂贵的切换页开销(页也有缓存技术)。

一些本世代游戏机虽然技术上支持虚拟内存,但由于其导致的开销,多数游戏引擎不会使用虚拟内存技术。

内存池(Memory Pool)

对C++内存分配进行适合当前程序的封装就显得尤为重要,这样C++程序员就能在封装完内存机制后减少大量心思警惕内存问题。
而如何封装还能高效的使用内存,就成了一门学问——内存池管理。

而内存池是什么:

预先通过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

posted @ 2019-04-26 13:02  KillerAery  阅读(4361)  评论(0编辑  收藏  举报