Unity C# Job System

首先在學習ECS之前,我們先來了解了解ECS所依賴的Job System,這樣方便我們可以更輕鬆的讀懂ECS的一些代碼。

 

簡介

利用Job System,我們可以編寫簡單並且安全的多線程代碼,來提高遊戲性能。

官方文檔:https://docs.unity3d.com/Manual/JobSystem.html

 

NativeContainer

在Job System中我們會使用到一種新的類型NativeContainer。它是一種託管值類型,爲本機內存提供了一個相對安全的 C# 封裝器,它包含一個指向非託管分配的指針。

與 Job System一起使用時,NativeContainer允許Job訪問與主線程共享的數據,而不是拷貝數據。如果是拷貝數據會導致同樣的數據到不同的Job中,其結果是相互隔離的,因此我們需要將結果存儲在共享內存中,也就是NativeContainer。

Unity 附帶了一個名爲NativeArray<T>的NativeContainer,用來代替傳統的數組(T[])。

ECS爲其進行了拓展Unity.Collections命名空間以包含其他類型的 NativeContainer:

  • NativeList<T> - 可調整大小的 NativeArray,類似於List<T>
  • NativeHashMap<T, R> - 鍵/值對,類似於Dictionary<T, R>
  • NativeMultiHashMap<T, R> - 每個鍵有多個值。
  • NativeQueue<T> - 先進先出隊列,類似於Queue<T>

創建 NativeContainer時,必須指定所需的內存分配類型(Allocator),分配類型取決於Job運行的時間。設置不同的值以便在每種情況下獲得最佳性能。

  • Allocator.Temp - 具有最快的分配速度。此類型適用於壽命爲一幀或更短的分配。不應該使用 Temp 將 NativeContainer 分配傳遞給Job。在從方法調用返回之前,需要調用 Dispose 方法。
  • Allocator.TempJob - 的分配速度比 Temp 慢,但比 Persistent 快。此類型適用於壽命爲四幀的分配,並具有線程安全性。如果沒有在四幀內對其執行 Dispose 方法,控制檯會輸出警告。大多數邏輯量少的Job都使用這種類型。
  • Allocator.Persistent - 是最慢的分配,但可以在您所需的任意時間內持續存在,如果有必要,可以在整個應用程序的生命週期內存在。此分配器是直接調用 malloc 的封裝器。持續時間較長的Job可以使用這種類型。在非常注重性能的情況下不應使用 Persistent。

例如:

NativeArray<float> result = new NativeArray<float>(10, Allocator.TempJob);

注:使用NativeContainer需要我們手動Dispose,而不是等GC的時候自動釋放

 

我的第一個Job

創建

如何創建Job呢,其實很簡單,只需要創建一個結構體並且實現IJob接口即可。例如我們要創建一個做加法運算的Job,代碼如下:

public struct AdditionJob : IJob
{
    [ReadOnly] public float a;
    [ReadOnly] public float b;
    [WriteOnly] public NativeArray<float> result;

    public void Execute()
    {
        result[0] = a + b;
    }
}

可以看到,我們對加法運算後的結果值的類型使用了NativeArray。因爲這個值是在Job內部修改的,若不使用前面提到的NativeArray,外部就無法正確的獲取到Job修改後的該值。

同時若有些字段並不需要讀寫兩種屬性,我們可以爲其配置 [ReadOnly] 或 [WriteOnly] 的標籤來提升性能

注意,在Job中我們能使用的類型只有NativeContainer和Blittable兩種類型。

Blittable類型包括:System.Byte | System.SByte | System.Int16 | System.UInt16 | System.Int32 | System.UInt32 | System.Int64 | System.UInt64 | System.IntPtr | System.UIntPtr | System.Single | System.Double

System.Boolean | System.Char,bool和char經測試也可使用

 

執行

創建完Job之後,自然是如何使用它了,看下面這段代碼。

AdditionJob additionJob = new AdditionJob();
additionJob.a = 1;
additionJob.b = 10;
additionJob.result = new NativeArray<float>(1, Allocator.TempJob);
additionJob.Schedule().Complete();
Debug.Log(additionJob.result[0]);//Log 11
additionJob.result.Dispose();

和別的Struct一樣,我們要收實例化這個Job,然後對其內部字段進行賦值,接下來調用的兩個方法則是重點了

Schedule方法:也稱調度,會返回一個JobHandle對象,調用該方法則將該Job放入Job隊列中,以便在適當的時間執行。一旦Job被調度,就不能中斷該Job了。只能從主線程調用 Schedule,Job會在子線程中被執行。

Complete方法:則是主線程等待Job執行完成。

