为了方便做参考,自己把文档翻译了一下,以后查阅时不用看英文了:)
官方文档地址: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平台的特性来提升效率。