来源:The Unity Job System - YouTube

在Profiler窗口中,除了Main Thread,Unity还根据当前处理器核心数创建了Worker Thread。这些Worker Thread除了执行Unity内部功能外,我们也可以使用一些代码自定义使用它们。

Unity Worker Thread

一个基本的Job结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using Unity.Burst;
using Unity.Collections;

[BurstCompile]
public struct SquaresJob : IJob
{
public NativeArray<int> Nums;

public void Execute()
{
for(int i = 0; i < Nums.Length; i++)
{
Nums[i] *= Nums[i];
}
}
}

在这个Job中,我们给一个NativeArray的每一个数都乘方。

一个Job必须声明为struct,并且继承IJob接口,还需要声明一个Execute方法,方法体内部的代码会被worker线程异步执行。

NativeArray<int>Unity.Collections提供的数组,它不属于C#的原生数组(C# managed Array),它是Unmanaged,更好地内存管理,更适用于Job系统。Job系统也是可以使用C# managed objects的,但是需要复杂的处理。

Burst编译器无法使用C# managed Object

开启一个Job

1
2
3
4
5
6
7
8
9
10
11
//在主线程的某地方
//实例化一个Job
var job = new SquaresJob {Nums = myArray};
//Schedule这个Job,这意味着这个Job将会放在Global Job Queue里,等待一个空闲的worker线程去pull off这个job然后执行。
//Schedule返回一个handle,供主线程获取一些信息。
JobHandle handle = job.Schedule();
//调用handle.Complete有两个用处:
//1. 如果这个Job还没有完成,主线程会在此等待
//2. 当job完成之后,调用Complete会移除掉所有在JobQueue的Job,所以在调用此方法时要注意避免“Resuorce Leak”的情况
handle.Complete();

注意,只有主线程才可以“Schedule”和“Complete” Jobs。

主线程不可以处理当前正在“Schedule”中的Job里面的数据。例如,我们不可以这样:

1
2
3
4
5
var job = new SquaresJob {Nums = myArray};
JobHandle handle = job.Schedule();
Debug.Log(myArray[0]);//因为Unity Job System有safety check,所以会throws an exception。
handle.Complete();
//Debug.Log(myArray[0]);在Complete之后再处理是可以的。

Job之间的依赖

如果我们想在主线程中开启两个相同的Job,这两个Job引用了相同的NativeArray

1
2
3
4
5
6
7
//在主线程的某地方
//实例化两个Job
var job = new SquaresJob{ Nums = myArray };
var otherJob = new SquaresJob{ Nums = myArray };

JobHandle handle = job.Schedule();
JobHandle otherHandle = otherJob.Schedule();//安全检查出错

我们当然可以将两个Job分开运行:

1
2
3
4
5
6
7
8
9
10
11
12
//在主线程的某地方
//实例化两个Job
var job = new SquaresJob{ Nums = myArray };
var otherJob = new SquaresJob{ Nums = myArray };

JobHandle handle = job.Schedule();

handle.Complete();

JobHandle otherHandle = otherJob.Schedule();//安全检查出错

otherHandle.Complete();

但是这样会阻塞主线程。

最好的方式是使用Job Dependency:

1
2
3
4
5
6
7
8
9
10
11
//在主线程的某地方
//实例化两个Job
var job = new SquaresJob{ Nums = myArray };
var otherJob = new SquaresJob{ Nums = myArray };

JobHandle handle = job.Schedule();

JobHandle otherHandle = otherJob.Schedule(handle);//将第一个handle作为参数传入Schedule方法中,

handle.Complete();
otherHandle.Complete();

使用Job Dependency,第一个Job就成了第二个Job的Dependency,当第一个Job完成之时,不会把这个Job从Job Queue中除去,而是等所有它的Job Dependency都Complete之后再执行。而且安全检查也会知道当前Job和它的Dependencies不冲突。

Job Dependency可以为我们的Job设置执行顺序:

A <—— B <—— C <—— D

1
2
3
4
JobHandle handleA = jobA.Schedule();
JobHandle handleB = jobB.Schedule(handleA);
JobHandle handleC = jobC.Schedule(handleB);
JobHandle handleD = jobD.Schedule(handleD);

当然也可以多对一依赖:

1
2
3
4
JobHandle handleA = jobA.Schedule();
JobHandle handleB = jobB.Schedule(handleA);
JobHandle handleC = jobC.Schedule(handleA);
JobHandle handleD = jobD.Schedule(handleA);

可以一对多依赖,使用JobHandle.CombineDependencies静态方法:

1
2
3
4
5
6
7
JobHandle handleB = jobB.Schedule();
JobHandle handleC = jobC.Schedule();
JobHandle handleD = jobD.Schedule();

JobHandle combined = JobHandle.CombineDependencies(handleB,handleC,handleD);

JobHandle handleA = jobA.Schedule(combined);

我们可以设计一个非常复杂的Job依赖图:

Jobs 依赖

在上图中,B依赖A,D依赖B和C……以此类推。

如果我们调用handleI.Complete(),那么jobE和jobF也都会Complete,我们无需担心

禁止出现循环依赖

不能出现A依赖B,B依赖A的情况,幸运的是,我们不需要担心出现这种情况:一个Job只能依赖在它之前Schedule的Job,一个Job一但Schedule,就不能改变它的Dependence了。

Parallel Job

在一些用例中,我们在一个Job内处理的Array或者List可能是可以并行处理的,这时我们可以使用IJobParalleFor接口

1
2
3
4
5
6
7
8
9
10
11
12
13
using Unity.Burst;
using Unity.Collections;

[BurstCompile]
public struct SquaresJob : IJobParalleFor
{
public NativeArray<int> Nums;

public void Execute(int index)//Execute的重载,此方法用来表示NativeArray每个element应该执行的操作
{
Nums[index] *= Nums[index];//如果访问其他element会警告
}
}

接下来应用这个job

1
2
3
4
5
6
7
8
9
var job = new SquaresJob { Nums = myArray };

//IJobParalleFor的Schedule需要传入两个参数,第一个是Array的Length,第二个是Batch Size
//Job Queue将每个Batch记录并分配给worker Threads
//例如如果我们的Array.Length = 250,那么Batch1 :0~99、Batch2 :100~199、Batch3 :200~249
//3个Batch会分别交给Worker Threads执行
JobHandle handle = job.Schedule(arr.Length, 100);

handle.Complete();

Batch的容量没有定论,我们需要实际情况实际处理。

寄存器争抢

如果我们将这个整数乘方的Job,创建一千万个整数,然后将Batch Size设为100,整个Job将使用3ms左右

乘方

但是如果我们将Batch Size和Array的Length相同,这意味着我们只在一个核心上运行乘方,整个Job一样执行3ms

乘方1 core

这是因为我们的执行运算只是乘方,每个核心的计算量虽然不大,但是每个核心会争抢寄存器,这是耗时的,所以像这样的用例,使用单个核心比较好。