【译】 Object Pool

发表于2016-01-28
评论5 1.2k浏览
翻译自:Object Pool
原文作者未做版权声明,视为共享知识产权进入公共领域,自动获得授权

目的:
  在一个固定的POOL里重用对象,而不是单独的分配和释放某一个对象,从而提高性能和内存利用率。

动机:
  我们正在实现一个游戏内的视觉效果:英雄施放一个符咒,会在屏幕上产生一个闪烁特效,这时需要一个粒子系统来实现,引擎会生成一个闪光的图形然后播放直到消失。 因为一轮施法会产生数以百计的粒子特效,所以我们需要快速的创建它们,更重要的是,创建和销毁这些粒子不会引起内存碎片化。

碎片的诅咒:
  在很多方面单机或手机游戏的编程比传统的PC编程更接近嵌入式编程,内存非常稀缺,用户希望游戏非常稳定,而很少有有效的内存压缩管理器可以使用。在这种情况下,内存碎片是致命的。 
  内存碎片的意思是堆上的可用空间从一个段大的内存块分离成很多小片。这样即使我们的总内存可用空间很大,但是大的连续的区域却非常的小。举个例子:我们现在有14字节的空闲内存,但是它被一段已经分配的内存所隔开,如果我们想分配一个12字节的对象就会失败,你也看不见屏幕上的特效了亲。 
即使碎片不是很频繁,它也会降低堆的使用率, 将它变成一个充满洞和裂缝的泡沫塑料,最终毁掉游戏。

两全其美:
  因为碎片和分配内存非常慢,游戏非常在意如何和何时管理内存,一个简单的办法是游戏初始化的时候分配一块大的内存,直到游戏结束再释放他们。但是对于某些在运行中需要创建和销毁对象的游戏来说,是非常痛苦的。 
  对象池是最好的解决办法:预先分配好一大块内存块,游戏运行的时候不要回收,对于用户来说,我们自主的分配和回收这些对象用于我们的核心内容。

模式
  POOL 的定义很简单:维护一个可重用对象的集合,每个对象有一个是否可用的标志可以用来查询,POOL 初始化的时候,它会预先创建整个对象集合,(通常是在一块连续的区域),然后将这些对象设置为不可用的标记。 
  当你需要一个新的对象,找POOL 要一个。它会找到一个可用对象并标志为“已经使用”然后返回给你。当对象不再需要的时候,会被设置成不可用状态。用这种方法,对象能够自由的创建和销毁而不需要申请内存或者其它资源。

何时使用
对象池模式广泛用于游戏中,比如说游戏实体或者特效等,但它还可能用于一些不常见的数据比如说播放的声音。 
这些情况使用: 
频繁创建和销毁对象。 
对象大小相似。 
在堆上分配对象非常耗时并可能导致内存碎片。 
每个对象都封装了资源(数据库 或者 网络连接数据)获取它们代价很大并且可以重用。

铭记于心
通常你依赖于GC或者new delete操作来帮助你管理内存,但如果使用了对象池。
· 你现在有责任把握它的局局限性:
  POOL可能会浪费内存在不需要的对象上,你需要根据游戏需要调整POOL 的大小。
