[C#] 对象池产生的问题

文章探讨了在Unity游戏开发中使用对象池避免垃圾收集问题时遇到的挑战,包括显式释放、状态维护、多引用管理、集合池化、开销增加、线程同步和默认构造函数的限制,提醒开发者在应用对象池时需谨慎权衡这些问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

英文原文:https://www.jacksondunstan.com/articles/3829

很常见的是,有人问有关避免垃圾收集器的问题,但得到的答案却是“只需使用池”,就好像这立即完全解决了问题一样。虽然池通常会让垃圾收集器陷入困境,但它们也会带来一系列你必须处理的新问题。今天的文章将讨论其中的几个问题,以便您了解所涉及的权衡,并希望避免一些陷阱。

对象池回顾

我们希望避免游戏中由于 Unity 提供的缓慢、一次性、内存碎片、主线程阻塞垃圾收集而导致的帧峰值/停顿。因此,我们经常求助于对象池,它旨在将对象保存在列表中,并且永远不会释放引用以供 GC 收集。我之前提供过一个简单的池,如果你上网搜索的话还有更多。这是我制作的典型池的示例:

public interface IPoolableObject<TInitArgs>
	where TInitArgs : struct
{
	void Init(TInitArgs initArgs);
	void Release();
}
 
public class ObjectPool<TObject, TInitArgs>
	where TObject : class, IPoolableObject<TInitArgs>, new()
	where TInitArgs : struct
{
	private readonly Stack<TObject> unused = new Stack<TObject>();
 
	public TObject Get(TInitArgs initArgs)
	{
		var obj = unused.Count == 0 ? new TObject() : unused.Pop();
		obj.Init(initArgs);
		return obj;
	}
 
	public void Release(TObject obj)
	{
		obj.Release();
		unused.Push(obj);
	}
}

真的很简单!不使用 new,而是调用 ObjectPool.Get,它将重用池中的对象(如果可用)。完成后,只需调用 ObjectPool.Release,它就会返回到堆栈中。那么可能会出现什么问题呢?

需要显式释放

对于初学者来说,当您使用完池化对象后,您必须记住将其放回池中。这听起来很容易,直到您意识到如果您忘记了,您基本上不会收到提醒您的反馈。没有编译器错误或警告,不会打印调试日志,游戏也不会崩溃。只需覆盖一个变量,您就可以释放它引用的对象,并且最终会发生垃圾收集。

发现这种情况的唯一方法就是对游戏进行剖析,观察垃圾回收情况,很可能是在注意到 GC 导致的帧中断之后。然后,你需要回溯上次垃圾回收后的所有帧,查看 "GC 分配 "列,找出分配了哪些对象,并猜测其中哪些对象没有返回到池中。整个过程非常耗时且乏味。在这个过程中,基本上没有任何工具可以为你提供支持,只需省略一行简单的代码,就会带来这些麻烦。

返回池时内容未释放

但假设您永远不会忘记将对象放回池中。还有什么可能出错的地方?好吧,您的 IPooledObject 类型上有这些 Init 和 Release 函数,您应该在其中重置对象的状态,以便重用时它是新鲜的。不幸的是,很容易忘记正确重置对象的状态。您可以轻松添加字段并忘记在发布中重置它。您可能有需要复杂重置的复杂对象。例如,您可能有一个 IPooledObject 字段,需要将其放回其自己的池中。

如果您忘记执行其中任何操作,那么对象的状态将在使用之间“泄漏”。这也确实很难追踪!您可能想知道为什么您的敌人一开始会间歇性地获得增益,或者失去 30% 的生命值。你必须以某种方式发现这是因为敌人的状态在释放时没有正确重置。同样,没有工具可以向您发出警告或错误,说明您只是省略了一两行代码。你必须艰难地完成调试会话,试图找出为什么你的敌人处于奇怪的状态……有时。

对池化对象的多个引用

当您对池对象有多个引用时,另一个令人讨厌的问题就会出现。通过垃圾回收,对象将在最后一个引用被释放后自动清理。通过池化,您需要手动跟踪对某个对象有多少个引用。这意味着每次将池对象传递给函数或将其存储在类的字段上时,您需要记住在某处增加引用计数。每次这些函数返回或这些类覆盖它们的引用字段时,您都必须记住递减该数字。并且不要忘记闭包和协程,它们会秘密创建带有局部变量字段的类,因为您也需要对它们进行计数。

如果您忘记了哪怕一个增量或一个减量,那么您的引用计数将不准确。如果它没有降到零,该对象将永远不会被放回池中。如果在所有引用被释放之前它下降到零,那么剩余的引用将继续使用已经(希望)被清理甚至被代码的另一个区域重用的对象。再说一遍,没有工具可以警告您这一点,您需要找出为什么(有时)伤害一个敌人会伤害另一个敌人,以及诸如此类的其他棘手问题。

难以池化集合

数组、列表和字典等集合是大多数 Unity 游戏的基本构建块。它们也很难池化!想象一下,您将 int[] 包装在 PooledIntArray 类中。另一段代码想要获取精确长度的数组的可能性有多大?不是特别的。如果您使用列表,您可以更改长度,但仍然存在问题。首先,随着列表的增长,它将在内部释放对其数组的引用,并将元素复制到一个新的、更大的数组中。您无法插入该过程来插入您自己的数组池。您可以列出一个从非常大的容量开始的列表,并希望您永远不会超过它,但是您的内存使用量将始终处于您需要的“高水位线”。同样的问题也适用于字典和其他集合类型。您如何解决这个问题?池在这里不提供解决方案。

增加了池类型的开销

池本身包含某种对象列表,就像上面示例中的堆栈一样。这是您原本不会使用的内存,如果列表由于释放过多的对象而需要调整大小,则该内存甚至更多。池类及其列表也是 GC 分配和跟踪的对象,但希望您永远不会释放它们。更高级的池将需要更多的内存来存储指示哪些对象在池中或在池外的数据。其中涉及大量虚拟函数调用,例如 Get 和 Release。这是在没有对象池的情况下通常使用的所有 CPU 和内存开销。

默认情况下不是线程安全的

使用 new 操作符和将引用设置为 null 都是完全线程安全的操作。当涉及到池的时候,情况就变得扑朔迷离了。你可以为每个线程创建一个池,但这对于短时线程来说效率很低,而且对象不会跨线程重复使用。或者,你可以为所有线程创建一个池,并在获取和释放函数中添加锁语句。在上述基础上,这样做的开销会更大。

此外,还存在重入问题。当池调用 Init 或 Release 时,这些函数可能会调回池中获取或释放。对于比上述简单的池更高级的池,这可能会导致池本身的状态出现重大问题,并可能影响到游戏中影响深远的区域。锁语句在这里帮不上忙,因为它只能防止多个线程运行相同的代码。因此,你需要更多的开销,比如设置一个易失性 bool 标志,并在所有地方进行检查。这需要大量的开销和复杂性,而且很难做到正确,即使做到了也很昂贵。

没有默认构造函数

像上面这样的池需要能够在池为空时创建对象,因此它们添加了一个 where T : new() 约束,以便保证有一个默认构造函数。问题是许多类没有默认构造函数。考虑一个 Person 类,它需要名字、姓氏和年龄才能构造成有效状态。相反,它需要有一个默认构造函数,将这些字符串保留为空(或空),并将年龄保留为无效的值,例如零或-1。 Init 函数将立即被调用,但只有池被设置为每次都能正确执行此操作。编写代码很容易,只需调用 new Person 并跳过池和 Init 函数。再次没有警告表明重要函数已被跳过,只有稍后才会因空字符串或由于 -1 年龄而出现一些奇怪的算术而导致崩溃。

使用起来很尴尬

最后,池的使用很不方便。要获取或释放对象,你需要一个指向池的引用。要获得该引用,你需要像使用工厂一样,将其传递给每个创建对象的函数。这样就会使参数列表变得臃肿。此外,函数的调用者知道函数将如何创建或释放对象也显得很奇怪。

为了解决传递池的参数菊花链问题,您可能会很想创建某种全局池变量。您可以将其称为“单例”或“服务”或“管理器”,但可以从游戏中的每个功能全局访问它。在这种情况下,这可能是可以接受的,但它强制采用“所有线程一个池”的方法,该方法需要锁,除非您的整个游戏是单线程的。

您还必须实现 IPoolableObject 以便池化某个类的对象。由于您无法控制 Unity、.NET 和其他库已定义的许多类,因此无法池化它们。因此,如果您想池化 StringBuilder,那么您必须将其包装在 PoolableStringBuilder 类型的类中。每当您创建其中之一时,您都会分配两个对象:包装器本身和包装对象。这会带来更多的开销、GC 的更大压力以及更尴尬的代码。

结束语

对象池并不是万能的。它们无法轻松彻底地解决 Unity 游戏中的垃圾收集问题。根据你的观点,治疗方法甚至可能比疾病本身更糟糕。它们可以成为对抗框架尖刺/挂钩的有用工具。如果您决定使用它们,请不要轻易这样做。在考虑权衡时,请记住上述缺点。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值