深入解讀Job System(1)


https://mp.weixin.qq.com/s/IY_zmySNrit5H8i0CcTR7Q

通常而言,最好不要把Unity實體組件系統ECS和Job System看作互相獨立的部分,要把它們看作用於大幅提升游戲性能的組合系統。

 

本系列文章我們將深入了解使用二者開發項目的過程,從而使項目獲得高性能。今天我們來了解ECS和Job System的基礎知識,了解ECS請閱讀:《詳解實體組件系統ECS》。

 

什么是Job System

一些人認為Unity無法進行多線程處理,那個觀點是錯的,因為這是可以實現的,但是你可能無法使用任何Unity中特定的命名空間。你可以多線程處理不同類型的任務,只要任務不需要在主線程外訪問Transform或游戲對象即可,所以在獨立線程執行一些Vector3數學運算是沒有問題的。

 

如果你非常了解Unity相關知識,或許你已經知道引擎的部分功能已經實現多線程處理。現在加入Job System后,Unity允許我們利用它的多線程處理功能。

 

Job System允許我們輕松編寫多線程代碼,從而實現高性能游戲體驗。它不僅能改善幀率,而且在做移動開發時,它還能顯著改善移動設備的電池壽命。

 

通過該功能,我們能夠編寫和Unity引擎功能共享工作線程的代碼。

 

什么是多線程處理

通常在單線程程序中,每次只處理一個執行調用,一次只輸出一個結果。

 

程序性能主要取決於加載和完成所用的時間。單線程會按線性順序進行處理,需要的時間會比雙線程同時處理更長,這種多個線程同時處理就是我們說的多線程處理。

 

多線程處理會利用CPU功能來同時在多個內核處理多個線程。

 

默認情況下,“主線程”會在程序開始時運行。主線程會創建新線程來處理任務。這些新線程會並行運行,通常在完成后將結果與主線程同步。

 

多線程處理方法適合用來處理多個需要長時間運行的任務。然而,游戲開發代碼通常帶有很多需要同時執行的小指令。如果為每個小指令都創建一個線程,結果會得到很多線程,每個線程的生命周期都很短。從而導致CPU和操作系統處理能力達到極限。

 

你可以通過線程池來解決線程生命周期的問題,然而即使使用線程池,還是會同時有很多活動線程。如果線程數量比CPU內核數量多,會造成線程互相競爭CPU資源,並且頻繁切換上下文(Context switching)。

 

上下文切換是指切換線程時,會保存當前進程的執行狀態,然后處理另一線程,在重構第一個線程后,繼續處理該線程。上下文切換是個資源密集型過程,所以要盡量避免該過程。

 

Job System和傳統多線程的區別

 

在多線程處理時,要打開線程然后提供任務。你需要注意將輔助線程合並到主線程的時間,還要正確關閉線程。所以多線程處理需要你管理很多操作。

 

Job System使用不同的方法,因為我們不會創建任何線程,而是會使用Unity在多個內核上的工作線程,給它們提供任務-Unity稱之為Jobs作業。很容易看出,這種方法更為簡單,因為避免了管理線程時可能遇到的問題。不僅如此,我們還不必擔心出現競態條件。

 

通過內置的安全檢查,Job System可以檢測所有潛在的競態條件。通過給每個作業發送需要處理的數據副本而不是在主線程引用數據,Job System可以避免發生競態條件,進而消除競態條件,因為現在處理的是獨立數據而不是它的引用。

 

因此,作業只能訪問blittable數據類型。當在托管代碼和本地代碼之間傳遞數據時,該類型數據不需要轉換。

 

Unity使用C++方法復制的內存塊在Unity的托管部分和本地部分復制和傳遞數據。在調度作業時,我們會將數據放入本地內存,並在執行作業的同時允許托管部分訪問數據副本。

 

你甚至不必擔心發生上下文切換和CPU爭用,因為Unity通常在每個CPU內核有一個工作線程,作業會在這些線程間同步調度。

 

Job System中,所有作業都會放入隊列中。空閑工作線程會獲取作業,並按照隊列的順序執行。為了確保作業按照所需順序執行,我們可以利用作業依賴。

 

Job是什么

總的來說,每個作業(Job)都可以看作是方法調用,每個作業在創建時會得到數據和參數,之后用於執行過程。作業可以是獨立的,這意味着當它們什么時候完成對我們來說並不重要。或者在更合理情況下,它們可以擁有依賴。依賴能為我們帶來便利,因為它能讓代碼在正確的時間執行。

 

對多線程處理來說,這非常重要,你需要確保執行過程能避免發生競態條件,這意味着一項任務不必等待其它任務完成才執行,那樣會造成延遲。

 

所以基本上,依賴意味着我們的第二個任務依賴於第一個任務,第二個任務會在第一個任務完成后才開始執行。

 

句法

每個作業都需要實現以下三個類型的其中一個類型:IJob、IJobParallelFor或 IJobParallelForTransform。

 

IJobParallelFor用於需要多次並行執行單個任務的作業。JobParallelForTransform和IJobParallelFor差不多,尤其是用於處理Unity Transform時。

 

這些類型實際上都是接口,因此只要腳本中沒有Execute函數,編譯器就會出問題。還要記住,作業必須是nullable類型,這意味着它必須是struct,並且在任何情況下都不能是類,這是因為內存分配問題。

 

Unity創建新容器是為了讓我們能夠很容易就寫出線程安全的代碼。

using Unity.Collections;
using Unity.Jobs;

/*作業(Job)需要是可空類型,這意味着它們必須為struct結構…

每個作業都必須繼承自IJobParallelFor、IJobParallelForTransform或IJob*/

