Unity开发者的C#内存管理

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


我要在开始这个帖子之前忏悔一下。虽然一直作为一个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#开发中的具体需要。


垃圾收集的限制
大多数现代操作系统划分动态内存为栈和堆(1, 2),许多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<int, int>类的一个静态域。


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


  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;        
}    
}}                    




在 .NET/Mono 和Unity里内存管理的基础,并且提供了一些避免不必要的堆分配的建议。第三篇会深入到对象池。所有的都主要是面向中级的C#开发者。


我们现在来看看两种发现项目中不想要的堆分配的方法。第一种-Unity profiler-实在是太简单了,但是却相当费钱,得买’pro‘版的。第二种是讲你的.NET/Mono程序集反汇编成中间语言(CIL)然后再检查。如果你从没见过反汇编的.NET代码,继续看下去,不难,而且免费还很有启发意义。


容易的方法:使用Unity profiler


Unity优秀的分析器主要被用来分析游戏中各种资源需要的性能和资源:着色器,纹理,音频,游戏对象等等。然而分析器在发掘内存上也一样有用-跟你的C#代码的行为有关-甚至是外部的 没引用UnityEngine.dll的.NET/Mono程序集!在当前Unity版本中(4.3),这个功能不是来自内存分析器,而是CPU分析器。到C#代码的时候,内存分析器只是展示Mono堆的总大小和已使用的量。






这样让你看你的C#代码是否有嫩村泄露实在太粗糙了。即使不适用任何脚本,已使用的堆大小也会持续增长和缩减。只要你使用脚本,你需要一个看哪里分配了内存的途径,然后CPU分析器刚好给你提供这个。


让我们来看看一些实例代码。假设下面的脚本绑定到了一个GameObject上。


复制代码
1 using UnityEngine;using System.Collections.Generic;
2 public class MemoryAllocatingScript : MonoBehaviour{    void Update()    {        
3 List<int> iList = new List<int>(new int[] { 
4 072, 101, 108, 108, 111, 032, 119, 111, 114, 108, 100, 033 });        
5 string result = "";       
6  foreach (int i in iList.ToArray())            
7 result += ((char)i).ToString();       
8  Debug.Log(result);    }}
复制代码
它所做的就是通过一组整数用一种绕的方法创建了一个字符串("Hello world!"),一路上造成了不必要的内存分配。多少呢?很高兴你问了,但是我很懒,就让我们看看CPU分析器吧。选中窗口顶部的”Deep Profiler“,可以跟踪到每帧的调用树。






正如你所见,堆内存在Update()函数过程中的5个不同位置被分配。这个列表的初始化,foreach循环里到数组的转换是多余的,每一个数字到字符的转换以及连接都需要分配内存。有趣的是,仅仅是调用Debug.Log()也会分配一大块内存-这点值得记下来,即使在生产环境中这段代码会被剔除。


如果你没有Unity Pro,但是恰巧有Microsoft Visual Studio,那就有替代Unity Profiler的方法来发掘调用堆栈。Telerik 告诉我他们的 JustTrace Memory profiler 有相似的功能 (see here). 然而, 我不知道它模仿Unity每帧记录调用树到了什么程度。更进一步,尽管对Unity项目的远程调试(通过UnityVS) 是可以的,我还是没有成功的把JustTrace用来分析被Unity调用的程序集。


只是稍微难一点点的方法:反汇编你的代码


CIL的背景知识


如果你已经有了一个.NET/Mono的反汇编器,开始用吧,不然我推荐ILSpy. 这个工具不仅是免费的,它还非常干净简单,但是刚好包含下面我们会用到的一个特殊功能。


你也许知道C#编译器不会将你的代码编译成机器语言,而是公共中间语言。这种语言是被原.NET团队作为一种包含两种来自高级语言特性的低级语言开发出来的。一方面,它与硬件无关,另一方面,它包含最适合被称为’面向对象’的特性,比如可以引用其他模块或者类的能力。


没有经过代码模糊处理( code obfuscator )的CIL代码是异常容易反向工程的。 许多情况下,结果几乎和原始的C#(VB)代码一样。ILSpy 可以替你做这件事,但是我们仅仅反汇编代码就可以了(ILSpy通过调用ildasm.exe来实现,.它是NET/Mono的一部分)。让我们从一个加两个整数的函数开始。


