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

還有其他的內存陷阱可以寫的。但是,我不想給你更多的魚了,而是教你自己捕魚。這就是下篇帖子的內容!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM