Unity Job System 文档翻译 (2019.2版本)

为了方便做参考,自己把文档翻译了一下,以后查阅时不用看英文了:)

官方文档地址:https://docs.unity3d.com/Manual/JobSystem.html

C# Job System

Unity C# Job System,让你可以简单,安全的编写多线程代码与Unity Engine交互,以此提升游戏效率。

你可以与 Unity Entity Component System(ECS) 一起使用。ECS可以帮助你针对各个平台生成高效的机器码程序。

C# Job System 概览

C# Job System 是如何工作的?

编写多线程代码可以提高游戏运行效率,尤其是提高帧速率。同时使用Burst Compiler,可以优化代码生成,使代码执行效率更高,同时也能降低游戏对手机电量的消耗。

Job System是集成到Unity引擎层的,所以效率更高。用户写的代码,和Unity引擎代码共享Work Thread(工作线程)。这样,避免创建比CPU核心更多的线程,因为这会导致线程之间竞争CPU资源,造成上下文切换,降低效率。

什么是多线程?

在单线程计算系统中,指令总是一条一条地执行。程序执行时间依赖于要交给CPU的工作量。

多线程是利用CPU多核性能,在通以时间处理多个线程的变成方法。这时,指令是同时被执行的。

程序启动时会自动启动一个线程,即主线程。主线程可以创建子线程来执行任务。新的线程与主线程时并行执行的,并在完成任务后,与主线程同步结果。

如果你的任务很少,逻辑简单,并且运行时间很长,这种简单的多线程模型多数时候可以正常工作,然而,我们的游戏通常具有复杂的逻辑,同时需要执行很多小的逻辑模块。如果为每个逻辑模块创建线程,最终我们的程序会有很多的线程,没个线程执行完逻辑销毁,生命周期很短。这种创建,销毁线程的操作,会大量占用CPU和操作系统资源,造成游戏效率降低。

使用线程池可以降低这方面的消耗。然而通常线程池会创建大量线程,同时运行。超过CPU核心数量的线程,会导致线程之间竞争CPU资源,同时导致CPU上下文切换。上下文切换,时保存当前线程的运行状态,加载另一个线程的运行状态,并开始执行该线程,然后再次保存,加载,执行。所以上下文切换是很浪费效率的,要尽量避免。

什么是 Job System?

Job System 通过创建Job而不是线程,来进行多线程编程。

Job System 管理创建在多个核心上的多个工作线程。通常没个逻辑核心创建一个工作线程,来避免上下文切换(但是也会创建少于核心数量的工作线程,来为操作系统和主线程保留核心)。

Job System 将 Job 推到Job队列中执行。工作线程从队列中取出Job来执行。Job System还管理Job之间的依赖关系来保证Job之间以合理的顺序执行。

什么是Job?

Job是一个执行特别任务的小的逻辑单元。Job接收参数并处理他们,像一个函数调用。Job可以独立执行,也可以依赖其它Job,等待其它Job完成再处理它。

什么是Job依赖

在一些复杂的系统,比如游戏开发中,通常Job不是独立执行的。可以是一个Job准备数据,另一个Job继续处理。Job之间是能够知道彼此,依靠依赖关系来处理这类情况。如果JobA依赖JobB,Job System 会确保JobB完成后,才会执行JobA。

C# Job System中的安全系统

条件竞争

当编写多线程代码时,经常会发生条件竞争。当一个操作的输出,依赖于另一个不受自己控制的处理的时间调度时,就会发生条件竞争。

条件竞争并不一定是Bug,但是它很可能导致不确定的程序行为。当出现问题时,查找这类BUG会非常困难,因为BUG的产生跟代码执行的调度时间有关,所以难以重现BUG。当你Debug时,可能问题就消失了,因为Debug时的断点及日志输出导致代码执行的调度时间发生了改变。处理条件竞争是编写多线程代码最大的挑战之一。

安全系统

为了简化多线程编程,Job System 会发现所有的潜在的条件竞争并保证不会发生条件竞争类的BUG。

例如,当将一个引用类型的数据从主线程传递给Job处理时,我们无法确定当Job在修改该数据时,主线程是否在修改数据,这就导致条件竞争。

Job System通过向Job传递数据的拷贝来解决这个问题。这份拷贝跟原始数据时独立的,所以避免了条件竞争。

