Unity开发者的C#内存管理(上篇)

本文翻译自:C# Memory Management for Unity Developers (part 1 of 3)

很多游戏时常崩溃,大多数情况下都是内存泄露导致的。这系列文章详细讲解了内存泄露的原因,如何找到泄露,又如何规避。

我要在开始这个帖子之前忏悔一下。虽然一直作为一个C / C++开发者,但是很长一段时间我都是微软的C#语言和.NET框架的秘密粉丝。大约三年前,当我决定离开狂野的基于C / C++的图形库,进入现代游戏引擎的文明世界,Unity 带着一个让我毫不犹豫选择它的特性脱颖而出。Unity 并不需要你用一种语言(如Lua或UnrealScript)‘写脚本’却用另外一种语言'编程'。相反,它对Mono有深度的支持,这意味着所有的编程可以使用任何.NET语言。哦,真开心!我终于有一个正当的理由和C ++说再见,而且通过自动内存管理我所有的问题都得到了解决。此功能已经内置在C#语言,是其哲学的一个组成部分。没有更多的内存泄漏,没有更多的考虑内存管理!我的生活会变得容易得多。

如果你有哪怕是最基本的使用Unity或游戏编程的经验,你就知道我是多么的错误了。我费劲艰辛才了解到在游戏开发中,你不能依赖于自动内存管理。如果你的游戏或中间件足够复杂并且对资源要求很高,用C#做Unity开发就有点像往C ++方向倒退了。每一个新的Unity开发者很快学会了内存管理是很麻烦的,不能简单地托付给公共语言运行库(CLR)。Unity论坛和许多Unity相关的博客包含一些内存方面的技巧集合和最佳实不规范践。不幸的是,并非所有这些都是基于坚实的事实,尽我所知,没有一个是全面的。此外,在Stackoverflow这样的网站上的C#专家似乎经常对Unity开发者面对的古怪的、非标准的问题没有一点耐心。由于这些原因,在这一篇和下面的两篇帖子,我试着给出关于Unity特有的C#的内存管理问题的概述,并希望能介绍一些深入的知识。

第一篇文章讨论了在.NET和Mono的垃圾收集世界中的内存管理基础知识。我也讨论了内存泄漏的一些常见的来源。
第二篇着眼于发现内存泄漏的工具。Unity的Profiler是一个强大的工具,但它也是昂贵的(似乎在中国不是)。因此,我将讨论.NET反汇编和公共中间语言(CIL),以显示你如何只用免费的工具发现内存泄漏。
第三篇讨论C#对象池。再次申明,重点只针对出现在Unity/ C#开发中的具体需要。

垃圾收集的限制
大多数现代操作系统划分动态内存为栈和堆(12),许多CPU架构(包括你的PC / Mac和智能手机/平板电脑)在他们的指令集支持这个区分。 C#通过区分值类型支持它(简单的内置类型以及被声明为枚举或结构的用户自定义类型)和引用类型(类,接口和委托)。值类型在堆中,引用类型分配在栈上。堆具有固定大小,在一个新的线程开始时被设定。它通常很小 - 例如,NET线程在Windows默认为一个1MB的堆栈大小。这段内存是用来加载线程的主函数和局部变量,并且随后加载和卸载被主函数调用的函数(与他们的本地变量)。一些内存可能会被映射到CPU的缓存,以加快速度。只要调用深度不过高或局部变量不过大,你不必担心堆栈溢出。这种栈的用法很好地契合结构化编程的概念(structured programming)。

如果对象太大不适合放在栈上,或者如果他们要比创造了他们的函数活得长,堆这个时候就该出场了。堆是“其他的一切“- 是一段可以随着每个OS请求增长的内存,and over which the program rules as it wishes(这句不会……)。不过,虽然栈几乎是不能管理(只使用一个指针记住free section开始的地方),堆碎片很快会从分配对象的顺序到你释放的顺序打乱。把堆想成瑞士奶酪,你必须记住所有的孔!根本没有乐趣可言。进入自动内存管理。自动分配的任务 - 主要是为你跟踪奶酪上所有的孔 - 是容易的,而且几乎被所有的现代编程语言支持。更难的是自动释放,尤其是决定释放的时机,这样你就不必去管了。