1 int AddTwoInts(int first, int second){    
2 int result = first + second;           
3  return result;
4 }
如果你愿意,你可以将这段代码粘贴到MemoryAllocatingScript.cs文件里。然后确保Unity编译了它,再用ILSpy打开编译了的库Assembly-Csharp.dll。如果你选择AddTwoInts() 方法,你会看到下面的:






除了蓝色的关键字 hidebysig,我们可以忽略掉,方法签名应该看起来差不多。要了解到方法里主要发生了什么,你需要知道CIL把CPU看成一个堆栈式机器stack machine 而不是寄存器机器register machine。CIL假设CPU可以处理非常基础,非常算法的指令,例如”将两个整数相加“,而且它可以处理任何内存地址的随机访问。CIL还假设CPU不直接在RAM上进行算术操作,而是首先需要将数据装载进概念上的计算堆栈。(注意计算堆栈和你你知道的C#堆栈没有任何关系。CIL计算堆栈只是一个抽象的,并且预设很小。)在行IL_0000到IL_0005发生了:


两个整型参数被推进堆栈。
加法被调用然后从堆栈里弹出开始位置的两个对象,自动将记过压进堆栈。
第3和4行可以忽略,因为在发行版本里会被优化掉。
这个方法返回堆栈的第一个值。
找到CIL里面的内存分配


CIL代码美在它不会隐藏任何堆分配。而且,堆分配会严格按照以下三个顺序分配,在你的反汇编代码里能看到。


newobj <constructor>:这创建了一个由constructor指定类型的未初始化的对象。如果这个对象是值类型,它就在堆栈上被创建。如果它是一个引用类型,就在堆上。你总是能从CIL代码知道类型,所以你可以容易的知道内存分配产生的地方。
newarr <element type>:这条指令在堆上创建了一个新的数组。Element的类型由参数指定。
box <value type token>:这条特殊的指令执行装箱操作,我们已经在第一篇帖子里说过。
Let's look at a rather contrived method that performs all three types of allocations.


然我们来看一个人为的执行这三种内存分配的方法。


复制代码
1 void SomeMethod(){    
2 object[] myArray = new object[1];    
3 myArray[0] = 5;    
4 Dictionary<int, int> myDict = new Dictionary<int, int>();
5 myDict[4] = 6;    
6 foreach (int key in myDict.Keys)    
7 Console.WriteLine(key);
8 }
复制代码
有这几行代码产生的CIL代码很多,所以这里我们只看关键部分:


IL_0001: newarr [mscorlib]System.Object...IL_000a: box [mscorlib]System.Int32...IL_0010: newobj instance void class [mscorlib]System.    Collections.Generic.Dictionary'2<int32, int32>::.ctor()...IL_001f: callvirt instance class [mscorlib]System.    Collections.Generic.Dictionary`2/KeyCollection<!0, !1>    class [mscorlib]System.Collections.Generic.Dictionary`2<int32,    int32>::get_Keys()


正如我们怀疑过的,对象的数组(SomeMethod()里的第一行)导致newarr指令。整数5被赋给数组的第一个元素需要装箱。Dictionary<int, int>是被newobj指令分配的。


但是还有第四个堆分配!正如我在第一篇帖子里提到的,Dictionary<K, V>. KeyCollection被声明为一个类,不是结构。这个类的一个实例会被创建,这样foreach蓄奴换才有迭代的对象。不幸的是,分配发生在Keys属性的getter方法里。正如你在CIL代码里看到,这个方法的名字是get_Keys(),而且它的返回值是一个类。


作为一个查找内存泄露的通用方法,你可以生成一个对你的整个程序集反汇编的CIL文件,只要在ILSpy按下Ctrl+S。然后用你喜欢的文本编辑器打开这个文件,搜索上面提到的三种指令。查出其他程序集里的内存泄露是有难度。我唯一知道的办法就是仔细检查你的C#代码,确认所有的外部方法调用,并且一个个地查看它们的CIL代码。你怎么知道什么时候就完成了?很简单:你的游戏可以流畅的运行好几个小时,不因为垃圾收集造成任何的性能瓶颈。


PS:在之前的帖子里,我答应要向你们展示如何确认你们系统上的Mono版本。只要装了ILSpy,没有比这更简单的了。在ILSpy里,点击打开然后找到Unity根目录。找到Data/Mono/lib/mono/2.0然后打开mscorlib.dll。在层级视图里,找到mscorlib/-/Consts,然后那儿你能找到MonoVersion作为一个字符串常量。



  • 2
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值