C# Job System

概述

设计目的:简单安全地使用多线程,随便就能写出高性能代码
收益:FPS更高,电池消耗更低(Burst编译器)
并行性:C# Job System和Unity Native Job System共享工作线程worker threads,也就是它们不会创建超过CPU cores数量的线程,也就不会导致CPU资源抢占问题。

什么是多线程

单线程:一次执行一条指令,产生一个结果
多线程:利用CPU的多核,多条指令同时执行,其他线程执行完成后会将结果同步给主线程。
多线程好的实践:几个运行时间很长的任务。
游戏代码的特点:大量小而短的任务。
解决方案:线程池
context switching:线程上下文切换,性能敏感的,要尽量避免。
    当激活的线程数超过CPU cores时,就会导致CPU资源争夺,从而触发频繁的context switching。
    过程:先saving执行了一部分的当前线程,然后执行另外的线程,切回来的时候再reconstructing之前的线程再继续执行。

什么是Job System

简化多线程:job system通过创建jobs来实现多线程,而不是直接创建thread。
job概念:完成特定任务的一个小的工作单元。job接收参数并操作数据,类似于函数调用。job之间可以有依赖关系,也就是一个job可以等另一个job完成之后再执行。
job system管理一组worker threads,并且保证一个logical CPU core一个worker thread,避免context switching
job system将jobs放在一个job queue里面,worker threads从job queue里面获取job然后执行。
job依赖性:job system管理job依赖关系,并保证执行时序的正确性

C# Job System的Safety System

Race conditions:竞争条件,一个输出结果依赖于不受控制的事件出现的顺序或时机。
在写多线程代码时,race conditions是一个很大的挑战。race conditions不是bug,但它会导致不确定性行为。并且一旦出现,就很难定位,也很难调试,因为它依赖时机,打断点和加log本身都会改变各个独立线程执行的时机。
Safety system:为了写出更安全的多线程代码,C# Job System会检查所有的潜在的race conditions并保护代码不受可能会产生的bug的影响(这句话有点模糊......)。
解决办法:数据拷贝,每个job操作来自主线程数据的副本,而不是操作原数据。这样数据独立,就不会产生race conditions了。
blittable data types:job只能访问blittable的数据,这些数据在托管代码和native代码之间拷贝的时候,不需要做额外的类型转换。
拷贝方式:memcpy

NativeContainer

NativeContainer实际上是native memory的一个wrapper,包含一个指向非托管内存的指针。
不需要拷贝:使用NativeContainer可以让一个job和main thread共享数据,而不用拷贝。(copy虽然能保证Safety System,但每个job的计算结果也是分开的)。
可使用的C#类型定义:
  
数据结构 说明 来源
NativeArray 数组 Unity
NativeSlice 可以访问一个NativeArray的某一部分 Unity
NativeList 一个可变长的NativeArray ECS
NativeHashMap key value pairs ECS
NativeMultiHashMap 一个key对应多个values ECS
NativeQueue FIFO的queue ECS
Safety System安全策略:    
  Safety System内置于所有的NativeContainer,会自动跟踪NativeContainer的读写状态。
    注意:所有的safety checkes都只在Editor和PlayMode模式下生效:bounds checks、deallocation checks、race condition checks。
    还有一部分安全策略:
        DisposeSentinel:自动检测memory leak并报错。依赖宏定义ENABLE_UNITY_COLLECTIONS_CHECKS。
        AtomicSafetyHandle:用来转移NativeContainer的控制权。比如当2个jobs同时写一个NativeContainer,Safety System就会抛出一个error,并描述如何解决。异常会在产生冲突的job调度时抛出。依赖宏定义ENABLE_UNITY_COLLECTIONS_CHECKS。
        这种情况下,可以使用job依赖,让其中一个job依赖另外一个job的完成。
规则:Safety System允许多个job同时read同一块数据。
规则:Safety System不允许一个job正在writing数据时,调度激活另一个“拥有write权限”的job(不是不让同时write)。
规则:手动指定job对数据的只读:(默认是可读写,会影响性能)
    [ReadOnly]
    public NativeArray<int> input;
  注意:job对static data的访问没有Safety System安全保护,所以使用不当可能造成crash。
 
NativeContainer Allocator分配器:
(1)Allocator.Temp
    最快,维持1 frame,job不能用,需要手动Dispose(),比如可以在native层的callback调用时使用。