后者任务被称为垃圾收集(GC)。不是你告诉你的运行时环境什么时候可以释放对象的内存,是运行时跟踪所有的对象引用,从而能够确定——在特定的时间间隔里,一个对象不可能被你的代码引用到了。这样一个对象就可以被销毁,它的内存会被释放。GC仍被学者积极地研究着,这也解释了为什么GC的架构自.net框架1.0版以来改变如此之多。然而,Unity不使用.net而是其开源的表亲,Mono,而它一直落后于它的商业化对手(.net)。此外,Unity不默认使用Mono的最新版本(2.11/3.0),而是使用版本2.6(准确地说,2.6.5,在我的Windows4.2.2安装版上(编辑:这同样适用于Unity4.3])。如果你不确定如何自己验证这一点,我将在接下来的帖子里讨论。

在Mono2.6版本之后引入了有关GC的重大修改。新版本使用分代垃圾收集(generational GC),而2.6仍采用不太复杂的贝姆垃圾收集器(Boehm garbage collector)。现代分代GC执行得非常好,甚至可以在实时应用中使用(在一定限度内),如游戏。另一方面,勃姆式GC的工作原理是在堆上做穷举搜索垃圾。以一种相对“罕见”的时间间隔(即,通常的频率大大低于一次每帧)。因此,它极有可能以一定的时间间隔造成帧率下降,因而干扰玩家。Unity的文档建议您调用System.GC.Collect(),只要您的游戏进入帧率不那么重要的阶段(例如,加载一个新的场景,或显示菜单)。然而,对于许多类型的游戏,出现这样的机会也极少,这意味着,在GC可能会在你不想要它的时候闯进来。如果是这样的话,你唯一的选择是自己硬着头皮管理内存。而这正是在这个帖子的其余部分,也是以下两个帖子的内容!

自己做内存管理者

让我们申明在Unity/.NET的世界里“自己管理内存”意味着什么。你来影响内存是如何分配的的力量是(幸运的)非常有限的。你可以选择自定义的数据结构是类(总是在堆上分配的)或结构(在栈中分配,除非它们被包含在一个类中),并且仅此而已。如果你想要更多的神通,必须使用C#的不安全关键字。但是,不安全的代码只是无法验证的代码,这意味着它不会在Unity Web Player中运行,还可能包括一些其他平台。由于这个问题和其他原因,不要使用不安全的关键字。因为堆栈的上述限制,还因为C#数组是只是System.Array(这是一个类)的语法糖,你不能也不应该回避自动堆分配。你应该避免的是不必要的堆分配,我们会在这个帖子下一个(也是最后一个)部分讲到这个。

当谈到释放的时候你的力量是一样的有限。其实,可以释放堆对象的唯一过程是GC,而它的工作原理是不可见的。你可以影响的是对任何一个对象的最后一个引用在堆中超出范围的时机,因为在此之前,GC都不能碰他们。这种限制有巨大的实际意义,因为周期性的垃圾收集(你无法抑制)往往在没有什么释放的时候是非常快的。这一事实为构建对象池的各种方法提供了基础,我在第三篇帖子讨论。

不必要的堆分配的常见原因

你应该避免foreach循环吗?

在Unity 论坛和其他一些地方我经常碰到的常见建议是避免foreach循环,并用for或者while代替。乍一看理由似乎很充分。Foreach真的只是语法糖,因为编译器会这样把代码做预处理:

foreach (SomeType s in someList)   
s.DoSomething();
...into something like the the following:
using (SomeType.Enumerator enumerator = this.someList.GetEnumerator()){   
 while (enumerator.MoveNext())    {       
       SomeType s = (SomeType)enumerator.Current;
       s.DoSomething();    
}}