Every job has to inherit from either IJobParallelFor, IJobParallelForTransform or IJob */
public struct MyJob : IJobParallelFor {

/*在作業中,需要定義所有用於執行作業和輸出結果的數據

Unity會創建內置數組,它們大體上和普通數組差不多,但是需要自己處理分配和釋放設置*/

 public NativeArray<Vector3> waypoints;
 public float offsetToAdd;

/*所有作業都需要Execute函數*/

 public void Execute(int i)
 {

  /*該函數會保存行為。要執行的變量必須在該struct開頭定義。*/

   waypoints[i] = waypoints[i] * offsetToAdd;
 }
}

調度作業

現在已創建MyJob.cs struct,要如何使它工作呢?我們必須調度它。

 

通常該過程非常簡單,但需要注意,每個作業都需要被調度。那意味着我們首先發起作業,添加數據,然后發送到隊列中等待執行。一旦該過程發生,我們就無法中斷該過程。

 

Unity提供的常見句法參考中的作業代碼如下:

// 創建單個浮點數的本地數組(NativeArray)來存儲結果。為了更好說明功能,該示例會等待作業完成。

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

// 設置作業數據

MyJob jobData = new MyJob();
jobData.a = 10;
jobData.b = 10;
jobData.result = result;

// 調度作業

JobHandle handle = jobData.Schedule();

// 等待作業完成

handle.Complete();

//NativeArray的所有副本都指向相同內存,你可以在NativeArray的副本中訪問結果。

float aPlusB = result[0];

// 釋放結果數組分配的內存

result.Dispose();

 

這些正確的代碼,它可以正常執行,但帶有一些缺點,因為在調度完成后進行完成調用會產生短暫的等待時間,在性能分析器中,該時間稱為“Idle Time”。

 

相反如果你習慣調度作業,性能分析器中顯示的等待時間將最小化,而且會得到不錯的性能,至少在舊機器上效果會很明顯。

 

高效調度作業

在調度作業后,因為工作線程沒有時間完成任何任務。這造成在調度調用期間會產生空閑時間,會對性能產生影響。

 

本示例中,我們會創建struct,保存對句柄和本地數組的引用。為什么保存這些內容?

 

保存句柄是為了在之后調用作業,保存本地數組是因為需要釋放本地數組,NativeArray和常規數組的工作方式差不多,但是需要設置Allocator,用來定義數組在內存中的保留時間,本示例中使用Allocator.TempJob。

 

我們還需要在調用完成時釋放內存,然后復制數據。我們創建了JobResultAndHandle的引用,然后對它調用ScheduleJob()。這會使我們的作業開始調度,而且它的引用會保存在列表中。

 

然后我們可以查看列表中的每個條目,調用完成,復制執行數據,然后棄用NativeArray來釋放內存。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Collections;
using Unity.Jobs;

public class MyJobScheduler : MonoBehaviour 
{
 Vector3[] waypoints;
 float offsetForWaypoints;

  //我們將保存結果和句柄的列表

 List<JobResultAndHandle> resultsAndHandles = new List<JobResultAndHandle>();

 void Update() 
 {

/*我們會在需要時創建新的JobResultANdHandle(該代碼不必在Update方法中,因為它只是個示例)

然后我們會給ScheduleJob方法提供引用。*/


   JobResultAndHandle newResultAndHandle = new JobResultAndHandle();
   ScheduleJob(ref newResultAndHandle);

   /*如果ResultAndHAndles的列表非空,我們會在該列表進行循環,了解是否有需要調用的作業。*/

   if(resultsAndHandles.Count > 0)
   {
     for(int i = 0; i < resultsAndHandles.Count; i++){
       CompleteJob(resultsAndHandles[i]);
     }
   }
 }

  /* ScheduleJob會獲取JobResultAndHandle的引用,初始化並調度作業。

 void ScheduleJob(ref JobResultAndHandle resultAndHandle)
 {

    //我們會填充內置數組,設置合適的分配器

   resultAndHandle.waypoints = new NativeArray<Vector3>(waypoints, Allocator.TempJob);

   //我們會初始化作業,提供需要的數據

   MyJob newJob = new MyJob
   {
     waypoints = resultAndHandle.waypoints,
     offsetToAdd = offsetForWaypoints,
   };

  //設置作業句柄並調度作業

   resultAndHandle.handle = newJob.Schedule();
   resultsAndHandles.Add(resultAndHandle);
 }

  //完成后,我們會復制作業中處理的數據,然后棄用棄用內置數組

  //這一步很有必要,因為我們需要釋放內存

 void CompleteJob(JobResultAndHandle resultAndHandle)
 {
   resultsAndHandles.Remove(resultAndHandle);

   resultAndHandle.handle.Complete();
   resultAndHandle.waypoints.CopyTo(waypoints);
   resultAndHandle.waypoints.Dispose();
 }
}

struct JobResultAndHandle
{
 public NativeArray<Vector3> waypoints;
 public JobHandle handle;
}

JobHandles和依賴

對作業調用Schedule()會使它返回JobHandle。JobHandle對保留作業的引用非常有用,但也可以將它們用作其它作業的依賴。這是什么意思呢?

 

如果某個作業依賴其它作業的結果,我們可以將其它作業的句柄作為參數傳遞到myjobs調度方法中,這樣能讓該作業完成后執行我們的作業。

 

前文中提到的競態條件問題、線程等待線程的問題,以及使用多線程代碼的缺點問題都可以通過傳遞句柄來輕松避免。

 

小結

本文我們了解了Job System的基礎知識,在下一篇中我們將以網格變形項目為示例,講解Job System的使用,盡請期待!更多Unity最新功能介紹盡在Unity官方中文論壇(UnityChina.cn)!

 

本文來源:http://www.itskristin.me/

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM