Unity JobSystem ECS 快速入门

学习目标:

学习使用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的流程如下:

  1. 定义Job
  2. 实例化Job
  3. 执行Job
  4. 完成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.

简单作业定义的示例
// 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;
    }
}

7.调用JobSystem

要在主线程中安排作业,您必须:

实例化作业。 填充作业的数据。 调用调度方法。
调用Schedule将作业放入作业队列中,以便在适当的时间执行。一旦安排好,您就不能中断作业。

注意:您只能Schedule从主线程调用。

调度作业的示例

// 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();

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);

// Populate `handles` with `JobHandles` from multiple scheduled jobs...

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。请注意,调用此方法会对性能产生负面影响。

多个作业和依赖项的示例

工作代码:

// 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();

如上所示,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 作业的示例
职位代码:

// 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();

我们可以看到,和普通的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的安全系统提前为我们考虑好的事情

  • 4
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值