拷贝数据的方式,以为着Job 只能访问结构数据类型,在Managed和Native之间传递这种数据类型,不需要额外的转换。当调度Job时,直接调用memcpy进行内存拷贝,将托管数据传递给Native数据,Job执行时直接访问Native数据,效率比较高。

NativeContainer

JobSystem执行时处理数据的拷贝,提供安全性的同时,将处理结果也孤立出来,导致我们无法在Job中修改数据以返回结果。为了解决这个问题,我们需要将结果存储在一个叫NativeContainer的共享数据类型中。

什么是NativeContainer

Native Container是托管值类型,对Native内存提供了相对安全的C#封装。它内部包含了一个指向非托管内存的指针。该类型提供了Job和主线程共享数据的方法。

Native Container类型

Unity提供了一个叫NativeArray的Native Container。该类同时提供接口,获得NativeArray的指定起始和长度的子集:NativeSlice。

Entity Component System扩展了NativeContainer,提供以下类型:

Native List – 一个可变长度的Native Array。

NativeHashMap – 键值对集合。

Native Multi Hash Map – 一个键对应多值的集合。

Native Queue – 先进先出队列

Native Container and the safety system

安全系统处理了所有的NativeContainer类型,控制他们的读写操作。

注:所有在NativeContainer上的检查(数组越界,释放,条件竞争),仅在编辑器的运行模式有效(发布后就不做这些检查了?为了提高效率?)。

安全系统定义了DisposeSentinel和AtomicSafetyHandle。Dispose Sentinel用来发现内存泄漏并给出错误日志。该错误可能在内存泄露一段时间后才报出(超时未释放)。

AtomicSafetyHandle实现Native Container在代码间的所有权转移。例如,如果2个Job都想同一个NativeArray中写数据,安全系统会抛出异常,并描述如何解决该问题。这通常是个不合法的Job派发。

这种情况下,你可以在派发Job时,使用依赖。当第一个Job写数据,并完成后,第二个Job可以读写同一个NativeContainer。读写限制对主线程同样有效。安全系统允许多个Job并行地读同一个数据(写不允许)。

默认,当一个Job访问NativeContainer时,该Job将同时获得读写权限,但这种情况效率不高。因为JobSystem不允许在一个Job写数据时,另一个Job读数据。

如果以个Job不需要写数据,那么把数据描述为只读:

[ReadOnly]

public NativeArray<int> input;

这种声明,你可以同时执行其它的同样时只读的Job,来提高效率。

注:目前没有办法处理静态数据的Job的安全访问。访问静态数据绕过安全系统可能会导致Unity崩溃。

Native Container Allocator

创建Native Container时,必须指定内存分配回收模式。内存分配回收模式决定了内存的生命周期。使用恰当的分配模式和可获得最佳的性能提升。

有三种分配回收模式:

Allocator.Temp:速度最快。其生命周期为一帧以内。以该模式分配的Native Container不能传递给Job使用。当使用完后,需要调用Dispose来释放。

Allocator.TempJob:比Temp慢一点但是比下面的Persistent快。生命周期为4帧(why?假定4帧能完成Job?)。如果4帧内你没有Dispose,控制台会打印警告信息。运算量比较小的可以用这个(当然是4帧内能完成的了。但是我们如何保证?3帧是,强行调用Complete())。

Allocator.Persistent:最慢,但是没有生命周期限制,知道你用完释放。内部是直接封装了malloc来分配内存。当性能有问题时,可以考虑这个方面。

分配例子:

NativeArray<int> result = new NativeArray<int>(1,Allocator.TempJob);

分配长度为1的整数数组。

创建Jobs

Unity中创建Job需要实现IJob接口。IJob允许你分派多个Job并行执行。

Job实际上是一个术语,是实现了IJob接口的结构体。

创建Job方法:

l 创建一个实现IJob接口的结构体。

l 添加Job需要的变量(可以是结构类型或者是NativeContainer类型)。

l 为你的Job创建一个Execute方法,实现你的逻辑。

当执行Job时,Execute方法在一个CPU核心上执行一次。

注:实现 Job时要记住,Job操作的是数据的拷贝,除了NativeContainer。也只有通过这种数据类型,才能在主线程中访问到Job写的数据。

例子

public struct MyJob : IJob

{

public float a;

public float b;

public NativeArray<float> results;

 

public void Execute()

{

Result[0] = a + b;

}

}

分派(发布执行)Job

在主线程中发布任务,需要:

l 实例化Job(new)

l 填充数据

l 调用Schedule方法

调用Schedule方法,将Job推送到Job执行队列,并且不能再终止Job。

只能再主线程中发布Job。

例子:

// 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和依赖

调用Schedule发布Job会返回JobHandle。可以用JobHandle来于其它Job建立依赖关系。如果一个Job依赖于另一个Job的结果,你可以将第一个Job的JobHandle当作第二个Job的Schedule调用的参数(第二个依赖于第一个):

JobHandle firstJobHandle = firstJob.Schedule();

secondJob.Schedule(firstJobHandle);

组合依赖

如果以个Job依赖多个Job,可以用JobHandle.CombineDependencies来合并这些依赖,并作为参数传递给该Job的Schedule调用。

NativeArray<IJobHandle> handles = new NativeArray<JobHandle>(numJobs, Allocator.TempJob);

handles[0] = aJogHandle;

JobHandle jh = JobHandle.CombineDependencies(handles);

在主线程中等待Job完成

使用JobHandle可以强制代码等待Job完成后再继续执行,调用JobHandle.Complete()。之后,主线程中就可以安全的访问Job使用的NativeContainer的数据了。

注:Schedule调用后,Job并不会马上执行。如果需要再主线程中等待Job完成,并访问Job处理的NativeContainer的数据,则需要调用JobHandle.Complete()。该方法强制刷新Job队列并执行它们。Complete方法使主线程从Job中重新取得对NativeContainer的所有权。调用依赖Job的Complete同样可以返回NativeContainer的所有权。例如Job B依赖JobA,调用JobHandleA.Complete() 或者 JobHandleB.Complete() 都能返回JobA的NativeContainer的所有权。

另外,如果不需要访问数据,只是要立即执行Job队列,则调用JobHandle.ScheduleBatchedJobs()。不过该调用会降低执行效率。

多重依赖的例子

Job代码:

// Job adding two floating point values together

public struct MyJob : IJob

{

public float a;

public float b;

public NativeArray<float> result;

 

public void Execute()

{

result[0] = a + b;

}

}

 

// Job adding one to a value

public struct AddOneJob : IJob

{

public NativeArray<float> result;

 

public void Execute()

{

result[0] = result[0] + 1;

}

}

主线程代码:

// Create a native array of a single float to store the result in. This example waits for the job to complete

NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

 

// Setup the data for job #1

MyJob jobData = new MyJob();

jobData.a = 10;

jobData.b = 10;

jobData.result = result;

 

// Schedule job #1

JobHandle firstHandle = jobData.Schedule();

 

// Setup the data for job #2

AddOneJob incJobData = new AddOneJob();

incJobData.result = result;

 

// Schedule job #2

JobHandle secondHandle = incJobData.Schedule(firstHandle);

 

// Wait for job #2 to complete

secondHandle.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();

ParallelFor jobs

当分派job时,一个线程只能执行一个job。游戏中,经常会在大量的对象上执行同样的操作。IJobParallelFor可以用来处理这种分割job。

Unity中,ParallelFor job是用来描述实现IJobParallelFor接口的结构体。

ParallelFor Job使用NativeArray来作为数据源进行操作,该类job可以运行在多个CPU核心上。每个核心上都有一个Job,每一个处理任务的一部分。执行Execute时,将提供一个索引参数,来访问Job的数据(NativeArray数组)。

ParallelFor job例子

struct IncrementByDeltaTimeJob : IJobParallelFor

{

public NativeArray<float> values;

public float deltaTime;

 

public void Execute(int index)

{

float temp = values[index];

temp += deltaTime;

values[index] = temp;

}

}

调度(派发)ParallelFor jobs

调度ParallelFor jobs时,需要指定要拆分的NativeArray数据源的长度。当Job 结构体有多个NativeArray数据时,Job System 并不知道你想要用哪个数据作为源数据。数据源长度同时告诉JobSystem要执行多少次Execute方法。

在后台,Parallel For jobs 的调度要复杂的多。调度时,系统会把工作分成若干批次,并平均地分配给每个工作线程。调度时为每个CPU核心安排一个Job,并为这些Job分配工作批次。

当一个Job完成它的工作,会“偷取”其它Job的未开始的工作批次。但是每次只“偷取”半个批次,以保证缓存的索引的有效性。