(2)Allocator.TempJbo
    稍微慢一点,最多维持4 frames,thread-safe,如果4 frames内没有Dispose(),会有warning。大多数small jobs都会使用这个类型的分配器.
(3)Allocator.Persistent
    最慢,但是可持久存在,就是malloc的wrapper。Longer jobs使用这个类型,但在性能敏感的地方不应该使用。
NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

创建Job

三要素:
(1)创建一个struct实现接口IJob;
(2)添加数据成员:要么是blittable类型, 要么是NativeContainer;
(3)添加Execute()方法实现。
执行job时,job.Execute()方法会在一个cpu core上执行一次。
注意:job操作数据是基于拷贝的,除非是NativeContainer类型。那么,一个job访问main thread数据的唯一方式就是使用NativeContainer。
public struct TestJob : IJob
{
    public float a;
    public float b;
    public NativeArray<float> result;
    public void Execute()
    {
        result[0] = a + b;
    }
}

调度Job

三要素:
(1)实例化job;
(2)设置数据;
(3)调用job.Schedule()方法。
调用Schedule方法会将job放到job queue里面等待执行。一旦开始schedule,就没法中断job了。(疑问:这个once scheduled,是job.Schedule方法,还是从job queue里面拿出来开始执行?)
private void TestScheduleJob()
{
    // Create a native array of a single float to store the result. This example waits  for the job to complete for illustration purposes
    NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

    // Set up the job data
    MyJob jobData = new MyJob();
    jobData.a = 10;
    jobData.b = 10;
    jobData.result = result;

    // Schedule the job
    JobHandle handle = jobData.Schedule();

    // Wait for the job to complete
    handle.Complete();

    // All copies of the NativeArray point to the same memory, you can access the  result in "your" copy of the NativeArray
    float aPlusB = result[0];

    // Free the memory allocated by the result array
    result.Dispose();
}

JobHandle和Job依赖

设置job依赖关系:
JobHandle firstJobHandle = firstJob.Schedule();
secondJob.Schedule(firstJobHandle);
secondJob依赖firstJob的结果。
组合依赖项:
NativeArray<JobHandle> handles = new NativeArray<JobHandle>(numJobs, Allocator.TempJob);
// Populate `handles` with `JobHandles` from multiple scheduled jobs...
JobHandle jh = JobHandle.CombineDependencies(handles);
在main thread中等待jobs执行完成:
    flush job:使用JobHandle.Complete()来等待job执行完成。
    job只有Schedule之后才会执行,如果你想在main thread中访问job的正在使用的数据,你可以调用JohHandle.Comlete()。该方法flush job,并开始执行,然后将NativeContainer的数据权限返回给main thread。
    如果你不需要访问数据,也可以调用统一static flush函数:JobHandle.ScheduleBatchedJobs(),当然该方法会影响到性能。
public struct MyJob : IJob
{
    public float a;
    public float b;
    public NativeArray<float> result;
    public void Execute()
    {
        result[0] = a + b;
    }
}
public struct AddOneJob : IJob
{
    public NativeArray<float> result;
    
    public void Execute()
    {
        result[0] = result[0] + 1;
    }
}


private void TestScheduleJob()
{
    NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);
    MyJob jobData = new MyJob();
    jobData.a = 10;
    jobData.b = 10;
    jobData.result = result;
    JobHandle firstHandle = jobData.Schedule();
    AddOneJob incJobData = new AddOneJob();
    incJobData.result = result;
    JobHandle secondHandle = incJobData.Schedule(firstHandle);
    secondHandle.Complete();
    float aPlusB = result[0];
    result.Dispose();
}

ParallelFor jobs 并行job

IJob只能一次一个job执行一个任务,但游戏开发中经常需要重复执行某个动作很多次,这时候就可以用到并行任务IJobParallelFor。
    ParallelFor jobs使用NativeArray作为数据源,并且运行在多个core上,还是一个job一个core,只是每个job只负责处理完整数据的一个子集。
    Execute(idx)方法对于数据源NativeArray中的每个item都调用一次。
 调度:
  需要手动指定执行次数,表示需要分多少次独立Execute来执行,一般直接取NativeArray的数组长度作为执行次数,一次处理一个数据。       
  
