介绍
在前面的文章中我们介绍了利用GPU来强化计算,Unity还提供了另外一种办法来帮助我们快速的编写多线程代码。他便是今天的主角JobSystem。Unity的Jobsystem包现在已经被包含在了Dots核心包中。在以后的版本就不需要单独安装。虽然JobSystem是和ECS一起提出来的,但我们仍然可以单独使用来对我们的游戏增加一些优化
Unity在引擎层面实现了一个Cpp版本的JobSystem,和一个C#层的JobSystem,Cpp层的JobSystem解决了线程上下文切换的问题,而C#层则是提供给我们的接口层。
JobSystem管理了一组工作线程,他们可以是主线程或者是其他的线程,一般来说一个核上一个工作线程(因此避免了上下文切换),在底层中他会根据任务来自动的分配给工作线程,我们无法来控制我们的任务分配给哪个工作线程,只能将自己的任务提供给Job队列,然后由底层自动分配给工作线程。具体的了解可以去看《DOTS之路》的前导课。
Unity中的使用
再unity中想要使用JobSystem需要声明一个继承自 Job Types 的结构体。它里面会实现一个叫 Execute 的方法,这个方法有的时候会有参数,有的时候则会没有。比如当我们继承自 IJob 的时候他就没有参数,继承自 IJobFor 的时候参数为 (int Index),而继承自IJobParallelForTransform 的时候则会有两个参数:**(int Index,TransformAccess transform)**。具体的参数含义我们后面再讲。一个完整的Job代码如下
struct MyJob : IJob
{
public void Execute()
{
//do something;
}
}
在unity中的使用代码如下
public Class MyScript : MonoBehavior
{
void Start()
{
MyJob m = new MyJob();
m.Run();
}
}
其中一个Job的调用大概有三种,
方法名 | 介绍 |
---|---|
Run | 主线程立即顺序执行 |
Schedule | 单个工作线程或者主线程,每个Job顺序执行 |
ScheduleParallel | 多个工作线程上同时执行,性能最好,但要注意数据访问可能会发生冲突 |
其中名字带有Parallel的Job类型,仅提供Schedule,不提供Run和ScheduleParallel的调度方式。(这句话在0.5版本的Jobs不适用,不确定1.0的版本会不会是这样)
不管是哪种调度在调用后都会返回一个JobHandle类型的变量,它会阻塞我们当前的线程。用于等待我们的Job执行完毕,比如我第一个Job需要处理某些数据,而第二个Job则需要用到这些数据,那我们就应该让第二个Job来等待第一个执行完毕,而阻塞方法为 Complete
JobHandle handle = myJob.Schedule();
handle.Complete();
当调用Complete的时候他就会阻塞当前线程,如果我们想让其他的Job来等待这个Job完成的话可以这样写
MyJob job2 = new();
JobHandle handle = myJob.Schedule();
job2.Schedule(handle);
//当我们希望JobC等待JobA和JobB都完成以后才执行可以这么写
JobHandle handle = JobHandle.CombineDependencies(handleA,handleB);
Jobc.Schedule(handle);
不同JobType的区别
JobType拥有很多的类型,但我们常用的大概就那么几个,主要为IJob,IJobFor,IJobParallelForTransform。
IJob:主要用于单个任务,不需要大量并行的时候,调用方法跟上一节介绍的使用没有区别。
IJobFor:由于IJob是一个工作线程上工作,性能提升有限,所以Unity为我们提供了并行的版本,他的Execute函数和调用会有一点的差别。长下面这样
struct MyJob : IJob
{
public void Execute(int index)
{
//do something;
}
}
调用代码
void Start()
{
MyJob m = new MyJob();
m.Run(num);
m.Schedule(num,default);
m.ScheduleParallel(num,64,default);
}
IJob类型我们在调用的时候只会调用一次,而我们的IJobFor则是调用多次,上面代码的num参数便是调用次数,那么IJobFor类型的Execute方法的参数index也就很好理解了,他就是我们当前执行的是第几遍,方便我们对流程进行控制,而但我们传入的是数组长度的时候,我们也就可以将index看成我们的数组下标值。比如我们想填充某个数组,就可以在调用的时候输入数组的长度,而index便是我们想要输出的数组的下标。
第二个default是关键字,原本的参数是一个JobHandle类型的变量,填default也就是没有等待的意思,
比较特殊的便是ScheduleParallel方法的第二个参数,他的含义是一个工作线程一次工作的批次长度,也就是我这一次任务会执行64个,假设我们有8个核心,也就是8个线程,而我们的总长度,也就是Num数量为128时,那么也就只会有两个工作线程来工作。当某个工作线程先完成了任务,他还会获得别的Job的任务,以保证大家都完成这一批次的任务。
但也不能说我们的批次越少越好,官方建议我们多次更改这个数值来观察,以达到最佳性能。
IJobParallelForTransform:专门用于操作Tranform的操作。
struct MyJob : IJobParallelForTransform
{
public void Execute(int index, TransformAccess transform)
{
//do something
}
}
他的Execute方法又多了一个参数,它用于控制传入的Transform值。他的使用也有点不同,代码如下
void Start()
{
TransformAccessArray transformAccessArray = new TransformAccessArray(4 * xHalfCount * zHalfCount);
for (var z = -zHalfCount; z <= zHalfCount; z++)
{
for (var x = -xHalfCount; x <= xHalfCount; x++)
{
var cube = Instantiate(cubeAchetype);
cube.transform.position = new Vector3(x * 1.1f, 0, z * 1.1f);
transformAccessArray.Add(cube.transform);
}
}
var handler = job.Schedule(transformAccessArray);
}
代码写的十分的易懂,也就不在多讲解了,Execute就是数组的下标,transform就是我们的物体transform值。
至于其他的JobType,等我以后用到了再来继续写
数据的传递
JobSystem为了避免数据竞争,他选择了执行的时候是复制而非引用数据,而因此我们要使用的数据也就Blittable types类型的数据。
使用复制数据的缺点在于我们所得到的结果也是独立的,而为了解决这个问题,JobSystem选择将结果储存在一端公共内存中。也便是NativeContainer。
NativeContainer以相对安全的托管类型的方式指向一个非托管的内存地址,使Job 可以直接访问主线程数据而非复制。
Unity 自带 NativeContainer类型为 NativeArray,ECS 包又扩展了NativeList、NativeHashMap、NativeMultiHashMap和NativeQueue。从他们的名字中我们可以大概了解到他们应该如何使用,因此这里不再细说。声明一个NativeContainer如下:
//C#类中,这里float3是Mathematics下的,用Vector3也是可以的,只不过效率会变低。
NativeArray<float3> Nums = new NativeArray(int Length,Allocator type);
//在结构体中声明一个相同的即可
NativeArray<float3> Nums;
在声明完毕后赋值进去就好了。在创建的时候我们会看到参数中有个Allocator类型的变量,它有以下几种类型。需要我们根据Job执行的时长来决定使用哪种。
Allocator.Temp
最快的分配方法,适用于一帧或几帧的生命时长,不能将该类型分配的数据传给 Job,在方法 Return 前执行Dispose
Allocator.TempJob
分配速度比 Temp 慢比 Persistent 快,4帧的生命时长且线程安全。若四帧内没有调用Dispose,控制台会打印原生代码生成的警告。大部分小任务都使用该类型分配NativeContainer
Allocator.Persistent
是对malloc的包装,能够维持尽可能地生命时长,性能不足的情况下不应使用
由于NativeContainer是非托管数据,因此需要我们自己手动Dispose。不要忘记。
在ECS中的使用
~~待补充
优化
- 在Job中我们不要使用静态变量,比如直接在Execute函数中访问Time.delat变量
- 对于不会读或者不会写入的变量我们可以对其使用特性[ReadOnly]和[WriteOnly]来标记。
- 对于我们所写的Job都可以在前面加上[BurstCompile]特性来利用Burst来编译,可以大幅度优化代码。
- 我们可以引入Unity.Mathematics包来代替我们原本的类型,比如利用float3 代替 Vector3,Brust也会优化这些数学运算