英文原文:https://www.jacksondunstan.com/articles/4644
Unity 的 GC 一直是我们的眼中钉。我们不断通过池化对象、限制语言功能的使用和避免 API 来解决这个问题。我们甚至在加载屏幕上调用 GC.Collect,希望 GC 不会在游戏过程中运行。今天的文章更进一步,展示了如何完全禁用 GC,使其运行的可能性为零。当我们再次准备好时,我们还将了解如何重新打开它。
Unity 的 GC 是用 Unity 附带的 C 代码实现的。以下是截至 2017.3 的 Mac 安装位置:
/Applications/Unity/Unity.app/Contents/il2cpp/external/boehmgc/
在 Misc.c 文件中,我们在大约 1800 行下面看到了这些函数:
GC_API void GC_CALL GC_enable(void)
{
DCL_LOCK_STATE;
LOCK();
GC_ASSERT(GC_dont_gc != 0); /* ensure no counter underflow */
GC_dont_gc--;
UNLOCK();
}
GC_API void GC_CALL GC_disable(void)
{
DCL_LOCK_STATE;
LOCK();
GC_dont_gc++;
UNLOCK();
}
这些都是非常简单的函数。它们只是增加一个整数 (GC_dont_gc),该整数用作是否启用 GC 的全局标志。如果为零,则 GC 已启用。如果它不为零,则 GC 被禁用。所以我们应该能够调用 GC_disable 来禁用 GC,然后调用 GC_enable 来重新启用它。
因此,让我们尝试使用 [DllImport] 属性从 C# 调用这些函数:
using System.Runtime.InteropServices;
public static class GcControl
{
// Unity engine function to disable the GC
[DllImport("__Internal")]
public static extern void GC_disable();
// Unity engine function to enable the GC
[DllImport("__Internal")]
public static extern void GC_enable();
}
现在我们可以从应用程序中的任何位置调用 GcControl.GC_disable() 或 GcControl.GC_enable() 来禁用和重新启用 GC。重要的是要记住,这些函数以类似于引用计数的方式递增和递减整数。因此,对 GC_disable 的调用应与对 GC_enable 的调用相匹配。
现在让我们尝试使用一个小应用程序来显示 GC 运行的次数以及用于打开和关闭 GC 的按钮。每一帧,我们都会在 byte[] 中分配 100 KB 的托管内存并保留它,这样内存压力就会不断增加并导致 GC 运行。
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;
using UnityEngine.UI;
class GCTestScript : MonoBehaviour
{
// UI
public Text gcCountText;
public Text toggleButtonText;
// GC status
private bool isGcDisabled;
// Holds allocated memory
// Prevents GC from collecting it
private List<byte[]> storage;
void Awake()
{
storage = new List<byte[]>();
toggleButtonText.text = "Disable GC";
}
void Update()
{
// Display how many times GC has run
int gcCount = GC.CollectionCount(0);
gcCountText.text = gcCount.ToString();
// Add managed memory pressure
storage.Add(new byte[100*1024]);
}
// Toggle GC on or off
public void OnToggle()
{
isGcDisabled = !isGcDisabled;
if (isGcDisabled)
{
GcControl.GC_disable();
toggleButtonText.text = "Enable GC";
}
else
{
GcControl.GC_enable();
toggleButtonText.text = "Disable GC";
}
}
// Re-enable GC on exit
void OnApplicationQuit()
{
if (isGcDisabled)
{
GcControl.GC_enable();
}
}
}
下面是它的实际效果:
单击禁用 GC 的按钮确实会阻止 GC 收集计数上升。单击按钮重新启用 GC 会恢复数字的上升,因为 GC 会开始尝试回收内存,为新分配的 byte[] 对象腾出空间。
这适用于 Unity 编辑器和 Android 设备。无法保证它可以在 Unity 支持的每个平台上运行,但至少这两个平台都可以运行。
既然我们有了这个工具,那么我们应该如何使用它呢?要决定策略,了解该工具的效果非常重要。对于我们大多数人来说,在禁用 GC 的情况下运行游戏是一个新领域。
通常,启用 GC 后,当我们分配托管内存(例如使用 new Class())并且托管堆中没有足够的空间来存储该对象时,会发生两件事。首先,运行 GC 来收集所有垃圾,以便可以重用内存。其次,如果仍然没有足够的空间,则会扩展托管堆以为新对象腾出空间。
禁用 GC 后,第一步将不会发生,并且不可能重用一个或多个垃圾对象的内存。相反,我们将直接进入第二步并扩展托管堆。这意味着随着我们分配越来越多的对象,内存使用量将继续增长。如果我们分配足够的内存,我们可能会耗尽内存并面临诸如由于使用“虚拟内存”(例如在 Mac 上)或让我们的应用程序被操作系统彻底终止(例如在 Android 上)而导致性能下降等后果。禁用GC根本不让我们放弃所有的自我控制并随心所欲地分配。
因为我们真的不希望这些事情发生,所以在禁用 GC 时不要使用太多内存,这一点至关重要。通常可以对此进行安排。例如,如果我们在加载一个游戏关卡后开始使用 100 MB 的内存,并且我们知道在该关卡期间不会分配超过 100 MB 的内存,那么我们在任何可以使用的设备上都没有问题。最多使用 200 MB。作为一种保护措施,我们始终可以定期查询内存使用情况,并在达到对设备而言存在危险的阈值(例如 250 MB)时重新启用 GC。
还值得注意的是,我们调用的这些 C 函数是 Unity 引擎的内部函数,而不是公共 API 的一部分。无法保证函数名称、签名或行为在不同版本中保持相同。但实际上,这些函数是 Unity 使用的现成 GC 的一部分,文件头中的最新日期是 2001 年,因此这种技术很可能会继续有效,直到 Unity 最终取代 GC。目前该项目位于“开发”下的路线图上,描述为“正在进行中,时间表较长或不确定”。
尽管如此,这绝对是一项先进的技术,使用时应非常谨慎。对于游戏的帧速率关键部分禁用 GC 并在之后重新启用它(例如在加载屏幕上)可能是一个很大的好处,对于某些游戏来说是值得的,但应该非常小心地使用。