当一个native job提前完成它的batches,它会从其他的native job偷取一部分batches,然后继续执行。
颗粒度问题:分得太细会有work不断重建的开销,分得太粗又会有单核负载问题。
尝试法:所以最佳实践是从1开始逐步增加,直到性能不再提高。
public struct MyParallelJob : IJobParallelFor
{
    public NativeArray<float> a;
    public NativeArray<float> b;
    public NativeArray<float> result;
    public void Execute(int index)
    {
        result[index] = a[index] + b[index];
    }
}

private void TestScheduleParallelJob()
{
    NativeArray<float> a = new NativeArray<float>(10, Allocator.TempJob);
    NativeArray<float> b = new NativeArray<float>(10, Allocator.TempJob);
    NativeArray<float> result = new NativeArray<float>(10, Allocator.TempJob);
    for(int i = 0; i < 10; ++i)
    {
        a[i] = i * 0.3f;
        b[i] = i * 0.5f;
    }
    MyParallelJob jobData = new MyParallelJob();
    jobData.a = a;
    jobData.b = b;
    jobData.result = result;
    JobHandle handle = jobData.Schedule(10, 1);
    handle.Complete();
    for(int i = 0; i < 10; ++i)
    {
        Debug.LogError(result[i]);
    }
    a.Dispose();
    b.Dispose();
    result.Dispose();
}

ParallelForTransform jobs

public struct MyTransformParallelJob : IJobParallelForTransform
{
    public void Execute(int index, TransformAccess transform)
    {
    }
}

注意事项:

(1)不能在job中访问static数据
    在job中访问static数据是没有Safety System保证的,可能会导致crash。unity后续版本会增加static analysis来阻止这种用法。
 
(2)Flush scheduled batchs
    JobHandle.ScheduleBatchedJobs:当你想要你的job开始执行是,可以调用这个函数flush调度的batch。
    不flush batch会导致调度延迟到主线程等待batch执行结果时才触发执行。
    JobHandle.Complete:直接开始执行。
    在ECS中,batch flush是隐式执行的,不需要手动调用JobHandle.ScheduleBatchJobs。
    
(3)不要试图更新NativeContainer的内容
    因为缺乏ref returns机制,所以不要这样用:
    nativeArray[0]++;
    // 等同于:
    var tmp = nativeArray[0];
    tmp++;
    // 不生效!

    // 正确的写法是:
    var tmp = nativeArray[0];
    tmp++;
    nativeArray[0] = tmp;

    MyStruct temp = myNativeArray[i]; 
    temp.memberVariable = 0;
    myNativeArray[i] = temp;
(4)调用JobHandle.Complete来让main thread重获控制权
    主线程在访问数据之前,需要依赖的job调用complete。不能只是check JobHandle.IsCompleted,而是需要手动调用JobHandle.Complete()。
    此调用还会清理Safety System的状态,不调用的话会有内存泄漏。
 
(5)在主线程中使用Schedule和Complete
    这两个函数只能在主线程中调用。不能因为一个job依赖另一个job,就在前一个job中手动schedule另一个job。
 
(6)在正确的时间使用Schedule和Complete
    Schedule:在数据填充完毕,立马调用
    Complete:只在你需要result的时候调用
    
(7)NativeContainer添加read-only标记
    默认是可读写的,如果确定只读就标记为read-only,可以提升性能。
 
(8)检查数据依赖
    如果在profiler里看到main thread有“WaitForJobGroup”,就表示在等待worker thread处理完成。也就是说你的代码里面在什么地方引入了一个data dependency,这时候可以通过检查JobHandle.Complete来看一下是什么依赖关系导致了main thread需要等待的情况。
 
(9)调试jobs
    Jobs有一个Run函数,你可以用它来替换原本调用Schedule的地方,从而在main thread上立即执行这个job。可以使用这个方法来调试。
 
(10)不要在job里面分配托管内存managed memory
    在job里面分配托管内存是非常慢的,而且会导致Burst compiler没法使用。
    Burst是基于LLVM的后端编译技术,它可以利用平台特定能力将c# jobs代码编译成高度优化过的机器码。
 
Unity GDC 2018: C# Job System
 
Unity at GDC - Job System & Entity Component System
 
Job System介绍
 
posted @ 2020-02-04 11:53  斯芬克斯  阅读(4943)  评论(0编辑  收藏  举报