How the C# Job System works
Unity c# Job System 允许用户编写多线程代码,这些代码可以与Unity的其他部分很好地交互,并且使得编写正确的代码更加容易。编写多线程代码可以提供高性能的好处。其中包括帧率的显著提高。在c# Job 中使用 Burst 编译器可以提高代码生成质量,这也可以显著降低移动设备上的电池消耗。c# Job System 的一个重要方面是它集成了Unity内部使用的东西(Unity的 local Job System)。用户编写的代码和Unity共享工作线程。这种协作避免了创建比CPU内核更多的线程,这会导致对CPU资源的争用.
What is multithreading?
在单线程计算系统中,一条指令一次输入,一个结果一次输出。加载和完成程序的时间取决于需要CPU完成的工作量。多线程是一种编程类型,它利用CPU的能力在多个核心之间同时处理多个线程。它们不是一个接一个地执行任务或指令,而是同时运行。默认情况下,一个线程在程序的开始运行。这就是“主线”。主线程创建新线程来处理任务。这些新线程彼此并行运行,并且通常在完成后将其结果与主线程同步。
如果您有一些任务需要长时间运行,那么这种多线程方法可以很好地工作。然而,游戏开发代码通常包含许多需要同时执行的小指令。如果您为每个线程创建一个线程,您可能会得到许多线程,每个线程的生命周期都很短。这可能会使CPU和操作系统的处理能力达到极限。
通过拥有一个线程池,可以减轻线程生存期的问题。但是,即使使用线程池,也可能有大量线程同时处于活动状态。拥有比CPU内核更多的线程会导致线程之间争夺CPU资源,从而导致频繁的上下文切换。
上下文切换是这样一个过程: 通过执行来保存线程的部分状态,然后在另一个线程上工作,然后重新构造第一个线程,然后继续处理它。上下文切换是资源密集型的,所以应该尽可能避免使用它。
What is a job system?
job system 通过创建 job 而不是线程来管理多线程代码。
job system 管理一组跨多个核心的工作线程。它通常在每个逻辑CPU内核上有一个工作线程,以避免上下文切换(尽管它可能为操作系统或其他专用应用程序保留一些内核)。
job system将 job 放入要执行的 job 队列中。job system 中的工作线程从 job 队列中获取项并执行它们。job system 管理依赖项并确保 job 以适当的顺序执行。
What is a job?
job 是完成一项特定任务的小工作单元。作业接收参数并对数据进行操作,类似于方法调用的行为。job 可以是自包含的,也可以在运行之前依赖于其他 job 来完成。
What are job dependencies?
在复杂的系统中,比如游戏开发所需要的系统,每个任务都不可能是独立的。一项工作通常是为下一项工作准备数据。job 知道并支持使其工作的依赖项。如果 jobA 依赖于 jobB,作业系统将确保 jobA 在 jobB 完成之前不会开始执行。
The safety system in the C# Job System
Race conditions
在编写多线程代码时,总是存在竞争条件的风险。当一个操作的输出依赖于其控制范围之外的另一个进程的时间时,就会出现竞争条件。
竞态条件并不总是一个bug,但它是不确定性行为的一个来源。当竞态条件确实导致bug时,可能很难找到问题的根源,因为它依赖于时间,所以您只能在很少的情况下重新创建问题。调试它会导致问题消失,因为断点和日志记录会改变各个线程的计时。竞态条件是编写多线程代码的最大挑战。
Safety system
为了更容易地编写多线程代码,Unity c# job system检测所有潜在的竞争条件,并保护您免受它们可能造成的bug。
例如: 如果c# job system 将对数据的引用从主线程中的代码发送到 job,它无法验证主线程是否正在读取数据,同时 job 也在向它写入数据。这个场景创建了一个竞态条件。
c# job system 通过向每个 job 发送它需要操作的数据的副本来解决这个问题,而不是发送对主线程中数据的引用。该副本隔离数据,消除了竞争条件。
c# job system 复制数据的方式意味着 job 只能访问blittable数据类型。这些类型在托管代码和本机代码之间传递时不需要转换。
c# job system 可以使用memcpy复制blittable类型,并在Unity的托管部分和本机部分之间传输数据。它使用memcpy在调度job时将数据放入本机内存,并在执行 job 时让托管端访问该副本。
NativeContainer
安全系统复制数据的过程的缺点是,它也隔离了每个副本中的 job 结果。为了克服这个限制,您需要将结果存储在一种名为NativeContainer的共享内存中。
What is a NativeContainer?
NativeContainer是一种托管值类型,它为本机内存提供了一个相对安全的c#包装。它包含一个指向非托管分配的指针。当与Unity c# job system一起使用时,NativeContainer允许 job 访问与主线程共享的数据,而不是使用副本。
What types of NativeContainer are available?
Unity 使用一个叫做 NativeArray 的 NativeContainer。你还可以通过一个NativeSlice 来操作一个NativeArray,从而获得从某个特定位置开始确定长度的NativeArray子集。
注意:Entity Component System(ECS) 包扩展了 Unity.Collections 命名空间,包括了其他类型的 NativeArray:
- NativeList - 一个可变长的NativeArray
- NativeHashMap - 键值对
- NativeMultiHashMap - 每个Key可以对应多个值
- NativeQueue - 一个先进先出(FIFO)队列
NativeContainer and the safety system
安全性系统是所有NativeContainer类型的组成部分。它会追踪所有关于任何NaiveContainer的读写。
注意:所有关于 NativeContainer 类型的安全性检查(包括下标边界检查,内存释放检查和资源竞争检查),只在Unity Editor 和 Play Mode 中生效。(译者:即只在编辑器环境中进行检查)
安全性系统是由DisposeSentinel 和 AtomicSafetyHandle 组成的。DisposeSentinel检测内存泄漏同时在你没有正确释放内存的时候给你一个错误信息。但内存泄漏的错误只有在泄露发生很久之后才会触发。
使用 AtomicSafetyHandle 在代码中进行 NativeContainer 所有权的转移。举例来说,如果两个已经安排的 jobs 向同一个NativeArray写入数据,安全性系统会抛出一个异常,带有明确的错误信息关于为什么以及如何解决这个问题。安全性系统会在你安排一个违规的 job 后抛出一个异常。
在这种情况下,你可以在安排job的时候添加一个依赖。第一个job可以写入到NativeContainer,一旦它执行完毕,下一个job可以安全地读取和写入同一个NativeContainer。读取和写入的限制同样影响在访问主线程中的数据时生效。安全性系统允许多个jobs并行的读取同一份数据。
通常来说,当一个job有 NativeContainer 的访问权限时,它同时拥有读取和写入的权限。这种配置会使性能变差。一个C# Job System不允许你在有 job 正在对一个 NativeContaine r进行读写的时候,安排另一个 job 对该 NaiveContainer 拥有写入权限。
如果某个 job 不需要向某个 NativeContainer 写入,可以将该 NativeContainer 加上[ReadOnly]属性,像这样
[ReadOnly]
public NativeArray<int> input;
在上面的例子中,你可以在其他 jobs 拥有该 NativeContaine r只读权限的时候同时执行该 job。
注意:这边没有针对从一个job中访问静态数据的保护。访问静态数据可以绕过所有的安全性系统并可能导致Unity 崩溃。
NativeContainer Allocator
当创建一个NativeContainer时,你必须指定你需要的内存分配类型。分配的类型由jobs运行的时间来决定。这种情况下你可以在每一种情况下使分配器达到可能的最好性能。
这里对于NativeContainer的内存分配有三个分配器类型。当你初始化你的NativeContainer时你需要指定一个合适的分配器。
- Allocator.Temp 是最快的分配类型。它适用于分配一个生命周期只有一帧或更短时间的操作。你不应当把一个分配器为Temp类型分配的NativeContainer传递给jobs使用。你同时需要在函数返回之前调用Dispose方法(例如MonoBehaviour.Update,或者其他从原生到托管代码的调用)
- Allocator.TempJob 是相比于Temp是一个较慢的分配类型但它比Persistent要快。这是一个生命周期为四帧的内存分配而且它是线程安全的。如果你在四帧之内没有调用Dispose,控制台会打印一个由原生代码生成的警告信息。绝大部分小 jobs 使用这种类型的NativeContainer分配器。
- Allocator.Persistent 是最慢的分配类型,但它可以持续存在到你需要的时间,如果必要的话可以贯穿应用程序的整个生命周期。它是直接调用malloc的一个封装。长时间的jos可以使用这种分配类型。当性能比较紧张的时候你不应当使用Persistent。
使用示例:
NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);
注意:上例中的1表明了NativeArray的长度。在这个例子中,它只有一个数组元素(因为它只在result中存储了一块数据)。
Creating jobs
为了在Unity中创建一个job你需要实现IJOb接口。IJob允许你调度一个job,和其他jobs并发执行。
注意:“job”是Unity中所有实现了IJob接口的结构的总称。
为了创建一个job,你需要: 创建一个实现IJob的结构体 添加job需要使用的成员变量(可以是blittable类型或NativeContainer类型) * 在结构体中添加一个叫Execute的方法并将job的具体逻辑实现放在里面
当job执行的时候,Excute方法在单个核心上运行一次
注意:当设计你的job时,记住你是在一份数据的拷贝上进行操作的,除了在使用NativeContainer的情况下。所以,在主线程访问一个job中数据的唯一方法就是将数据写入NativeContainer。
一个简单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;
}
}
Scheduling jobs
为了在主线程中调度一个job,你必须: 实例化一个job 填充job中的数据 * 调用Schedule方法
在合适的时间调用Schedule将job放入到job的执行队列中。一旦job被调度,你不能中途打断一个job的执行。
注意:你只能从主线程中调用Schedule
调度一个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 and dependencies
当你调用Schedule方法时会返回一个JobHandle。你可以在代码中使用JobHandle作为其他jobs的依赖关系。如果一个job依赖于另一个job的结果,你可以将第一个job的JobHandle作为参数传递给第二个job的Schedule方法,像这样:
JobHandle firstJobHandle = firstJob.Schedule();
secondJob.Schedule(firstJobHandle);
Combining dependencies
如果一个job有多个依赖项,你可以使用JobHandle.CombineDependencies方法来合并他们。CombineDependencies允许你将他们传递给Schedule方法。
NativeArray<JobHandle> handles = new NativeArray<JobHandle>(numJobs, Allocator.TempJob);
// Populate `handles` with `JobHandles` from multiple scheduled jobs...
JobHandle jh = JobHandle.CombineDependencies(handles);
Waiting for jobs in the main thread
使用JobHandle来让你的代码在主线程等待直到你的job执行完毕。为了做到这样,需要在JobHandle上调用Complete方法。这样的话,你就确定主线程可以安全访问job之前使用的NativeContainer。
注意:jobs不是在你调度他们的时候就立刻开始执行。如果你在主线程中等待job,并且你需要访问job正在使用的NativeContainer,你可以调用JobHandle.Complete方法。这个方法会刷新内存缓存中的jobs并开始执行。调用JobHandele的Complete会将job的NativeContainer类型数据的归属权交还给主线程。你需要在JobHandle上调用Complete来在主线程再次安全地访问这些NativeContainer类型。你也可以调用一个由job依赖产生的JobHandle的Complete方法来将数据的归属权交还给主线程。举例来说,你可以调用jobA的Complete方法,或者你可以调用依赖于jobA的jobB的Complete方法。两种方法都可以让你在调用Complete后在主线程安全访问jobA使用的NativeContainer类型。
否则,如果你不需要对数据的访问,但你需要明确地刷新这个批次的job。为了做到这点,调用静态方法JobHandle.ScheduleBatchedJobs。注意这个调用会对性能产生负面的影响。
一个关于多重job和依赖的例子
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
当调度Jobs时,只能有一个job来进行一项任务。ParallelFor jobs 当调度Jobs时,只能有一个job来进行一项任务。在游戏中,非常常见的情况是在一个庞大数量的对象上执行一个相同的操作。这里有一个独立的job类型叫做IJobParallelFor来处理此类问题。
注意:“ParallelFor”job是Unity中所有实现了IJobParallelFor接口的结构的总称。
一个并行化job使用一个NativeArray存放数据来作为它的数据源。并行化job横跨多个核心执行。每个核心上有一个job,每个job处理一部分工作量。IJobParallelFor的行为很类似于IJob,但是不同于只执行一个Execute方法,它会在数据源的每一项上执行Execute方法。Execute方法中有一个整数型的参数。这个索引是为了在job的具体操作实现中访问和操作数据源上的单个元素。
一个定义并行化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;
}
}
Scheduling ParallelFor jobs
当调度并行化job时,你必须指定你分割NativeArray数据源的长度。在结构中同时存在多个NativeArrayUnity时,C# Job System不知道你要使用哪一个NativeArray作为数据源。这个长度同时会告知C# Job System有多少个Execute方法会被执行。
在这个场景中,并行化job的调度会更复杂。当调度并行化任务时,C# Job System会将工作分成多个批次,分发给不同的核心来处理。每一个批次都包含一部分的Execute方法。随后C# Job System会在每个CPU核心,的Unity原生Job System上调度最多一个job,并传递给这个job一些批次来完成。
一个并行化job划分批次到多个CPU核心
当一个原生job提前完成了分配给它的工作批次后,它会从其他原生job那里获取其剩余的工作批次。它每次只获取那个原生job剩余批次的一半,为了确保缓存局部性(cache locality)。
为了优化这个过程,你需要指定一个每批次数量(batch count)。这个每批次数量控制了你会生成多少job和线程中进行任务分发的粒度。使用一个较低的每批次数量,比如1,会使你在线程之间的工作分配更平均。它会带来一些额外的开销,所以有时增加每批次数量会是更好的选择。从每批次数量为1开始,然后慢慢增加这个数量直到性能不再提升是一个合理的策略。
调度并行化job的例子:
job代码
// Job adding two floating point values together
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;
b[0] = 2.2;
a[1] = 3.3;
b[1] = 4.4;
MyParallelJob jobData = new MyParallelJob();
jobData.a = a;
jobData.b = b;
jobData.result = result;
// Schedule the job with one Execute per index in the results array and only 1 item per processing batch
JobHandle handle = jobData.Schedule(result.Length, 1);
// Wait for the job to complete
handle.Complete();
// Free the memory allocated by the arrays
a.Dispose();
b.Dispose();
result.Dispose();
ParallelForTransform jobs
一个ParallelForTransform job是另一个类型的ParallelFor job;它是专门为了Transforms上的操作设计的。
注意:ParallelForTransform job是Unity中所有实现了IJobParallelForTransform接口的结构的总称。
C# Job System tips and troubleshooting
当你使用Unity C# Job System时,确保你遵守以下几点:
Do not access static data from a job (不要从一个job中访问静态的数据)
在所有的安全性系统中你都应当避免从一个job中访问静态数据。如果你访问了错误的数据,你可能会使Unity崩溃,通常是以意想不到的方式。举例来说,访问一个MonoBehaviour可以导致域重新加载时崩溃。
注意:因为这个风险,未来版本的Unity会通过静态分析来阻止全局变量在job中的访问。如果你确实在job中访问了静态数据,你应当预见到你的代码会在Unity未来的版本中报错。
Flush scheduled batches (刷新已调度的批次)
当你希望你的job开始执行时,你可以通过JobHandle.ScheduleBatchedJobs来刷新已调度的批次。注意调用这个接口时会对性能产生负面的影响。不刷新批次将会延迟调度job,直到主线程开始等待job的结果。在任何其他情况中,你应当调用JobHandle.Complete来开始执行过程。
注意:在Entity Component System(ECS)中批次会暗中为你进行刷新,所以调用JobHandle.ScheduleBatchedJobs是不必要的。
Don’t try to update NativeContainer contents (不要试图去更新NativeContainer的内容)
由于缺乏引用返回值,不可能去直接修改一个NativeContainer的内容。例如,nativeArray[0]++ ;和 var temp = nativeArray[0]; temp++;一样,都没有更新nativeArray中的值。
你必须从一个index将数据拷贝到一个局部临时副本,修改这个副本,并将它保存回去,像这样:
MyStruct temp = myNativeArray[i];
temp.memberVariable = 0;
myNativeArray[i] = temp;
Call JobHandle.Complete to regain ownership (调用JobHandle.Complete来重新获得归属权)
在主线程重新使用数据前,追踪数据的所有权需要依赖项都完成。只检查JobHandle.IsCompleted是不够的。你必须调用JobHandle.Complete来在主线程中重新获取NaitveContainer类型的所有权。调用Complete同时会清理安全性系统中的状态。不这样做的话会造成内存泄漏。这个过程也在你每一帧都调度依赖于上一帧job的新job时被采用。
Use Schedule and Complete in the main thread(在主线程中调用Schedule和Complete)
你只能在主线程中调用Schedule和Complete方法。如果一个job需要依赖于另一个,使用JobHandle来处理依赖关系而不是尝试在job中调度新的job。
Use Schedule and Complete at the right time(在正确的时间调用Schedule和Complete)
一旦你拥有了一个job所需的数据,尽可能快地在job上调用Schedule,在你需要它的执行结果之前不要调用Complete。一个良好的实践是调度一个你不需要等待的job,同时它不会与当前正在运行的其他job产生竞争。举例来说,如果你在一帧结束和下一帧开始之前拥有一段没有其他job在运行的时间,并且可以接受一帧的延迟,你可以在一帧结束的时候调度一个job,在下一帧中使用它的结果。或者,如果这个转换时间已经被其他job占满了,但是在一帧中有一大段未充分利用的时段,在这里调度你的job会更有效率。
Mark NativeContainer types as read-only(将NativeContainer标记为只读的)
记住job在默认情况下拥有NativeContainer的读写权限。在合适的NativeContainer上使用[ReadOnly]属性可以提升性能。
Check for data dependencies(检查数据的依赖)
在Unity的Profiler窗口中,主线程中的"WaitForJobGroup"标签表明了Unity在等待一个工人线程上的job结束。这个标签可能意味着你以某种方式引入了一个资源依赖,你需要去解决它。查找JobHandle.Complete来追踪你在什么地方有资源依赖,导致主线程必须等待。
Debugging jobs(调试job)
job拥有一个Run方法,你可以用它来替代Schedule从而让主线程立刻执行这个job。你可以使用它来达到调试目的。
Do not allocate managed memory in jobs(不要在job中开辟托管内存)
在job中开辟托管内存会难以置信得慢,并且这个job不能利用Unity的Burst编译器来提升性能。Burst是一个新的基于LLVM的后端编译器技术,它会使事情对于你更加简单。它获取C# job并利用你平台的特定功能产生高度优化的机器码。
来源:CSDN
作者:Zack-zzh
链接:https://blog.csdn.net/u012338130/article/details/104173889