[C#] 避免 Boxing/Unboxing 效能問題
之前在撰寫 Unity 套件的時候,因為不知道使用者會用什麼類別來傳資料,所以都轉成 object
型別來傳遞,後來發現這會有效能上的問題。本篇文章就來探討這個問題以及解法。
問題
根據官方文件,在 value type(如:int
、float
等)與 object
type 之間轉換會有效能問題:
- 如果將一個 value type 轉成
object
type 的話,C# 會額外在 heap 建立一個 instance 來儲存 value type 的值。這稱為 boxing。 - 反之將
object
type 轉回 value type 的話,C# 會先檢查該object
存的值(boxed value)是否可以轉到指定的 value type,然後將值複製回 value type。這稱為 unboxing。
如下圖(參考官方文件繪製):
再依這篇官方文件,boxing 比直接的 reference 賦值 1 最多差 20 倍的效能,unboxing 則最多差 4 倍。
解法
避免這種情況的解法是:建立一個資料類別來包裝要傳的值,並用這個類別的物件來傳遞。
傳送方
第一個類別 ContentBankA
會直接將 int
轉成 object
type 傳出去,這會有 boxing 的情況發生:
public class ContentBankA : MonoBehaviour
{
private readonly int[] _contents = {1, 2, 3, 4, 5};
public object GetContent(int id)
{
return _contents[id];
}
}
第二個類別 ContentBankB
則是將 int
先包在類別 DataWrapper
裡再傳出去:
public class ContentBankB : MonoBehaviour
{
private readonly int[] _contents = {1, 2, 3, 4, 5};
private DataWrapper _dataWrapper;
private void Awake()
{
_dataWrapper = new DataWrapper();
}
public object GetContent(int id)
{
_dataWrapper.value = _contents[id];
return _dataWrapper;
}
}
public class DataWrapper
{
public int value;
}
取用方
取用方也會依照傳送方使用對應的方式。第一個是類別是 ContentDisplayerA
,它會直接將 object
type 轉回 int
,這會有 unboxing 的情況發生:
public class ContentDisplayerA : MonoBehaviour
{
[SerializeField]
private ContentBankA _contentBank;
private int _content;
public void LoadContent()
{
_content =
(int) _contentBank.GetContent(Random.Range(0, 5));
}
}
第二個類別 ContentDisplayerB
則是從收到的 DataWrapper
物件中取值:
public class ContentDisplayerB : MonoBehaviour
{
[SerializeField]
private ContentBankB _contentBank;
private int _content;
public void LoadContent()
{
_content =
((DataWrapper) _contentBank.GetContent(Random.Range(0, 5))).value;
}
}
效能測試
測試類別
建立一個測試類別來分別執行這兩個類別數次:
public class TesterA : MonoBehaviour
{
[SerializeField]
private ContentDisplayerA _contentDisplayer;
private int _loadTimes = 100000;
private int _testIteration = 10;
private void Start()
{
var totalTicks = 0L;
var stopWatch = new Stopwatch();
for (var t = 0; t < _testIteration; ++t) {
stopWatch.Restart();
for (var i = 0; i < _loadTimes; ++i)
_contentDisplayer.LoadContent();
stopWatch.Stop();
totalTicks += stopWatch.ElapsedTicks;
}
Debug.Log(
$"{name}: " +
$"Average {totalTicks / (float)_testIteration:F1} ticks");
}
}
每一個測試會傳送與讀取資料十萬次,用 C# 的 StopWatch
來記錄執行時間,執行十次後,取平均做為結果。TesterB
與 TesterA
一樣,只是是取用 ContentDisplayerB
。
設置起來會像這個樣子:
測試結果
TesterA
是有 boxing/unboxing 的,而 TesterB
是用一個資料類別物件傳的,其結果是:
TesterA
: Average 75541.1 ticksTesterB
: Average 17795.3 ticks
可以看到效能約差 4.5 倍。
每次都建立資料類別物件
要注意的是,ContebtBankB
在使用資料類別傳送資料都是使用同一個物件。如果每次傳送都建立一個物件的話,其實就跟 boxing 的情況沒有兩樣了。如果 ContentBankB
長這樣:
public class ContentBankB : MonoBehaviour
{
private readonly int[] _contents = {1, 2, 3, 4, 5};
public object GetContent(int id)
{
var dataWrapper = new DataWrapper {
value = _contents[id]
};
return dataWrapper;
}
}
public class DataWrapper
{
public int value;
}
結果為:
TesterA
: Average 78446.0 ticksTesterB
: Average 77913.0 ticks
接著來測試傳送不同的資料。
傳遞 struct
struct 是 value type,所以轉換 struct 成 object
type 也會遇到 boxing 的問題。例如有個 struct 是這樣:
public struct PlayerData
{
public int id;
public string name;
public int hp;
}
則執行結果:
TesterA
: Average 108210.3 ticksTesterB
: Average 38747.1 ticks
效能差約 3 倍。
傳遞 reference type
如果傳遞的資料本來就是 reference type 的話,如:string
、類別等。這邊用一個字串陣列:
private readonly string[] _strings = {"a", "b", "c", "d", "f"};
結果為:
TesterA
: Average 26283.7 ticksTesterB
: Average 31247.4 ticks
可以看到因為都是 reference type,所以效能差不多。甚至 TesterB
因為多一個資料類別的轉換而稍為慢一點。
總結
如果有需要從 value type 轉成 object
type 的情況,就用一個資料類別來攜帶要傳送的值,並在使用方取出使用。要注意的是因為用的都是同一個資料類別物件,使用方要避免直接改到該物件的內容。
-
即 reference type 傳給另一個 reference type,
object
type 也是 reference type ↩