Unity Job System详解(1)——基础知识

【前言】

当需要加速计算以优化性能时可以考虑使用Job。为了更好的理解和使用Job,需要有些并行编程的基础。

必须先了解下C# Task是怎么回事

【Job创建和使用】

可以先看看unity的这篇文章初步了解如何创建和使用JobUnity - Manual: Create and run a job

所有的工作线程有一个管理者,管理者会将某个任务分配给某个工作线程,一旦一个工作线程完成了任务,管理者会给其分配下一个任务。

任务不能需要很长时间才会完成,那样的话会长久占用一个工作线程。

如果管理者没有任务可以分配而其他工作线程还有没开始处理的任务,会将该任务重新分配到该线程,这也叫工作窃取Work Stealing

当我们(调用者)调用Job.Schedule时,相当于将任务递交给管理者,管理者如何分配任务调用者不清楚,也即调用不知道任务将在哪个工作者线程中执行,当然,调用者也不需要去关心。工作线程会调用Job的Excute方法,所以我们的计算逻辑都在Excute方法中。

递交给管理者,管理者分配工作线程也是需要时间的,如果调用者需要计算的数据很少,(实际逻辑中是存在这种情况的)那么可以直接在主线程中计算,而不要递交给管理者,这就是调用Job.Run的情况。

调用方不知道任务什么时候会完成(多线程间一般不会提供同步回调),所以在需要使用数据时,先判断任务是否完成,即JobHandle.ISCompleted,如果没完成,需要阻塞主线程等待任务完成,即JobHandle.Complete。

任务没完成时主线程需要等待,因此,要尽可能的延迟使用JobHandle.Complete,也即尽可能将使用数据的逻辑延后。

为了提高性能减少内存,Job一般是结构体,工作线程计算的输入数据和输出数据都来自该结构体。

为了防止多线程间的静态条件,工作线程通常会将Job中的数据Copy一份。这种处理方式导致性能降低,内存增加,为此Unity提供的解决方式是NativeContainer

【NativeContainer】

NativeContainer可以让工作线程和主线程共享数据,而不是Copy一份,同时提供线程安全保障。

基本的数据类型是NativeArray和NaticeSlice,前置可以当作Array使用,其将非托管内存封装成托管类型来使用,后者通过Index和Length来表示NativeArray中的一部分。

其他常用的数据类型还有:

  • NativeList:大小可变的NativeArray
  • NativeHashMap:键值对,大致相当于Dictionary
  • NativeMultiHashMap:每个键有多个值
  • NativeQueue:相当于Queue

NativeContainer默认是可读可写的,为了防止竞态,而且又不Copy,Unity中引用了同一个NativeContainer的两个不同Job不允许同时执行,意思是其不能在两个不同的线程上并行执行,在时间上是有先后顺序的,可以先在一个线程中执行完一个Job,随后在另外一个线程中执行另一个Job。

如果数据只需要读取,加上[ReadOnly]特性,可以避免该问题。如果是只写的,加上[WriteOnly]特性。

NativeContainer在分配内存时,需要指定分配的方式,以NativeArray为例:

 //第一个参数count为数组大小,第二个参数allovator为分配内存的存活周期
            NativeArray<float> result1 = new NativeArray<float>(10, Allocator.Temp);//最快的配置。生命周期为一帧。从主线程传数据给Job时,不能使用Temp。一般用于Job内局部变量分配
            NativeArray<int> result2 = new NativeArray<int>(10, Allocator.TempJob);//较快的配置。生命周期为4帧。
            NativeArray<bool> result3 = new NativeArray<bool>(10, Allocator.Persistent);//最慢的配置。可以贯穿应用程序的整个生命周期。一般会在初始化的时候就预先分配好内存,主线程内持有的一般是这种

与内存分配对应得是内存释放,其是非托管内存,必须使用Dispose手动释放,建议在分配好内存时就在Destroy等函数内写好释放,否则很容易忘记。

另外,在使用NativeContainer时要注意,其没有实现ref return,不能能去直接修改其内容。例如,nativeArray[0]++ ;和 var temp = nativeArray[0]; temp++;一样,都没有更新nativeArray中的值。要用如下的方式:

MyStruct temp = myNativeArray[i];
temp.memberVariable = 0;
myNativeArray[i] = temp;

【Job类型】

job接收数据,计算数据,得到输出结果,一个Job的输入可能依赖另一个Job的输出,也即Job之间可以相互依赖。

Job有以下类型

  • IJob:将单个Job放在一个线程上去执行,这个线程是随机的。使用这种Job时一般要求间隔几帧才会用到数据,否则Job没执行完,在主线程中还是要Complete,没起到提升主线程性能的作用
  • IJobParallelFor:单个Job在多个线程上执行,每个线程处理部分数据,这要求各部分数据之间没有依赖。需要指定总的数据长度,和每个线程处理的数据数量。同样的,管理者会分配任务,总的数据中那部分数据先被执行完是确定的。
  • IJobFor:有三种执行方式
    • 调用Run(int arrayLength),直接在主线程上执行
    • 调用Schedule(int arrayLength, JobHandle dependency),顺序执行多个Job,需要等待上个Job执行完成才能执行下个Job,dependency为依赖的JobHandle
    • 调用ScheduleParallel(int arrayLength, int innerloopBatchCount, JobHandle dependency),相当于调用IJobParallelFor.ScheduleParallel()
  • IJobParallelForTransform:用于并行访问Transform组件,unity自己实现的比较特殊的读写Transform信息的Job

【使用Job的注意事项】

  • 不要在Job中(即Execute方法中)访问静态变量:Execute可以理解为创建线程时的Run方法(new Thread(Run)),理论上访问静态变量是没问题的。但工作线程毕竟不是自己写代码控制的,可能在其他地方会修改静态变量的值,造成竞态条件,严重的时候会崩。一般要在Job中新定义一个字段,将静态变量的值传递进去
  • 不能在Job中调用新的Job:只能在主线程中schedule和 complete
  • 不要在Job中分配托管内存:Job中所需分配的内存最好在new Job之前已经分配好了,如果一定要分配内存,使用NativeContainer
  • Job的字段不能有托管类型:Job的字段只能是值类型的,不能是托管类型的,不要搞强转指针的骚操作,NativeContainer中也不能用托管类型,例如NativeArray<int[]>是不被允许的。string类型可以用NativeText。

猜你喜欢

转载自blog.csdn.net/enternalstar/article/details/134385677