Unity C# Job System

首先在学习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,代码修改很简单,这边就不在赘述了。

猜你喜欢

转载自blog.csdn.net/wangjiangrong/article/details/107020789