JobSystem 是 Unity 提供的一個多執行緒方案,讓原本在主執行緒上運行的代碼能夠輕鬆、安全和高效地運行在 worker thread 上,並且盡可能平行運行。這有助於減少主執行緒完成遊戲邏輯運算所需要的總時間,提供顯著的效能提升。
本系列文章將會由三篇組成:
本文章為系列的第一篇。
大綱
在不考慮多執行緒的情況下,如果一個遊戲的 Update()
原先花費 24ms,我們的目標是將它降低到 16ms 好讓遊戲保有 60FPS 的表現,那麼我們只有兩個選擇:讓 CPU 運行得更快(通常不是好選擇),或者讓 CPU 做更少的工作。
void Update()
{
// <lots of simulation logic...>
}
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();
}
Update()
被分成四個部分更新,每個更新都在自己的執行緒上運行別高興得太早,這都只是幻想,在沒有任何幫助的情況下,沒有什麼魔法可以將 Update()
方法分成幾部分並在單獨的核心上運行他們,儘管我們有 128 核心的 CPU,上面的 24ms Update()
方法仍然是需要 24ms 的執行時間。
如果沒有魔法可以做到這件事,那不是非常浪費多核心 CPU 的潛力嘛!沒關係,我們現在就要來說說 JobSystem 這個 Unity 提供的多執行緒方案如何達到我們的需求
Job 指的是執行一項特定任務的工作單元,它可以接收參數並對資料進行操作,行為上其實就類似於調用一個方法。Job 可以獨立運行,也可以依賴於其他 Job 完成後才運行,在 Unity 中,Job 指的是任何實現 IJob
或類似介面的 struct,Job 的類型基本如下:
IJob
介面的 struct。IJob
需要實作 Excute()
方法。struct MyJob : IJob
{
public int a;
public int b;
public NativeArray<int> result;
public void Execute()
{
result[0] = a + b;
}
}
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;
}
}
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 撰寫程式碼之前,先稍微介紹一些專有名詞,讓大家先有個概念,後面的章節中會再挑一些內容作重點介紹。
[ReadOnly]
/[WriteOnly]
的標籤。Dispose()
來釋放,否則會跳出警告。Schedule()
方法,系統即會將 job 分派給某個 worker thread 執行。
MyJob _myJob = new MyJob {...};
_myJob.Schedule();
Schedule()
方法會回傳一個 JobHandle
,可以用它來判斷對應的 job 是否執行完畢,也可以用作另一個 job 呼叫 Schedule()
時的參數。
MyJob _myJobA = new MyJob {...};
JobHandle _jobAHandle = _myJobA.Schedule();
Schedule()
方法取得 JobHandle
後,JobHandle
可以作為參數讓下一個 job 的 Schedule()
方法使用,表示下個 job 會等收到的 Handle 所對應的 job 執行完才開始執行。
MyJob _myJobB = new MyJob {...};
JobHandle _jobBHandle = _myJobB.Schedule(_jobAHandle);
//表示 _myJobA 執行完才開始執行 _myJobB
Complete()
方法,會讓主執行緒等到對應的 job 以及它相依的 job 都執行完畢後,才會繼續往下執行。如此便能讓我們在呼叫 Complete()
方法之後安全的使用 job 拿去讀寫的資料。
_jobBHandle.Complete();
// 下面可以安全的使用 _myJobA 與 _myJobB 拿去讀寫的資料
[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);
}
}