当需要加速计算以优化性能时可以考虑使用Job。为了更好的理解和使用Job,需要有些并行编程的基础。
必须先了解下C# Task是怎么回事
可以先看看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可以让工作线程和主线程共享数据,而不是Copy一份,同时提供线程安全保障。
基本的数据类型是NativeArray和NaticeSlice,前置可以当作Array使用,其将非托管内存封装成托管类型来使用,后者通过Index和Length来表示NativeArray中的一部分。
其他常用的数据类型还有:
NativeContainer默认是可读可写的,为了防止竞态,而且又不Copy,Unity中引用了同一个NativeContainer的两个不同Job不允许同时执行,意思是其不能在两个不同的线程上并行执行,在时间上是有先后顺序的,可以先在一个线程中执行完一个Job,随后在另外一个线程中执行另一个Job。
如果数据只需要读取,加上[ReadOnly]特性,可以避免该问题。如果是只写的,加上[WriteOnly]特性。
NativeContainer在分配内存时,需要指定分配的方式,以NativeArray为例:
//第一个参数count为数组大小,第二个参数allovator为分配内存的存活周期
NativeArray result1 = new NativeArray(10, Allocator.Temp);//最快的配置。生命周期为一帧。从主线程传数据给Job时,不能使用Temp。一般用于Job内局部变量分配
NativeArray result2 = new NativeArray(10, Allocator.TempJob);//较快的配置。生命周期为4帧。
NativeArray result3 = new NativeArray(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有以下类型