在任何时间只有固定数量的对象能够被激活。某种程度上说,这是个好事。通过对象类型将内存分离成一些独立的对象池能够保证这种情况,举例:你正准备生成一个敌人的对象,可是失败了,因为这时一系列爆炸的特效耗掉了你所有内存。 
尽管如此,还是有可能出现你需要重用对象但是它们正在被使用的情况。 
· 这里有一些解决策略:
1、完全阻止:这是最普遍的做法,调整POOL 大小,让它永远不会溢出,对于那些比较重要的对象比如说AI的时候这个解决办法是合理的,当你在游戏关卡内将要PK BOSS的时候,你却没有可用的对象来生成,你会束手无策。所以最聪明的做法是保证它肯定不会发生。另外不同的场景对象池所需的大小也不尽相同,你最好根据关卡来做调整。
2、不要创建:试想你已经面对满屏幕的亮瞎狗眼的特效的时候,用户会觉得你将播放的下一个特效跟让他印象深刻么?
暴力杀死当前对象。比如说有一个声音的对象池,你打算播放一个新的声音,但是池子满了,你又不想忽略这个声音,因为玩家会察觉到的。玩家正在用一个很牛逼的魔杖施法,有时候刷刷的声音很吊有时候吗的又没声音,他肯定不爽。一个好办法是找到一个比较安静的声音然后悄悄替换它。 
总的来说,只要某个对象的消失比新的对象的不出现更令人不能察觉的话,你可以直接干掉那个老的对象。、
3、增加内存池大小:如果你的游戏可以更加灵活的掌控内存的话,你可以考虑是否动态的改变POOL 的大小或者是用一个新的更大的POOL覆盖,同样当你发现你的POOL的很多空间多余的时候,你也可以考虑去恢复到POOL之前的大小。
对象的内存占用是固定的。很多对象池用数组存储,对于大小固定的对象来说这样是很好的。但是对于不同类型的对象池来说(比如说某个对象的之类添加了很多的数据),你必须保证每个POOL的插槽有足够的内存去满足那些大的对象,否则,大的对象会挤到下一个对象并产生内存垃圾。 
另一方面,如果对象池的对象大小不一样,也会有很多内存浪费,没一个插槽都需要足够大去容纳最大的那个对象,如果只有少部分大对象,你每次填充一个小的对象就会浪费很多的空间。就像一个傻逼去机场安检,带一个超大箱子装的只是他的钱包和钥匙(当然他如果是去国外购物的话当我没说)。 
如果你发现你干这种傻逼事情,你可以将的POOL分成不同类型的对象池。
重用对象不会自动清理。许多内存管理器清理回收内存后,数据会变成一些魔术数:0xdeadbeef, 
屯吞吞,shutthefuckingup。。等等。这可以用来帮助你查找诡异的未初始化变量的BUG。 
因为对象池不再归memory manager管理了,所以你需多加小心,搞的不好 你用的对象还是它回收前的那个状态,所以在初始化和回收对象的时候必须特别小心。
4、没用的对象还是在内存中:对象池在有GC的系统中用的不是那么普遍,因为内存管理器通常会帮你处理内存碎片问题,但是它还是可以帮你避免分配和回收内存的消耗,特别是在那些很屎的CPU和很戳的GC的机器上(比如说有手机等移动设备)。 
如果你想对象池和GC和谐相处,要注意潜在冲突。因为对象池并没有释放内存在回收对象的时候,它们仍然在内存中,如果这个时候它有对别的对象的引用,会阻止GC对它们回收利用,所以当对象不再使用的时候,记得清掉所有他的引用。

示例代码
现实世界的粒子系统往往应用到重力,风力,摩擦力等其它物理效果。我们这里用最简单的例子:在一个直线上以固定的帧率移动粒子然后销毁它们。它没有电影级别的效果,但必须能阐明如何使用Object pool。
我们从最简单的实现开始,首先是一个简单的particle类:
默认构造函数将粒子初始化为未使用,随后调用Init函数初始化粒子为激活状态。每帧调用粒子的animate()函数来激活粒子。
pool可以通过particle的inUse()函数知道哪些粒子可以被重复使用。粒子都有限定的生命周期,这个函数的优点在于能够利用_framesLeft变量去发现哪些粒子正在被使用,而不用另外存储一个特殊标志。
pool类也很简单:
外部代码调用create()函数来创建新的粒子。game每帧调用animate()函数,函数内部又依次调用每个粒子的animate()函数。
粒子本身以固定大小数组的形式存储于类中。在这个例子的实现中,pool的大小硬码在类的声明里,不过我们也可以用外部定义的方式,比如使用动态数组或者模板参数。
创建新的粒子很简单:
我们遍历pool找出第一个可用的粒子,找到后初始化它表示我们已经处理过。注意到在这个实现中,如果我没有没有找到任何可用的粒子,就不会创建新的。
这就是一个简单的粒子系统,当然,不包括渲染。我们现在可以创建一个pool然后用它创建粒子。这些粒子在生命周期结束的时候会自动释放。
目前这些实现已经能满足一个游戏的运行,但是细心的人会注意到我们创建一个粒子需要遍历整个集合,直到我们找到一个能用的槽位。如果pool非常的大而且几乎是满了的时候,这个操作会非常的费时。所以我们来研究下如何改进这个问题。

