Unity開發者的C#記憶體管理

Kaitiren發表於2015-09-12
很多遊戲時常崩潰,大多數情況下都是記憶體洩露導致的。這系列文章詳細講解了記憶體洩露的原因,如何找到洩露,又如何規避。


我要在開始這個帖子之前懺悔一下。雖然一直作為一個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作為一個字串常量。



相關文章