为了这个过程,你需要指定批次(tatch)数量,来控制工作被分派到几个Job,以及在线程间分配的粒度。指定batch为1,工作能最平均的分配到线程,所以通常要慢慢提升该值,知道获得一个接受的效率。

调度ParallelFor job 的例子

Job 代码:

public struct MyParallelJob : IJobParallelFor

{

[ReadOnly]

public NativeArray<float> a;

[ReadOnly]

public NativeArray<float> b;

public NativeArray<float> result;

 

public void Execute(int i)

{

Result[i] = a[i] + b[i];

}

}

主线程代码:

NativeArray<float> a = new NativeArray<float>(2, Allocator.TempJob);

NativeArray<float> b = new NativeArray<float>(2, Allocator.TempJob);

nativeArray<float> result = new NativeArray<float>(2, Allocator.TempJob);

 

a[0] = 1.1;

a[1] = 2.2;

b[2] = 3.3;

b[3] = 4.4;

 

MyParallelJob jobData = new MyParallelJob();

jobData.a = a;

jobData.b = b;

jobData.result = result;

 

JobHandle handle = jobData.Schedule(result..Length, 1);

Handle.Complete();

 

a.Dispose();

b.Dispose();

result.Dispose();

ParallelForTransform jobs

该类job 被专门设计来操作 Transform。

使用Jos System 的建议和目前的问题

需要坚持以下原则:

不要在Job内访问静态(static)数据

访问静态数据将绕过安全系统。如果访问了错误的数据,可能会导致Unity崩溃(如果能保证数据正确,是不是就可以访问了?)。例如,访问MonoBehaviour会导致在程序域重新加载时崩溃(目前应该时仅在编辑器模式下,代码发生改变重新编译时,才需要重新加载)。

因为这个风险,未来的Unity会利用静态代码分析,发现并禁止在Job里访问全局数据。如果你这么做了,要意识到你的代码在Unity未来某个版本将不能运行。

Flush schedule batches

当希望Jobs队列被立即执行,可以调用JobHandle.ScheduleBatchedJobs。该操作会降低执行效率,所以尽量不要这么做,知道主线程需要Jobs的执行结果。在多数情况下,调用JobHandle.Complete来开始这个Job的执行,等待结果。

注:在ECS里,系统自动帮你执行Flush,所以不需要我们自己调用。

不要试图直接修改NativeContainer的内容

例如以下代码是错误的:

nativeArray[0]++;

它相当于该操作:

var temp = nativeArray[0];

temp ++;

并没有修改数组内容。

我们应该从数组取得数据的拷贝,修改它,再将它保存回去:

MyStruct temp = myNativeArray[i];

temp.memberVariable = 0;

myNativeArray[i] = temp;

调用JobHandle.Complete来重新获得所有权

主线程获得数据的所有权以再次访问前,需要所有相关依赖的Jobs都完成。检查JobHandle.IsComplete是不够的。主线程必须要调用JobHandle.Complete来获取NativeContainer数据所有权,同时清理安全系统对该数据的管理状态。不这么做,可能会导致内存泄漏。当每帧创建新的Jobs,依赖之前的Jobs是,也需要调用。

通常我们这样调用来马上获得执行结果。

在主线程调用Schedule和Complete

在恰当的时机调用Schedule和Complete

当Job的数据准备好后,立即调用Schedule,在需要结果前,不要调用Complete。一个好的建议,是在没有竞争资源的Job执行时调度,例如在帧的结束,调度,在下一帧的开始使用结果数据。(帧结束时开始渲染,cpu可能会进入相对的空闲,等待显卡渲染完,在这个等待周期,调度Jobs,等渲染完,即下一帧开始,获取结果)。

标识NativeContainer为只读

默认Jobs对NativeContainer时读写的,写会排斥其它Jobs的读,所以标识为只读,可以让其它Jobs访问数据,提高并发。

检查数据依赖

在Profiler窗口,显示数据“WaitForJobGroup”,标识主线程在等待Job线程结果。意味着引入了数据依赖。查找JobHandle.Complete找到导致主线程等待的数据依赖。

调试Jobs

Jobs提供了Run函数,可以替代Schedule在主线程中调用来立即执行并调试。

不要在Jobs里分配托管内存

在Jobs里分配托管内存非常非常慢,而且导致无法进行Burst compiler来提升效率。Burst是基于LLVM的后端编译技术,它将C#编译为高度优化的机器代码来利用不同CPU平台的特性来提升效率。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值