Try   HackMD

掌握 Unity JobSystem 體驗多執行續的神奇力量 (一)-JobSystem 基本介紹

內容規劃

JobSystem 是 Unity 提供的一個多執行緒方案,讓原本在主執行緒上運行的代碼能夠輕鬆、安全和高效地運行在 worker thread 上,並且盡可能平行運行。這有助於減少主執行緒完成遊戲邏輯運算所需要的總時間,提供顯著的效能提升。

本系列文章將會由三篇組成:

  • 掌握 Unity JobSystem 體驗多執行續的神奇力量 (一)-JobSystem 基本介紹
  • 掌握 Unity JobSystem 體驗多執行續的神奇力量 (二)-以 JobSystem 實作鳥群模擬算法
  • 掌握 Unity JobSystem 體驗多執行續的神奇力量 (三)-深入探討 JobSystem

本文章為系列的第一篇。

大綱

多執行緒並行運算

在不考慮多執行緒的情況下,如果一個遊戲的 Update() 原先花費 24ms,我們的目標是將它降低到 16ms 好讓遊戲保有 60FPS 的表現,那麼我們只有兩個選擇:讓 CPU 運行得更快(通常不是好選擇),或者讓 CPU 做更少的工作。

void Update()
{
    // <lots of simulation logic...> 
}

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

在主執行緒上執行 24ms 的 Update() 方法

為了減少 8ms 的計算工作,通常可以通過改進演算法、將工作分散到多個幀、刪除冗於的計算等方式來嘗試達到目標,再不然就是透過削減遊戲內容和遊戲規格來降低計算的複雜性(這當然不理想)。

現在大多數的 CPU 都是多核心的,這表示如果我們能夠神奇而安全的將 Update() 方法中的工作分配給四個 CPU 核心,則原本需要 24ms 的 Update() 方法最後就只需要 6ms 即可完成工作!

void Update()
{
    // Some magic has split our logic into 4 equal parts
    // that can run in parallel. Wowee!
    PartialUpdateA();
    PartialUpdateB();
    PartialUpdateC();
    PartialUpdateD();
}

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

Update() 被分成四個部分更新,每個更新都在自己的執行緒上運行

別高興得太早,這都只是幻想,在沒有任何幫助的情況下,沒有什麼魔法可以將 Update() 方法分成幾部分並在單獨的核心上運行他們,儘管我們有 128 核心的 CPU,上面的 24ms Update() 方法仍然是需要 24ms 的執行時間。

如果沒有魔法可以做到這件事,那不是非常浪費多核心 CPU 的潛力嘛!沒關係,我們現在就要來說說 JobSystem 這個 Unity 提供的多執行緒方案如何達到我們的需求

Image Not Showing Possible Reasons
  • The image file may be corrupted
  • The server hosting the image is unavailable
  • The image path is incorrect
  • The image format is not supported
Learn More →

常用的 Job 類型

Job 指的是執行一項特定任務的工作單元,它可以接收參數並對資料進行操作,行為上其實就類似於調用一個方法。Job 可以獨立運行,也可以依賴於其他 Job 完成後才運行,在 Unity 中,Job 指的是任何實現 IJob 或類似介面的 struct,Job 的類型基本如下:

  • IJob
    最基礎的 job,在 worker thread 上運行一個單獨的任務。
    一個 job 的長相大致如下:
    • 實作 IJob 介面的 struct。
    • struct 內包含 blittable 類型與 NativeContainer 的成員變數。
    • IJob 需要實作 Excute() 方法。
struct MyJob : IJob
{ 
    public int a;
    public int b;
    public NativeArray<int> result;
    public void Execute()
    {
        result[0] = a + b;
    }
}
  • IJobFor
    一個會在內部用 for 迴圈呼叫每個 Excute(int index) 的 job,在一個執行緒上獨立運行。
struct MyJob : IJobFor
{ 
    public int a;
    public int b;
    public NativeArray<int> result;
    public void Execute(int index)
    {
        result[index] = (a + b) * index;
    }
}
  • IJobParallelFor
    如同 IJobFor 一樣會在內部用 for 迴圈呼叫每個 Excute(int index),不同的是 IJobParallelFor 能夠並行地運行。每個並行運行的 worker thread 都有一個獨占的 index 來安全地訪問他們之間的共享數據。