换句话说,每次使用foreach都会在后台创建一个enumerator对象-一个System.Collections.IEnumerator接口的实例。但是是创建在堆上的还是在堆栈上的?这是一个好问题,因为两种都有可能!最重要的是,在System.Collections.Generic 命名空间里几乎所有的集合类型(List<T>, Dictionary<K, V>, LinkedList<T>, 等等)都会根据GetEnumerator()的实现聪明地返回一个struct。这包括伴随着Mono2.6.5的所有集合版本。(Unity所使用)

Matthew Hanlon指出微软现在的C#编译器和Unity正在使用编译你的脚本的老的Mono/c#编译器之间一个不幸的差异。你也许知道你可以使用Microsoft Visual Studio来开发甚至编译 Unity/Mono 兼容的代码。你只需要将相应的程序集放到‘Assets’目录下。所有代码就会在Unity/Mono运行时环境中执行。但是,执行结果还是会根据谁编译了代码不一样。Foreach循环就是这样一个例子,这是我才发现的。尽管两个编译器都会识别一个集合的GetEnumerator()返回struct还是class,但是Mono/C#有一个会把struct-enumerator装箱从而创建一个引用类型的BUG。

所以你觉得你该避免使用foreach循环吗?

  • 不要在Unity替你编译的时候使用
  • 在用最新的编译器的时候可以使用用来遍历standard generic collections (List<T> etc.)Visual Studio或者免费的 .NET Framework SDK 都可以,而且我猜测最新版的Mono 和 MonoDevelop也可以。

当你在用外部编译器的时候用foreach循环来遍历其他类型的集合会怎么样?很不幸,没有统一的答案。用在第二篇帖子里提到的技术自己去发现哪些集合是可以安全使用foreach的。

你应该避免闭包和LINQ吗?

你可能知道C#提供匿名函数和lambda表达式(这两个几乎差不多但是不太一样)。你能分别用delegate 关键字和=>操作符创建他们。他们通常都是很有用的工具,并且你在使用特定的库函数的时候很难避免(例如List<T>.Sort()) 或者LINQ

匿名方法和lambda会造成内存泄露吗?答案是:看情况。C#编译器实际上有两种完全不一样的方法来处理他们。来看下面小段代码来理解他们的差异:

1 int result = 0;   
2 void Update(){   
3 for (int i = 0; i < 100; i++)    {        
4     System.Func<int, int> myFunc = (p) => p * p;       
5      result += myFunc(i);    
6 }}

正如你所看到的,这段代码似乎每帧创建了myFunc委托 100次,每次都会用它执行一个计算。但是Mono仅仅在Update()函数第一次调用的时候分配内存(我的系统上是52字节),并且在后续的帧里不会再做任何堆的分配。怎么回事?使用代码反射器(我会在下一篇帖子里解释)就会发现C#编译器只是简单的把myFunc替换为System.Func<intint>类的一个静态域。

我们来对这个委托的定义做一点点改变:

  System.Func<int, int> myFunc = (p) => p * i++;

通过把‘p’替换成’i++’,我们把可以称为’本地定义的函数’变成了一个真正的闭包。闭包是函数式编程的核心。它们把函数和数据绑定在一起-更准确的说,是和在函数外定义的非本地变量绑定。在myFunc这个例子里,’p’是一个本地变量但是’i’不是,它属于Update()函数的作用域。C#编译器现在得把myFunc转换成可以访问甚至改变非本地变量的函数。它通过声明(后台)一个新类来代表myFunc创造时的引用环境来达到这个目的。这个类的对象会在我们每次经历for循环的时候创建,这样我们就突然有了一个巨大的内存泄露(在我的电脑上2.6kb每帧)。

当然,在C#3.0引入闭包和其他一些语言特性的主要原因是LINQ。如果闭包会导致内存泄露,那在游戏里使用LINQ是安全的吗?也许我不适合问这个问题,因为我总是像躲瘟疫一样避免使用LINQ。LINQ的一部分显然不会在不支持实时编译(jit)的系统上工作,比如IOS。但是从内存角度考虑,LINQ也不是好的选择。一个像这样基础到难以置信的表达式:

 

