Update 8/10 2016 : Unity 5.3.5p8 導入了 Mono Compiler 的初步更新解決了這個問題,如果你的專案可以升級到 5.3.6 或是 5.4 之後的版本的話以下描述的問題就不會再發生了。

官方公告:

https://forum.unity.com/threads/upgraded-c-compiler-on-5-3-5p8.417363/

因為最近有討論區的朋友提到 foreach 的 Garbage Collection 問題,所以想寫一篇為什麼 foreach 會有 Garbage Collection 的文章。這篇文章比較無趣一些,TD;LR 的話就是問題是 IDisposable 不是 IEnumerator<T>

Garbage Collection

Unity 在使用 foreach 的時候會產生 24 bytes 的 GC 這個問題已經傳很久了。可以用個簡單的小程式去測試:

1
2
3
4
5
6
7
8
class ForEachTest : MonoBehaviour
{
   private readonly List<int> list = new List<int> { 1, 2, 3 };
   void Update()
   {
       foreach (var element in list) { }
   }
}

隨便掛在一個 GameObject 下面的執行結果,在 Unity 4.7.0f1

Unity 4.7

現在最新的 Unity 5.3.1f1 上面的結果好像更糟了:

Unity 5

兇手是誰?

我一直以為是因為 System.Collections.Generic 底下所有的容器的 Enumerator 都被宣告成 struct ,然後 foreach 在操作的時候卻是對 IEnumerator<T> 操作 IEnumerator<T>.Current 跟 IEnumerator<T>.MoveNext() 造成了 boxing 。這周末心血來潮把 Unity 建置出來的 dll 放進 ILSpy 裡面看看,才發現以往的認知是錯的。

System.Collections.Generic 底下所有的容器的 Enumerator 都被宣告成 struct 的原因可以看 Eric Lippert(C# Compiler Team 的成員的解釋) :
http://stackoverflow.com/questions/3168311/why-do-bcl-collections-use-struct-enumerators-not-classes/3168435#3168435
基本上是效能考量

以下是範例程式

1
2
3
4
5
List<int> list = new List<int> { 1, 2, 3 };
foreach(var item in list)
{
    Debug.Log(item);
}

ILSpy 反組譯的結果

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
List<int> list = new List<int>();
list.Add(1);
list.Add(2);
list.Add(3);
List<int> list2 = list;
using (List<int>.Enumerator enumerator = list2.GetEnumerator())
{
    while (enumerator.MoveNext())
    {
        int current = enumerator.get_Current();
        Debug.Log(current);
    }
}

可以看到實際上 Unity 其實正確地使用 List<int>.Enumerator 來承接 list.GetEnumerator() 的回傳值。所以那個 boxing 到底在哪裡呢?

有了這條線索後,Google 了一下發現已經有人找到了真正的問題。

https://www.reddit.com/r/Unity3D/comments/34s0je/c_memory_and_performance_tips_for_unity/cqyf5yk/

要看到問題要把 ILSpy 的展示模式從 C# 換成 IL 模式。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
IL_001e: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0> class [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()
IL_0023: stloc.2
.try
{
    IL_0024: br IL_003c
    // loop start (head: IL_003c)
        IL_0029: ldloca.s 2
        IL_002b: call instance !0 valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()
        IL_0030: stloc.1
        IL_0031: ldloc.1
        IL_0032: box [mscorlib]System.Int32
        IL_0037: call void [UnityEngine]UnityEngine.Debug::Log(object)
        IL_003c: ldloca.s 2
        IL_003e: call instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()
        IL_0043: brtrue IL_0029
    // end loop
    IL_0048: leave IL_0059
} // end .try
finally
{
    IL_004d: ldloc.2
    IL_004e: box valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
    IL_0053: callvirt instance void [mscorlib]System.IDisposable::Dispose()
    IL_0058: endfinally
} // end handler

可以看到 box 出現在 IL_004e 行 finally 區塊裡,結果是舊版的 Mono 對有實作 IDisposable 的 struct 呼叫 Dispose 的時候(using 關鍵字觸發的)用了 IDisposable 去 box ,這跟我之前以為的不一樣。

然後更冤的可以看一下 List<T>.Enumerator 的 Dispose 實作:

http://referencesource.microsoft.com/#mscorlib/system/collections/generic/list.cs,d3661cf752ff3f44

因為 List<T>.Enumerator 是 value type ,所以根本就不需要特別處理。這個 Dispose 是空函式,整個 boxing 是 100% 的浪費。

Mono 對於這個 bug 的 issue 在這裡:

https://bugzilla.novell.com/show_bug.cgi?id=571010

可以看到  Mono 本家已經在 2010 6/1 修正了了這個問題,但是 Unity 還是沒有 merge 這個修正。考慮到 Unity 自己有 Mono 的 fork (https://github.com/Unity-Technologies/mono),很有可能 Unity 有對 Mono 做修改,改動到現在合併有困難。否則大家喊很久的 Mono 升級或是改用 Roslyn ,為什麼 Unity 一直無法從善如流。

我自己對於 foreach 的態度就是雖然效能較差還有少量 GC 問題,但是做取捨我還是會選 foreach 取其可讀性。要小心的是如果 foreach 放在其他的 loop 裡面的情況,累積起來還是有可能會造成問題。

參考資料:

C# memory and performance tips for Unity

原文:

http://www.somasim.com/blog/2015/04/csharp-memory-and-performance-tips-for-unity/

Reddit 討論:

https://www.reddit.com/r/Unity3D/comments/34s0je/c_memory_and_performance_tips_for_unity/

C# Memory Management for Unity Developers (part 1 of 3)

https://www.gamasutra.com/blogs/WendelinReich/20131109/203841/C_Memory_Management_for_Unity_Developers_part_1_of_3.php

Why do BCL Collections use struct enumerators, not classes?

http://stackoverflow.com/questions/3168311/why-do-bcl-collections-use-struct-enumerators-not-classes/3168435#3168435

ILSpy

http://ilspy.net/