首先在學習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,代碼修改很簡單,這邊就不在贅述了。