1 int[] array = { 1, 2, 3, 6, 7, 8 };
2 void Update(){   
3  IEnumerable<int> elements = from element in array                    
4 orderby element descending                   
5  where element > 2                    
6 select element;    ...}

在我的系统上每帧需分配68字节(Enumerable.OrderByDescending()分配28,Enumerable.Where()40)!这里的元凶甚至不是闭包而是IEnumerable的扩展方法:LINQ必须得创建中间数组以得到最终结果,并且之后没有适当的系统来回收。虽然这么说,但我也不是LINQ方面的专家,我也不知道是否部分可以再实际中可以使用。

协程

如果你通过StartCoroutine()来启动一个协程,你就隐式创建了一个UnityCoroutine类(21字节)和一个Enumerator 类(16字节)的实例。重要的是,当协程 yield和resume的时候不会再分配内存,所以你只需要在游戏运行的时候限制StartCoroutine() 的调用就能避免内存泄露。

字符串

对C#和Unity内存问题的概论不提及字符串是不完整的。从内存角度考虑,字符串是奇怪的,因为它们既是堆分配的又是不可变的。当你这样连接两个字符串的时候:

1 void Update(){   
2  string string1 = "Two";   
3  string string2 = "One" + string1 + "Three";
4 }

运行时必须至少分配一个新的string类型来装结果。在String.Concat()里这会通过一个叫FastAllocateString()的外部函数高效的执行,但是没有办法绕过堆分配(在我的系统里上述例子占用40字节)。如果你需要动态改变或者连接字符串,使用System.Text.StringBuilder

装箱

有时候,数据必须在堆栈和堆之间移动。例如当你格式化这样的一个字符串:

string result = string.Format("{0} = {1}", 5, 5.0f);

你是在调用这样的函数:

 

1 public static string Format(    
2 string format,    
3 params Object[] args)

换句话说,当调用Format()的时候整数5和浮点数’5.0f’必须被转换成System.Object但是Object是一个引用类型而另外两个是值类型。C#因此必须在堆上分配内存,将值拷贝到堆上去,然后处理Format()到新创建的int和float对象的引用。这个过程就叫装箱,和它的逆过程拆箱。

对 String.Format()来说这个行为也许不是一个问题,因为你怎样都希望它分配堆内存(为新的字符串)。但是装箱也会在意想不到的地方发生。最著名的一个例子是发生在当你想要为你自己的值类型实现等于操作符“==”的时候(例如,代表复数的结构)。阅读关于如果避免隐式装箱的例子点这里here

库函数

为了结束这篇帖子,我想说许多库函数也包含隐式内存分配。发现它们最好的方法就是通过分析。最近遇到的两个有趣的例子是:

  • 之前我提到foreach循环通过大部分的标准泛集合类型并不会导致堆分配。这对Dictionary<K, V>也成立。然而,神奇的是,Dictionary<K, V>集合和Dictionary<K, V>.Value集合是类类型,而不是结构。意味着 “(K key in myDict.Keys)..."需要占用16字节。真恶心!
  • List<T>.Reverse()使用标准的原地数组翻转算法。如果你像我一样,你会认为这意味着不会分配堆内存。又错了,至少在Mono2.6里。有一个扩展方法你能使用,但是不像.NET/Mono版本那样优化过,但是避免了堆分配。和使用List<T>.Reverse()一样使用它:
public static class ListExtensions{    
public static void Reverse_NoHeapAlloc<T>(this List<T> list)    {       
     int count = list.Count;       
     for (int i = 0; i < count / 2; i++)        { 
              T tmp = list[i];          
        list[i] = list[count - i - 1];            
     list[count - i - 1] = tmp;        
}    
}}                    

还有其他的内存陷阱可以写的。但是,我不想给你更多的鱼了,而是教你自己捕鱼。这就是下篇帖子的内容!

posted @ 2015-03-16 15:03  易山松  阅读(3688)  评论(0编辑  收藏  举报