除了Schedule外,我們還可以使用IJob.Run()方法來執行Job,但是使用該方法Job將在主線程被執行,因此不建議使用。

最後記住要釋放我們的NativeArray,這樣我們就實現了執行一個最簡單的Job了。

 

Job之間的依賴關係

若我們有多個Job,其中有些Job需要在別的Job執行完得到結果後再執行。面對這種情況,我們應該如何處理?

舉個例子,假設我們有一個新的Job,用來給自身的值+1,類似於++,代碼如下

public struct AddOneJob : IJob
{
    public NativeArray<float> result;
    public void Execute()
    {
        result[0] = result[0] + 1;
    }
}

接着我們實例化我們的兩個Job並賦值

NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

AdditionJob additionJob = new AdditionJob();
additionJob.a = 1;
additionJob.b = 10;
additionJob.result = result;

AddOneJob addOneJob = new AddOneJob();
addOneJob.result = result;

我們需要的效果是先執行完AdditionJob後再執行AddOneJob,此時我們就需要使用到Job的依賴了。

當我們調用Schedule方法時會返回一個JobHandle的對象,我們可以將其作爲參數傳遞到下個Job的Schedule方法中,就可以形成依賴關係,代碼如下:

JobHandle additionJobHandle = additionJob.Schedule();
addOneJob.Schedule(additionJobHandle).Complete();
Debug.Log(result[0]);//Log 12
result.Dispose();

這樣AddOneJob就會在AdditionJob執行完成後再執行了。

若一個Job依賴於多個Job,我們可以利用JobHandle.CombineDependencies()方法來合併JobHandle,合併完成後再傳遞給Job的Schedule方法中即可

NativeArray<JobHandle> handles = new NativeArray<JobHandle>(numJobs, Allocator.TempJob);
handles[0] = job1;
handles[1] = job2;
handles[2] = job3;
......
JobHandle allJobHandle = JobHandle.CombineDependencies(handles);

 

IJobFor和IJobParallelFor

前面的Job中,我們只能對單個對象進行處理,若我們想要對大量對象進行一樣的處理,例如對兩個List的數據進行相加(當然Job中無法使用List或者Array這些,我們需要用NativeContainer來代替)就可以通過實現 IJobFor 或者 IJobParallelFor 來處理。

首先介紹一下IJobFor ,代碼如下:

public struct BatchAdditionJob : IJobFor
{
    [ReadOnly] public NativeArray<float> a;
    [ReadOnly] public NativeArray<float> b;
    [WriteOnly] public NativeArray<float> result;
    
    public void Execute(int index)
    {
        result[index] = a[index] + b[index];
        Debug.Log($"threadId:{System.Threading.Thread.CurrentThread.ManagedThreadId}  index:{index}");
    }
}


BatchAdditionJob batchAdditionJob = new BatchAdditionJob();
batchAdditionJob.a = new NativeArray<float>(7, Allocator.TempJob);
batchAdditionJob.a[0] = 1;
batchAdditionJob.a[1] = 3;
batchAdditionJob.a[2] = 5;
batchAdditionJob.b = new NativeArray<float>(7, Allocator.TempJob);
batchAdditionJob.b[1] = 5;
batchAdditionJob.b[2] = 4;
batchAdditionJob.b[3] = 3;
batchAdditionJob.result = new NativeArray<float>(7, Allocator.TempJob);
batchAdditionJob.Schedule(batchAdditionJob.result.Length, new JobHandle()).Complete();
foreach (var result in batchAdditionJob.result)
{
    Debug.Log(result);
}
batchAdditionJob.a.Dispose();
batchAdditionJob.b.Dispose();
batchAdditionJob.result.Dispose();

和IJob.Schedule不同的是,IJobFor.Schedule多了兩個參數,第一個參數即要處理的數據長度,第二個參數爲一個JobHandle。使用這種方法,Job會在一個子線程中執行,因此index是有序的。若我們想Job在多個子線程中並行執行可以使用下面方法:

batchAdditionJob.ScheduleParallel(batchAdditionJob.result.Length, 3, new JobHandle()).Complete();

使用ScheduleParallel方法,可以讓我們的Job在多個子線程中同時運行,這種情況下我們的index就是無序的了,該方法中第二個參數的值表示每個子線程可以處理多少項。(例如我們Demo中一共有7項數據,若填1,則會開啓7個子線程來處理,填3,則會有三個子線程來處理)

 

而IJobParallelFor就是完全使用多線程的Job接口了,其實 IJobParallelFor.Schedule 等同於 IJobFor.ScheduleParallel,代碼修改很簡單,這邊就不在贅述了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章