struct MyJob : IJobParallelFor
{ 
    public int a;
    public int b;
    public NativeArray<int> result;
    public void Execute(int index)
    {
        result[index] = (a + b) * index;
    }
}

專有名詞簡介

在真正開始使用 JobSystem 撰寫程式碼之前,先稍微介紹一些專有名詞,讓大家先有個概念,後面的章節中會再挑一些內容作重點介紹。

  • Job
    就像前面說的是一個執行一項特定任務的工作單元,在 worker thread 上運行。
  • Safety System
    為了避免發生 race condition 的錯誤狀況發生,job 內只能使用 blittable types 資料。
    Job 內的資料預設都是可以讀寫的,我們可以對每個資料加上 [ReadOnly]/[WriteOnly] 的標籤。
  • NativeContainer
    A thread-safe C# wrapper for native memory,它是一個能讓不同的執行緒之間共享的記憶體空間。我們能夠把主執行緒的資料透過 NativeContainer 提供給 job 進行讀寫。
  • NativeContainer Allocators
    NativeContainer 的記憶體空間配置與釋放有三種 Allocator 類型,在建立 NativeContainer 時,必須要將它指定為其中一種:
    • Allocator.Temp:最快的配置類型,存在時間小於等於一幀。
    • Allocator.TempJob:速度適中的配置類型,存在時間為四幀,需要在存續時間內主動呼叫 Dispose() 來釋放,否則會跳出警告。
    • Allocator.Persistent:最慢的配置類型,可以永久存在。
  • Job Schedule
    在我們建立出一個 job 之後,需要呼叫 Schedule() 方法,系統即會將 job 分派給某個 worker thread 執行。
    ​​​​MyJob _myJob = new MyJob {...};
    ​​​​_myJob.Schedule();
    
  • Job Handle
    Schedule() 方法會回傳一個 JobHandle,可以用它來判斷對應的 job 是否執行完畢,也可以用作另一個 job 呼叫 Schedule() 時的參數。
    ​​​​MyJob _myJobA = new MyJob {...};
    ​​​​JobHandle _jobAHandle = _myJobA.Schedule();
    
  • Job Dependencies
    Schedule() 方法取得 JobHandle 後,JobHandle 可以作為參數讓下一個 job 的 Schedule() 方法使用,表示下個 job 會等收到的 Handle 所對應的 job 執行完才開始執行。
    ​​​​MyJob _myJobB = new MyJob {...};
    ​​​​JobHandle _jobBHandle = _myJobB.Schedule(_jobAHandle);
    ​​​​//表示 _myJobA 執行完才開始執行 _myJobB
    
  • JobHandle Complete
    在主執行緒中呼叫 JobHandle 的 Complete() 方法,會讓主執行緒等到對應的 job 以及它相依的 job 都執行完畢後,才會繼續往下執行。如此便能讓我們在呼叫 Complete() 方法之後安全的使用 job 拿去讀寫的資料。
    ​​​​_jobBHandle.Complete();
    ​​​​// 下面可以安全的使用 _myJobA 與 _myJobB 拿去讀寫的資料
    
  • BurstCompile
    我們可以在 job 的 struct 加上 [BurstCompile] 標籤,能夠簡單無腦的優化 job 的執行效能。
    ​​​​[BurstCompile]
    ​​​​struct MyJob : IJob {...}
    

完整的 job 範例如下:

public class MyJobExample : MonoBehaviour {
    //主執行緒與 job 間共享的資料
    NativeArray<int> result;
    JobHandle handle;

    //一個簡單執行兩個 int 相加的 job
    struct MyJob : IJob {
        public int a;
        public int b;
        public NativeArray<int> result;

        public void Execute() {
            result[0] = a + b;
        }
    }
    
    void Start() {
        //建立一個持續存在的 NativeArray,長度為 1,執行緒之間可以共享
        result = new NativeArray<int>(1, Allocator.Persistent);
    }

    void Update() {
        //建立一個 job,將所需要的資料與 NativeArray 塞進去
        MyJob jobData = new MyJob {
            a = 10,
            b = 10,
            result = result
        };

        //呼叫 job 的 Schedule() 取得 JobHandle
        handle = jobData.Schedule();
    }

    private void LateUpdate() {
        //呼叫 JobHandle 的 Complete() 確保 job 執行完畢
        handle.Complete();
        
        //可以安全拿到 job 執行完的結果
        int res = result[0];
        Debug.Log(res);
    }
}