学习目标:
学习使用JobSystem
学习内容:
JobSystem的基础概念 线程的知识 JobSystem的使用
学习时间:
2022.1.25
学习产出:
搬运一下官方文档的详解,文章内容加上一些自己理解和解释以及易错点介绍,将其总结为学习笔记 里面有很多我在使用JobSystem中遇到的坑,以及原因 希望可以帮助你们快速入门
1.什么是JobSystem?我们为什么要学习它?
Unity C# Job System 允许用户编写与 Unity 的其余部分良好交互的多线程代码,并且更容易编写正确的代码。
编写多线程代码可以提供高性能优势。其中包括帧速率的显着提高。将 Burst 编译器与 C# 作业一起使用可以提高代码生成质量,这也可以显着减少移动设备上的电池消耗。
C# Job System 的一个重要方面是它与 Unity 内部使用的(Unity 的本地作业系统)集成。用户编写的代码和 Unity 共享工作线程。这种合作避免了创建的线程多于CPU 内核,从而导致 CPU 资源的争用。
默认情况下,脚本中几乎所有的执行语句都在Main thread上执行。这是一条通过CPU虚拟的路径,你可以将它想象为高速公路 同样的,我们可以将这条高速公路上的工作任务,分配给其他的高速公路。也就是创建Job来分担我们Mainthread上面的任务,减轻它的压力。我们可以将简单的而费时的计算分配给其他线程来做,减轻主线程的压力。这也是我们使用JobSystem的重要原因 Jobsystem的流程如下:
- 定义Job
- 实例化Job
- 执行Job
- 完成Job
2.什么是多线程?
在单线程计算系统中,一次输入一条指令,一次输出一个结果。加载和完成程序的时间取决于您需要 CPU 完成的工作量。
多线程是一种利用 CPU 跨多个内核同时处理多个线程的能力的编程类型。它们不是一个接一个地执行任务或指令,而是同时运行。
默认情况下,一个线程在程序开始时运行。这是“主线”。主线程创建新线程来处理任务。这些新线程彼此并行运行,并且通常在完成后将其结果与主线程同步。
如果您有一些需要长时间运行的任务,这种多线程方法会很有效。然而,游戏开发代码通常包含许多要一次执行的小指令。如果您为每个线程创建一个线程,您最终会得到许多线程,每个线程的生命周期都很短。这可能会突破CPU 和操作系统的处理能力极限。
可以通过拥有一个线程池来缓解线程生命周期的问题。但是,即使您使用线程池,您也很可能同时有大量线程处于活动状态。线程多于 CPU 内核会导致线程相互竞争 CPU资源,从而导致频繁的上下文切换。上下文切换是在执行过程中保存线程状态的过程,然后在另一个线程上工作,然后重建第一个线程,稍后继续处理它。上下文切换是资源密集型的,因此您应该尽可能避免使用它。
3.什么是JobSystem?
JobSystem通过创建作业而不是线程来管理多线程代码。
JobSystem管理一组跨多个内核的工作线程。它通常每个逻辑 CPU 核心有一个工作线程,以避免上下文切换(尽管它可能为操作系统或其他专用应用程序保留一些核心)。
JobSystem将作业放入作业队列中执行。作业系统中的工作线程从作业队列中获取项目并执行它们。作业系统管理依赖关系 并确保作业以适当的顺序执行。
什么是Job? Job是完成一项特定任务的一小部分工作。作业接收参数并对数据进行操作,类似于方法调用的行为方式。Job可以是自包含的,也可以依赖其他Job来完成才能运行。
什么是工作依赖( job dependencies)? 在复杂的系统中,例如游戏开发所需的系统,不可能每个工作都是独立的。一项工作通常是为下一项工作准备数据。Jobs 知道并支持依赖项来完成这项工作。如果jobA依赖于jobB,则作业系统确保在完成jobA之前不会开始执行jobB。
4.C#作业系统中的安全系统
Race conditions(竞争条件) 编写多线程代码时,总是存在竞争条件的风险。当一个操作的输出取决于另一个不受其控制的进程的时间时,就会出现竞争条件。
竞争条件并不总是错误,但它是不确定行为的来源。当竞争条件确实导致错误时,可能很难找到问题的根源,因为它取决于时间,因此您只能在极少数情况下重新创建问题。调试它可能会导致问题消失,因为断点和日志记录可以改变单个线程的时间。竞争条件在编写多线程代码时产生了最重大的挑战。
安全系统 为了更轻松地编写多线程代码,Unity C# Job System 检测所有潜在的竞争条件并保护您免受它们可能导致的错误的影响。
例如:如果 C# 作业系统从主线程中的代码向作业发送对数据的引用,则它无法验证主线程是否在作业正在写入数据的同时正在读取数据。这种情况会产生竞争条件。
C# 作业系统通过向每个作业发送它需要操作的数据的副本(也就是NativeContainer)来解决这个问题,而不是对主线程中的数据的引用。此副本隔离了数据,从而消除了竞争条件。
线程发生竞争是个很头疼的问题,这也是为什么JobSystem不让我们去访问主线程。 比如说,主线程有一个变量m,值为5 有两个Job,一个Job控制其m++,一个Job控制其m–,那么这个m在主线程里面数据的准确性就很难保障了,可能被Job随时修改,引发一系列问题 可能执行完成后,Job可能是任何值,可能被第一个Job修改,也可能被第二个Job修改,这显然不是我们想要的结果。数据的安全性是个很重要的问题,这也是我们为什么要少用静态变量的原因。静态变量是任何地方都能访问和修改的,所以很危险。
我们也可能通过加“锁”来控制,但是锁太多,也会提供编程的难度和程序的复杂度
我的GitHub仓库里面有对C#线程的使用教学,以及一些我学习C#做的案例和笔记。
所以,在JobSystem里面,不能传入外部变量,也不能修改外部变量,也不能干扰主线程。只能调用和修改静态变量,或者使用自定义的NativeArray去调用和计算数据,我们在主线程创建NativeArray为其赋值,然后给Job去处理,处理完了之后,在主线程将NativeArray的值拿出来。 下面有对其的详细介绍
5.原生容器(NativeContainer)
安全系统复制数据过程的缺点是它还会隔离每个副本中的作业结果。为了克服这个限制,您需要将结果存储在一种称为NativeContainer的共享内存中。
ANativeContainer是一种托管值类型,可为本机内存提供安全的 C# 包装器。它包含一个指向非托管分配的指针。当与 Unity C# 作业系统一起使用时,aNativeContainer允许作业访问与主线程共享的数据,而不是使用副本。
NativeContainer 的类型 Unity 附带了一个NativeContainer名为NativeArray。您还可以使用NativeSlice 操作 aNativeArray以获取从NativeArray特定位置到特定长度的子集。
注意:实体组件系统(ECS) 包扩展了Unity.Collections命名空间以包括其他类型NativeContainer:
NativeList- 可调整大小的NativeArray. NativeHashMap - 键值对。 NativeMultiHashMap - 每个键有多个值。 NativeQueue- 先进先出 ( FIFO ) 队列。 NativeContainer 和安全系统 安全系统内置于所有NativeContainer类型中。它跟踪任何正在读取和写入的内容NativeContainer。
注意:所有类型的安全检查NativeContainer(例如越界检查、释放检查和竞争条件检查)仅在 Unity编辑器和播放模式中可用。
该安全系统的一部分是DisposeSentinel和AtomicSafetyHandle。DisposeSentinel如果您没有正确释放内存,它会检测内存泄漏并给您一个错误。触发内存泄漏错误发生在泄漏发生很久之后。
使用AtomicSafetyHandle转移NativeContainer代码中的所有权。例如,如果两个计划的作业正在写入同一个NativeArray,安全系统会抛出异常,并带有明确的错误消息,解释为什么以及如何解决问题。当您安排有问题的作业时,安全系统会引发此异常。
在这种情况下,您可以使用 依赖 .第一个作业可以写入NativeContainer,一旦它完成执行,下一个作业就可以安全地读取和写入相同NativeContainer的 . 当从主线程访问数据时,读取和写入限制也适用。安全系统确实允许多个作业并行读取相同的数据。
默认情况下,当作业可以访问 aNativeContainer时,它同时具有读取和写入访问权限。此配置可能会降低性能。C#作业系统不允许您安排一个对 a 具有写访问权限的NativeContainer作业与另一个正在写入它的作业同时进行。
如果作业不需要写入 a NativeContainer,请NativeContainer使用[ReadOnly]属性标记 ,如下所示:
[ReadOnly] public NativeArray<int> input;
在上面的示例中,您可以与其他也对第一个具有只读访问权限的作业同时执行该作业NativeArray。
注意:没有防止从作业中访问静态数据的保护措施。访问静态数据会绕过所有安全系统,并可能导致 Unity 崩溃。有关详细信息,请参阅C#作业系统提示和故障排除。
NativeContainer 分配器 创建 时NativeContainer,您必须指定所需的内存分配类型。分配类型取决于作业运行的时间长度。通过这种方式,您可以调整分配以在每种情况下获得最佳性能。
内存分配和释放共有三种分配器类型。NativeContainer实例化 a 时必须指定适当的NativeContainer。
Allocator.Temp的分配速度最快。将其用于生命周期为一帧或更少的分配。但是,您不能使用Temp将NativeContainer分配传递给作业。
Allocator.TempJob的分配Temp速度比Persistent.
Allocator.Persistent是最慢的分配,但可以持续到您需要的时间,如果有必要,可以在应用程序的整个生命周期内持续使用。它是直接调用malloc的包装器。较长的作业可以使用此NativeContainer分配类型。不要Persistent在性能至关重要的地方使用。
例如:
NativeArray<float> result = new NativeArray<float>(1,Allocator.TempJob); 注意:上例中的数字 1 表示NativeArray.
在这种情况下,它只有一个数组元素,因为它只存储了一条数据result。
6.Create a Job
要在 Unity 中创建作业,您需要实现IJob接口。IJob允许您安排与正在运行的任何其他作业并行运行的单个作业。
注意:“作业”是 Unity 中实现IJob接口的任何结构的统称。
要创建工作,您需要: 创建一个实现IJob. 添加作业使用的成员变量(blittable 类型或NativeContainer类型)。 在您的结构中创建一个名为Execute的方法,其中包含作业的实现。
执行作业时,该Execute方法在单个核心上运行一次。
注意:在设计工作时,请记住它们对数据副本进行操作,除了NativeContainer. 因此,从主线程中的作业访问数据的唯一方法是写入NativeContainer.
简单作业定义的示例
public struct MyJob : IJob
{
public float a;
public float b;
public NativeArray<float> result;
public void Execute()
{
result[0] = a + b;
}
}
7.调用JobSystem
要在主线程中安排作业,您必须:
实例化作业。 填充作业的数据。 调用调度方法。 调用Schedule将作业放入作业队列中,以便在适当的时间执行。一旦安排好,您就不能中断作业。
注意:您只能Schedule从主线程调用。
调度作业的示例
NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);
MyJob jobData = new MyJob();
jobData.a = 10;
jobData.b = 10;
jobData.result = result;
JobHandle handle = jobData.Schedule();
handle.Complete();
float aPlusB = result[0];
result.Dispose();
8.JobHandle 和依赖项
当您调用作业的Schedule方法时,它会返回JobHandle。您可以JobHandle在代码中使用 a 作为依赖 对于其他工作。如果一个作业依赖于另一个作业的结果,您可以将第一个作业JobHandle作为参数传递给第二个作业的Schedule方法,如下所示:
JobHandle firstJobHandle = firstJob.Schedule();
secondJob.Schedule(firstJobHandle);
结合依赖 如果一个作业有很多依赖,你可以使用JobHandle.CombineDependencies方法来合并它们。CombineDependencies允许您将它们传递给Schedule方法。
NativeArray<JobHandle> handles = new NativeArray<JobHandle>(numJobs, Allocator.TempJob);
JobHandle jh = JobHandle.CombineDependencies(handles);
在主线程中等待作业 用于JobHandle强制您的代码在主线程中等待您的作业完成执行。为此,请在JobHandle. 此时,您知道主线程可以安全地访问作业正在使用的NativeContainer。
注意:当您安排它们时,作业不会开始执行。如果您正在主线程中等待作业,并且您需要访问作业正在使用的 NativeContainer 数据,则可以调用方法JobHandle.Complete。此方法从内存缓存中刷新作业并开始执行过程。调用CompleteaJobHandle将该作业类型的所有权返回NativeContainer给主线程。您需要调用Completea以再次从主线程JobHandle安全地访问这些类型。NativeContainer也可以通过调用来自作业依赖项Complete的 a来将所有权返回给主线程。JobHandle例如,您可以调用Completeon jobA,或者您可以调用which depends Completeon 。两者都导致jobBjobANativeContainerjobA在调用Complete.
否则,如果您不需要访问数据,则需要显式刷新批处理。为此,请调用静态方法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;
}
}
主线程代码:
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();
如上所示,AddOneJob会在MyJob执行完了之后才会执行
9.一种可以可以循环执行的Job-ParallelFor jobs
在调度作业时,只能有一个作业执行一项任务。在游戏中,通常希望对大量对象执行相同的操作。有一个名为IJobParallelFor 的单独作业类型来处理此问题。
注意:"ParallelFor"作业是 Unity 中实现接口的任何结构的统称。IJobParallelFor
ParallelFor 作业使用NativeArray数据作为其数据源。ParallelFor 作业跨多个内核运行。每个核心有一个作业,每个作业处理工作负载的一个子集。 行为类似于 ,但不是单个Execute方法,而是在数据源中的每个项调用该方法一次。方法中有一个整数参数。此索引用于访问作业实现中数据源的单个元素并对其进行操作。IJobParallelForIJobExecuteExecute
ParallelFor 作业定义的示例:
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 作业时,必须指定要拆分的数据源的长度。如果结构中有多个数据源,Unity C# 作业系统将无法知道要将哪个用作数据源。长度还告诉 C# 作业系统需要多少种方法。NativeArrayNativeArrayExecute
背后场景 ,则 ParallelFor 作业的调度更加复杂。在计划 ParallelFor 作业时,C# 作业系统会将工作划分为批处理,以便在内核之间分发。每个批次都包含一个方法子集。然后,C# 作业系统在每个 CPU 内核中在 Unity 的本机作业系统中最多安排一个作业,并将该本机作业传递一些批次以完成。
并行用于跨内核划分批处理的作业 当本机作业先于其他作业完成其批处理时,它会从其他本机作业中窃取剩余的批处理。它一次只窃取本机作业剩余批次的一半,以确保缓存局部性。
要优化流程,您需要指定批次计数。批处理计数控制您获得的作业数,以及线程之间工作重新分配的细粒度。具有较低的批计数(如 1)可在线程之间更均匀地分配工作。它确实会带来一些开销,因此有时最好增加批次计数。从 1 开始并增加批计数,直到性能提升可以忽略不计,这是一种有效的策略。
计划 ParallelFor 作业的示例 职位代码:
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;
JobHandle handle = jobData.Schedule(result.Length, 1);
handle.Complete();
a.Dispose();
b.Dispose();
result.Dispose();
我们可以看到,和普通的Job区别就是在Schedule的时候,我们需要传入我们需要循环的次数。 这也体现了该Job的核心优势在于对大量对象执行相同的操作
10.使用提示
1.不能将NativeContainer的数据类型放在NativeContainer 比如这样
NativeHashMap<int, NativeList<float3>> DicP = new NativeHashMap<int, NativeList<float3>>(FrameCount, Allocator.TempJob);
会出现
2.只能指定简单的数据类型,例如float,byte。不能指定复杂的数据类型,例如Vector3或者Object之类的
3.我们只能通过NativeContainer拿到数据,也就是通过安全备份去拿数据 如图所示,如果我们等待Job指行完了之后,我们去Debug出a的值,我们会发现,a不会被修改 这样一来,逻辑就很清楚了 JobSystem只能修改NativeContainer里面的数据,我们在Job里面修改Public 的float变量a,是不起效的 这也是JobSystem为了安全性考虑做出的牺牲,只能修改NativeContainer里面的数据
4.我们只能在Job完成之后,才能去访问Job里面的NativeContainer数据 如果Job没有完成,我们就去访问它的数据,就会报错。因为我们并不知道此时Job的完成情况,可能该job还没开始执行,所以数据并没有被处理
5.我们CPU会开销一定的性能去创建和分发任务,然后取回数据。 所以Job不宜过多
6.我们在JobSystem里面不能对IO进行操作,或者其他一些只能在主线程进行的操作 当然,这也是Unity的安全系统提前为我们考虑好的事情
|