1. 程式人生 > >深入解讀Job System(1)

深入解讀Job System(1)

通常而言,最好不要把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/