《圖解 C# 教程 第 5 版》與效能優化(附 Unity 專案)

貓冬發表於2020-02-01

這本書仍然是入門 C# 最好的一本書。

這本書新版出來的時候我十分關注,於是英子姐送了一本給我,本文也是答應英子姐所寫的一篇文章。她一開始還問我“你現在還需要看這本入門書嗎?”,我認為是的。工作了遇到了不少問題,大都跟自己基礎不牢有關係。

這本書以圖形為載體,生動地介紹了 C# 語言本身。其中圖形對我們瞭解 C# 語法在記憶體中的本質十分有幫助,非同步、異常等章節中的處理流程圖也很清晰明瞭,這也是我看重的一點。

在記憶體中的形態

為什麼要了解 C# 在記憶體中的形態呢?

書中第四章介紹記憶體區域的棧中,有一句話說的很好:

作為程式設計師,你不需要顯式地對它做任何事情。但瞭解棧的基本功能可以更好地瞭解程式在執行時在做什麼,並能更好地瞭解 C# 文件和著作。

遊戲開發中,除了業務邏輯,我們還會更關注遊戲的效能本身。我們需要保證遊戲能流暢執行在大部分機型上,保證每一幀能流暢地播放,例如 CPU 需要處理渲染程式碼、物理模擬、動畫回撥等等,其中我們的程式碼也有可能引起效能問題。我們需要更瞭解執行程式碼的代價,例如:

  • 這些程式碼產生了多少 GC
  • GC 只會產生一次還是每幀都會產生
  • 在極端情況下程式碼的效能如何
  • 是否使用了正確的資料結構
  • Unity API 或者一些庫 API 的背後到底做了什麼
  • ...

這本書相對上一版多了 .Net Core, C# 7.0 語法的講解,對於我而言,重溫的是第 4、7、11、13、15、17、19 和 27 章節,這些內容是我工作中經常要接觸、著手優化的地方。書中對於非同步程式設計也介紹地很好,但對於我來說,反射、非同步程式設計、新增語法等到以後有需要再看也不遲。

指令碼的效能優化,無非是用更合適的程式碼去實現需求,不必要的記憶體都給我吐出來!(注:作者在生活中並沒有這麼吝嗇)

下面會列舉一些程式碼寫法的效能對比。

結構和類

這其實也是用棧還是用堆的考量。

垃圾回收

Unity 用的是 mono 虛擬機器,其堆的記憶體是通過垃圾回收演算法 Boehm GC 來管理的,其不分代(Non-generational)和非壓縮式(Non-compacting)的特性,導致了我們平常要注意避免載入過多的小記憶體,從而記憶體碎片化(Memory fragmentation)。

  • 分代:大塊記憶體、小記憶體、超小記憶體分在不同記憶體區域來進行管理。此外還有長久記憶體,當有一個記憶體很久沒動的時候會移到長久記憶體區域中,從而省出記憶體給更頻繁分配的記憶體。

  • 壓縮式:當有記憶體被回收的時候,壓縮記憶體會把下圖空的地方重新排布。 壓縮式

  • 記憶體碎片化:記憶體過多小記憶體,導致大記憶體不能有效地被使用。 記憶體碎片化

具體可以參考 Unity 文件 Understanding the managed heap

同時也推薦高川老師的演講:淺談 Unity 記憶體管理,和我看視訊時的筆記:筆記

用結構還是類

這裡推一篇微軟官方文件:Choosing Between Class and Struct

引用型別被分配在堆上並被垃圾回收演算法管理,值型別則分配在棧上,棧會按需 unwind 來釋放他們。因此,值型別的釋放比引用型別的釋放開銷要小。

書中 11.9 小節還提到:

對結構進行分配的開銷比建立類例項小,所以使用結構代替類有時可以提高效能,但要注意裝箱和拆箱的高昂代價。

值型別陣列的分配和釋放比引用型別陣列的分配和釋放開銷也更小。

除了最基本的修改值型別和引用型別的區別外,要注意的是傳遞引數或者返回返回值的時候,值型別都會隱性地被建立,這可能也會產生沒想到的記憶體開銷。

從 .Net 記憶體分配成本的角度來說,類的物件儲存的記憶體首先需要分配 4 個位元組作為物件頭位元組(object header word),跟著再分配 4 個位元組作為方法表指標(method table pointer),這些欄位是服務於 JIT 和 CIL 的,是隱藏的分配成本。

保留在堆中所需的記憶體還會根據作業系統位數來決定:

  • 32 位系統中,堆上的物件會對齊到最近 4 位元組的倍數,因此如果一個物件只有一個 byte 成員,也需要對齊佔 4 個位元組,因此這個物件總共佔堆上 12 個位元組。
  • 64 位系統中,堆上的物件會對齊到最近 8 位元組的倍數,方法表指標和物件頭位元組也會分別佔 8 位元組的記憶體。

(注:平常開發我們不需要這麼摳門,上面只是一個小知識點。)

大多時候我們都會用型別來實現設計模式、框架的設計,那什麼時候使用結構體呢?我們可以遵循微軟爸爸的建議:

  • 邏輯上表示單個值
  • 大小小於 16 個位元組
  • 不會改變值
  • 不需要經常裝箱拆箱

對於一些特定的場景下,我們也可以享受值型別陣列在記憶體中線性排布的福利,例如記憶體連續、SIMD 等。Unity 的 DOTS 技術棧就是一個很好的例子。

推薦閱讀:

  • 在C#中使用Struct代替Class
  • 作者用 DOTS(結構體、Job System、Burst)實現 A 星尋路實現效能飛躍:y2b 搜 “Pathfinding in Unity DOTS!”

裝箱

第 17 章介紹了轉換,其中提到了裝箱拆箱。那麼裝箱的代價有多大呢?我們可以做個測試:

public const int Iterations = 100_000;

// 其他地方初始化了 Iterations 大小的隨機陣列
private int[] numberArr = null; 

protected override bool MeasureTestA()
{
    // 設定大小以避免自動擴容帶來的效能消耗
    Stack stack = new Stack(Iterations); 
    for (int i = 0; i < Iterations; i++)
    {
        stack.Push(numberArr[i]); // int -> object 裝箱
    }

    return true;
}

protected override bool MeasureTestB()
{
    Stack<int> genericStack = new Stack<int>(Iterations);
    for (int i = 0; i < Iterations; i++)
    {
        genericStack.Push(numberArr[i]);
    }

    return true;
}

Unity Profiler

檢視 Profiler 可以看到,呼叫十次 TestA 產生了 26.7MB GC,用了 267.22 毫秒;呼叫十次 TestB 只產生了 3.8MB GC,用了 20.92 毫秒。因此大量的裝箱拆箱會導致不必要的效能消耗,而有些消耗則是完全可以避免的。

我的 Rider 外掛 Heap Allocations Viewer 也會提示我 TestA 中存在裝箱的情況。

Rider 外掛

最後

寫到這裡,發現還很多東西沒講完就已經這麼多篇幅了...

大家對於平常程式碼的不同寫法也可以測試下效能,例如:

  • foreach 和 for
  • 裝箱和拆箱
  • 一維陣列、多維陣列(矩形陣列)和交錯陣列(Jagged Arrays)
    • 這裡還是強調下儘量使用一維陣列,實在需要用多維陣列的話,可以改用交錯陣列
  • 通過 for 迴圈複製陣列和 Array.CopyTo 方法
  • 字串拼接,string 和 Stringbuilder
  • 反射和 DynamicMethod
  • ...

上面裝箱的截圖中的測試專案我也上傳了 Github:Latias94/UnityCsharpPerformanceTest,不用 Unity 的同學也可以參考下實際的測試程式碼:UnityCsharpPerformanceTest/Assets/Scripts,自己寫個命令列專案來跑下對比。

當然我們開發中還是要以需求的變化為主,不能過早優化從而破壞程式碼的擴充套件性。

相關文章