A free list
如果我们不想把时间浪费在寻找可以用粒子上,一个显而易见的答案就是不要和它失去联系。我们可以单独存储一个链表指向每个未使用的粒子。这样,当我们需要创建粒子的时候,删除链表中的第一个指针并且重用它指向的粒子。
很遗憾,这种做法需要我们维护一整个独立的数组,指针数量和pool里的对象数量一样。毕竟我们第一次创建pool的时候,所有的粒子都是未使用的,所以链表初始化的时候还是需要指针指向对象池里的每一个粒子。
在不考虑牺牲内存的情况下,这种做法能很好的解决性能问题。如果合适的话,这里其实有很多现成的内存可以借用---那些未使用的粒子数据本身。
当粒子没有使用的时候,大多数它们的状态是无关紧要的。它们的位置和速度都没有被使用过,它仅仅需要标志是否被使用。在我们的粒子中就是_framesLeft这个变量。所有其它的比特都可以被重用,下面是我们修改过的粒子:

我们将除了framesLeft_的变量放在state_ unionlive struct中。这个结构体在粒子激活的时候保持维持粒子的状态。当粒子没有激活的时候,unionnext成员变量变为可用,它的指针指向下一个可用的粒子。

我们可以利用这些指针创建一个linked list,它能串联起所有pool中未使用的粒子,这样在不需要额外内存的情况下,利用未激活粒子的本身内存,我们维护了一个指向可用粒子的列表。

这种机灵的做法称为free list,为了正常工作,我们必须确保指针都正确初始化,而且在创建和销毁的时候都能维持正常的状态,最后还必须追踪链表的头部:

Pool第一次创建的时候所有的粒子都是可用的,free list会贯穿整个poolpool的构造函数里做了这些工作。

创建新粒子时,直接跳到第一个可用的地方:

我们需要知道粒子什么时候死亡然后添加到free list的尾部,所以修改animate()函数,当framesLeft_ 等于0时,返回true:

当返回为true时,串起整个链表。

这下你应该了解了这个轻量级的很不错的pool,它具备常数级的创建和删除时间。


设计决策

如你所看到的,一个简单的对象池的实现往往非常简单:创建一个对象数组并且在需要的时候重现初始化,原始代码非常简单,还有很多方法来扩展它让他更通用和安全,或者更加简单为主,假如你在自己的游戏中实现这些pool,你需要思考这些问题:

对象和pool结合起来?

写pool首先遇到的问题是对象自己是否知道在pool中,大多数时间是知道的,但是这样的话你就不能写出一个通用的pool能够支持任意的对象。

对象与pool关联

实现很容易,放置“in use”标志或者函数在对象池的对象中。

你需要确保对象能够被pool创建,在c++中,一个简单的做法是让pool类成为object类的friend并且让object的构造函数变成私有。

你可以避免存储一个explicit 的 “in use”的标志,许多对象已经保有一些状态能够说明object是否被激活。举例说明:一个粒子可以被重新利用在它消失在屏幕外的时候,这样我们可以用InUse()方法来封装这个状态,这样做省掉了另外存储InUse标志带来的开销。


对象与pool没有关联

任何的对象都能pool中使用是个很大的优势,这样将对象和pool解耦,你可以实现一个通用的pool。

"in use"标志必须能在object外能追踪到,一个简单的做法是创建一个独立的bit 字段。


初始化重用对象的职责

为了能够重用当前对象,它必须重新初始化为新的状态,关键问题是初始化是放在object内部还是外部。

pool是否在内部重新初始化

pool可以完全封装它的对象,依赖于对象所需要的其它功能,你可以保持这些功能完全在pool内部。这样能确保其它的代码不会维护对对象的引用,以免引起不可预期的重用。

Pool的初始化功能非常关键。对象的初始化函数可能需要很多重载函数,如果pool来管理初始化,这些接口都需要提供。

Pool外部初始化Object

 

Pool的接口就变的很简单:不需要提供各式各样的初始化函数,只需要返回新对象的引用即可。

 

   调用者就可以调用任何对象所暴露的方法来初始化它:

 

外部代码需要对创建对象的失败情况有所处理。之前的例子中我们假定create()函数总是能够成功返回一个对象的指针,当pool满了的时候,将会返回null,所以为了安全起见,初始化的时候必须判空检测。


其它参考

Object poolflyweight 模式有很多相同的地方,它们都维护了一个复用对象的集合,只是复用的具体意义不一样。Flyweight的对象复用对象为不同的拥有者同时占有的一个实例,这样是为了避免在很多情形下为相同的对象复制内存。

Pool中的对象只能再一定时间内复用,在object pool中,复用是指原始拥有者处理完对象的时候     重新为对象分配     内存,不会存在生命周期共享的情况。

 

将相同类型的对象打包成pool可以让CPU 的 cache在游戏遍历对象的时候高效运行。Data Locality模式讲到了相关的内容。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引

0个评论