作业系统。 另一面的概述

在2018年的新版本unity中 ,他们终于正式添加了新的Entity组件系统(简称ECS) ,该系统使您只能使用其数据,而不是通常使用对象的组件。

附加的任务系统使您可以使用并行计算功能来提高代码的性能。

这两个新系统( ECS作业系统 )一起提供了更高级别的数据处理。

具体来说,在本文中,我将不会分析整个ECS系统,该系统目前可以作为一个单独的可单独下载的工具集使用,但是将仅考虑任务系统以及如何在ECS软件包外部使用它。

新系统


最初, Unity以前可能使用过多线程计算,但是所有这些都必须由开发人员自己创建,以解决自己的问题并解决陷阱。 如果以前必须直接处理诸如创建线程,关闭线程,池,同步之类的事情,那么现在所有这些工作都落在了引擎的肩膀上,开发人员本人只需要创建任务并完成任务即可。

任务


要在新系统中执行任何计算,必须使用作为由计算方法和数据组成的对象的任务。

ECS系统中的任何其他数据一样, 作业系统中的任务也表示为继承三个接口之一的结构。

工友


最简单的任务接口包含一个Execute方法,该方法不接受参数形式的任何内容,也不返回任何内容。

任务本身如下所示:

工友
public struct JobStruct : IJob { public void Execute() {} } 


Execute方法中,您可以执行必要的计算。

IJobParallelFor


另一个具有相同Execute方法的接口,该接口已经接受了数字参数index

IJobParallelFor
 public struct JobStruct : IJobParallelFor { public void Execute(int index) {} } 


IJob接口不同,此IJobParallelFor接口提供了多次执行任务的功能,不仅可以执行,还可以将执行分成若干块,这些块将在线程之间分配。

不清楚 不用担心,我会告诉您更多。

IJobParallelForTransform


顾名思义,最后一个特殊接口旨在与对象的这些转换配合使用。 它还包含Execute方法,以及数字参数索引TransformAccess参数,在此位置变换的位置,大小和旋转。

IJobParallelForTransform
 public struct JobStruct : IJobParallelForTransform { public void Execute(int index, TransformAccess transform) {} } 


由于您不能直接在任务中使用统一对象,因此该接口只能将转换数据作为单独的TransformAccess结构进行处理。

完成后,现在您知道如何创建任务结构了,您可以继续练习。

任务完成


让我们创建一个继承自IJob接口的简单任务并完成它。 为此,我们需要任何简单的MonoBehaviour脚本和任务本身的结构。

测试工作
 public class TestJob : MonoBehaviour { void Start() {} } 


现在,将此脚本放在场景中的某个对象上。 在下面的同一脚本( TestJob )中,我们将编写任务的结构,并且不要忘记导入必要的库。

简单的工作
 using Unity.Jobs; public struct SimpleJob : IJob { public void Execute() { Debug.Log("Hello parallel world!"); } } 


例如,在Execute方法中,将简单的行打印到控制台。

现在,我们继续执行TestJob脚本的Start方法,在该方法中,我们将创建任务的一个实例,然后执行它。

测试工作
 public class TestJob : MonoBehaviour { void Start() { SimpleJob job = new SimpleJob(); job.Schedule().Complete(); } } 


如果您按照示例进行了所有操作,那么在开始游戏后,您将收到一条简单的消息,如图所示。

图片

发生的情况:调用Schedule方法后,调度程序将任务放在句柄中,现在可以通过调用Complete方法来完成任务。

这是仅将文本打印到控制台的任务示例。 为了使任务执行任何并行计算,必须将其填充数据。

任务中的数据


与在ECS系统中一样,在任务中无法访问统一对象,因此无法将GameObject放入任务并在其中更改其名称。 您所要做的就是将一些单独的对象参数传输到任务,更改这些参数,然后在完成任务后,将这些更改应用回该对象。

任务本身中的数据有几个限制:首先,它必须是结构,其次, 不能可转换的数据类型,也就是说,您不能将相同的布尔值字符串传递给任务。

简单的工作
 public struct SimpleJob : IJob { public float a, b; public void Execute() { float result = a + b; Debug.Log(result); } } 


主要条件是:未封装在容器中的数据只能在任务内部访问!

货柜


使用多线程计算时,需要以某种方式在线程之间交换数据。 为了能够将数据传输到其中并在任务系统中读取它们,出于这些目的,有一些容器。 这些容器以普通结构的形式表示,我以网桥的原理工作,通过该网桥在流之间同步基本数据。

有几种类型的容器:
NativeArray 。 最简单和最常用的容器类型是具有固定大小的简单数组。
NativeSlice 。 另一个容器-从翻译中可以明显看出,一个数组旨在将NativeArray切成碎片。

这是不连接ECS系统可用的两个主要容器。 在更高级的版本中,还有更多类型的容器。

NativeList 。 它是常规数据列表。
NativeHashMap 。 具有键和值的字典的类似物。
NativeMultiHashMap 。 相同的NativeHashMap ,一个键下只有几个值。
NativeQueue 数据队列列表。

由于我们无需连接ECS系统即可工作,因此只有NativeArrayNativeSlice可供我们使用

在进行实际操作之前,有必要分析最重要的一点-实例的创建。

创建容器


如前所述,这些容器代表了一个桥梁,线程之间的数据在该桥梁上同步。 任务系统在开始工作之前打开此桥,在完成工作后关闭它。 打开过程称为“ 分配 ”( Allocation )或“内存分配” ,关闭过程称为“ 资源释放 ”( Dispose )。

分配决定了任务可以使用容器中数据的时间-换句话说,桥接器将打开多长时间。

为了更好地理解这两个过程,让我们看一下下面的图片。

图片

下半部分显示了主线程( Main thread )的生命周期,它是根据帧数计算的;在第一帧中,我们创建了另一个并行线程( New thread) ,该线程存在一定数量的帧,然后安全地关闭。
在同一新线程中,带有容器任务到达。

现在看图片的顶部。

图片

白色条分配显示容器的寿命。 在第一个框架中,将分配容器-打开桥,直到此刻该容器不存在为止,在完成任务中的所有计算之后,该容器将释放内存,而在第9帧中,该桥被关闭。

同样在该条带( 分配 )上有时间段( TempTempJobPresistent ),每个时间段都显示了容器的估计寿命。

为什么需要这些细分! 事实是,按持续时间执行任务可能会有所不同,我们可以使用创建时所用的相同方法直接执行任务,如果执行起来很复杂,则可以延长任务执行时间,这些片段显示了任务可以使用数据的紧急程度和持续时间在容器中。

如果仍然不清楚,我将通过一个示例分析每种分配类型。

现在我们继续进行创建容器的实际操作,为此,请返回TestJob脚本的Start方法,并创建NativeArray容器的新实例,不要忘记连接必要的库。

温度


测试工作
 using Unity.Jobs; using Unity.Collections; public class TestJob : MonoBehaviour { void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); } } 


要创建一个新的容器实例,必须在其构造函数中指定分配的大小和类型。 由于仅在Start方法中执行任务,因此本示例使用Temp类型。

现在,在SimpleJob任务的结构中初始化完全相同的数组变量。

简单的工作
 public struct SimpleJob : IJob { public NativeArray<int> array; public void Execute() {} } 


做完了 现在,您可以创建任务本身,并将数组实例传递给它。

开始
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; } 


要这次运行任务,我们将使用其JobHandle句柄通过调用相同的Schedule方法来获取它。

开始
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); } 


现在,您可以在她的句柄上调用Complete方法,并检查任务是否完成以在控制台中显示文本。

开始
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); handle.Complete(); if (handle.IsCompleted) print(" "); } 


如果您以这种形式运行任务,那么在开始游戏后,您将收到一条红色错误消息,提示您在任务完成后没有从资源中释放数组容器。

这样的东西。

图片

为避免这种情况,请在完成任务后在容器上调用Dispose方法。

开始
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); handle.Complete(); if (handle.IsCompleted) print("Complete"); array.Dispose(); } 


然后,您可以安全地重新启动它。
但是任务什么都不做! -然后添加一些操作。

简单的工作
 public struct SimpleJob : IJob { public NativeArray<int> array; public void Execute() { for(int i = 0; i < array.Length; i++) { array[i] = i * i; } } } 


Execute方法中,我将自己乘以数组每个元素的索引,然后将其写回到数组数组,Start方法将结果打印到控制台。

开始
 void Start() { NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp); SimpleJob job = new SimpleJob(); job.array = array; JobHandle handle = job.Schedule(); handle.Complete(); if (handle.IsCompleted) print(job.array[job.array.Length - 1]); array.Dispose(); } 


如果我们将数组的最后一个元素打印为平方,那么在控制台中将得到什么结果?

这是创建容器,将其放入任务并对其执行操作的方式。

这是使用Temp分配类型的示例,这意味着在一帧之内完成一项任务。 当您需要在不加载主线程的情况下快速执行计算时,这种类型是最好的选择,但是如果任务太复杂或如果它们太多,则需要小心,可能会产生下垂现象,在这种情况下最好使用TempJob类型我将在后面进行分析。

临时工作


在此示例中,我将略微修改SimpleJob任务结构,并从另一个IJobParallelFor接口继承它。

简单的工作
 public struct SimpleJob : IJobParallelFor { public NativeArray<Vector2> array; public void Execute(int index) {} } 


此外,由于任务的运行时间超过一帧,因此我们将以协程形式提供不同的AwakeStart方法来执行和收集任务的结果。 为此,请稍微更改TestJob类的外观。

测试工作
 public class TestJob : MonoBehaviour { private NativeArray<Vector2> array; private JobHandle handle; void Awake() {} IEnumerator Start() {} } 


Awake方法中,我们将创建一个任务和一个向量容器,在Start方法中,将输出接收到的数据并释放资源。

醒着
 void Awake() { this.array = new NativeArray<Vector2>(100, Allocator.TempJob); SimpleJob job = new SimpleJob(); job.array = this.array; } 


再次在这里,使用分配类型TempJob创建一个数组容器,此后,我们创建任务并通过稍作更改调用Schedule方法来获取其句柄。

醒着
 void Awake() { this.array = new NativeArray<Vector2>(100, Allocator.TempJob); SimpleJob job = new SimpleJob(); job.array = this.array; this.handle = job.Schedule(100, 5) } 


Schedule方法中的第一个参数指示任务将被执行多少次,这与array array的大小相同。
第二个参数指示要共享任务的块数。

还有什么其他块?
以前,为了完成一项任务,一个线程简单地称为Execute方法一次,现在必须调用此方法100次,因此调度程序将这100次重复操作分成多个块,在每个线程之间分配该块以便不加载任何单独的线程。 在该示例中,一百次重复将分为5个块,每个块20个重复,也就是说,调度程序大概会将这5个块分配到5个线程中,其中每个线程将调用Execute方法20次。 当然,在实践中,调度程序不会那样做,这完全取决于系统的工作量,因此,所有100次重复都可能在一个线程中发生。

现在,您可以在任务句柄上调用Complete方法。

醒着
 void Awake() { this.array = new NativeArray<Vector2>(100, Allocator.TempJob); SimpleJob job = new SimpleJob(); job.array = this.array; this.handle = job.Schedule(100, 5); this.handle.Complete(); } 


启动协程中,我们将检查任务的执行情况,然后清理容器。

开始
 IEnumerator Start() { while(this.handle.isCompleted == false){ yield return new WaitForEndOfFrame(); } this.array.Dispose(); } 


现在,让我们继续执行任务本身中的操作。

简单的工作
 public struct SimpleJob : IJobParallelFor { public NativeArray<Vector2> array; public void Execute(int index) { float x = index; float y = index; Vector2 vector = new Vector2(x * x, y * y / (y * 2)); this.array[index] = vector; } } 


Start方法中完成任务之后,在控制台中显示数组的所有元素。

开始
 IEnumerator Start() { while(this.handle.IsCompleted == false){ yield return new WaitForEndOfFrame(); } foreach(Vector2 vector in this.array) { print(vector); } this.array.Dispose(); } 


完成后,您可以运行并查看结果。

要了解IJobIJobParallelFor之间的区别请看下面的图片。
例如,在IJob中,可以使用一个简单的for循环多次执行计算,但是在任何情况下,线程在整个任务过程中只能调用一次Execute方法,这是使一个人连续执行数百个相同动作的方法。

图片

IJobParallelFor提供的功能不仅可以在一个线程中多次执行一个任务,而且还可以在其他线程之间分配这些重复。

图片

通常,分配类型TempJob非常适合在几帧上执行的大多数任务。

但是,即使在完成任务后仍需要存储数据,又怎么办?如果收到结果后就不需要立即销毁数据,该怎么办。 为此,有必要使用Persistent分配类型,这意味着在“ 必要时”释放资源

持久的


让我们回到TestJob类并对其进行更改。 现在,我们将在OnEnable方法中创建任务,在Update方法中检查它们的执行,并在OnDisable方法中清理资源。
在示例中,我们将使用Update方法移动对象,为了计算轨迹,我们将使用两个向量容器-inputArray (将在其中放置当前位置)和outputArray(从中接收结果)。

测试工作
 public class TestJob : MonoBehaviour { private NativeArray<Vector2> inputArray; private NativeArray<Vector2> outputArray; private JobHandle handle; void OnEnable() {} void Update() {} void OnDisable() {} } 


我们还将通过从IJob接口继承它来执行一次来稍微修改SimpleJob任务结构。

简单的工作
 public struct SimpleJob : IJob { public void Execute() {} } 


在任务本身中,我们还将背叛两个向量容器,一个位置向量和一个数值增量,这会将对象移动到目标。

简单的工作
 public struct SimpleJob : IJob { [ReadOnly] public NativeArray<Vector2> inputArray; [WriteOnly] public NativeArray<Vector2> outputArray; public Vector2 position; public float delta; public void Execute() {} } 


ReadOnlyWriteOnly属性显示与容器内数据关联的操作的流限制。 ReadOnly仅提供流以从容器读取数据,相反, WriteOnly属性允许流仅将数据写入容器。 如果您需要使用一个容器一次执行这两项操作,则根本不需要使用属性来标记它。

让我们继续进行TestJob类的OnEnable方法,在该方法中初始化容器。

启用
 void OnEnable() { this.inputArray = new NativeArray<Vector2>(1, Allocator.Persistent); this.outputArray = new NativeArray<Vector2>(1, Allocator.Persistent); } 


容器的尺寸将是单一的,因为仅需要一次发送和接收参数。 分配类型将为Persistent
OnDisable方法中, 我们将释放容器的资源。

禁用
 void OnDisable() { this.inputArray.Dispose(); this.outputArray.Dispose(); } 


让我们创建一个单独的CreateJob方法,在其中创建带有其句柄的任务,然后在其中填充数据。

创建工作
 void CreateJob() { SimpleJob job = new SimpleJob(); job.delta = Time.deltaTime; Vector2 position = this.transform.position; job.position = position; Vector2 newPosition = position + Vector2.right; this.inputArray[0] = newPosition; job.inputArray = this.inputArray; job.outputArray = this.outputArray; this.handle = job.Schedule(); this.handle.Complete(); } 


实际上,这里实际上并不需要输入数组 ,因为可以将方向向量仅传递给任务,但是我认为最好理解为什么根本需要这些ReadOnlyWriteOnly属性。

Update方法中,我们将检查任务是否完成,然后将获得的结果应用于对象转换并再次运行。

更新资料
 void Update() { if (this.handle.IsCompleted) { Vector2 newPosition = this.outputArray[0]; this.transform.position = newPosition; CreateJob(); } } 


在开始之前,我们将稍微调整OnEnable方法,以便在初始化容器之后立即创建任务。

启用
 void OnEnable() { this.inputArray = new NativeArray<Vector2>(1, Allocator.Persistent); this.outputArray = new NativeArray<Vector2>(1, Allocator.Persistent); CreateJob(); } 


完成后,现在您可以转到任务本身,并在Execute方法中执行必要的计算。

执行
 public void Execute() { Vector2 newPosition = this.inputArray[0]; newPosition = Vector2.Lerp(this.position, newPosition, this.delta); this.outputArray[0] = newPosition; } 


要查看工作结果,可以将TestJob脚本放在某个对象上并运行游戏。

例如,我的精灵仅逐渐向右移动。

动画制作
图片

通常, 持久性分配类型非常适合可重用的容器,这些容器不需要每次都销毁和重新创建。

那用什么类型的!
Temp类型最适合用于快速执行计算,但是如果任务太复杂和太大,则会出现松弛。
TempJob类型非常适合处理统一对象,因此您可以更改对象的参数并将其应用,例如,在下一帧中。
当速度对您而言并不重要时,可以使用Persistent类型,但是您只需要不断地从侧面计算某种数据,例如,通过网络处理数据或AI的工作。

无效且无
还有两种分配类型InvalidNone ,但是调试它们需要更多类型,并且不参与工作。


作业手柄


另外,值得分析任务句柄的功能,因为除了检查任务执行过程之外,这个小句柄仍可以通过依赖关系创建整个任务网络(尽管我更喜欢将它们称为队列)。

例如,如果您需要按一定顺序执行两个任务,那么您只需要将一个任务的句柄附加到另一个任务的句柄即可。

看起来像这样。

图片

每个单独的句柄最初都包含其自己的任务,但是当组合在一起时,我们将获得一个包含两个任务的新句柄。

开始
 void Start() { Job jobA = new Job(); JobHandle handleA = jobA.Schedule(); Job jobB = new Job(); JobHandle handleB = jobB.Schedule(); JobHandle result = JobHandle.CombineDependecies(handleA, handleB); result.Complete(); } 


大概吧

开始
 void Start() { JobHandle handle; for(int i = 0; i < 10; i++) { Job job = new Job(); handle = job.Schedule(handle); } handle.Complete(); } 


保存执行序列,并且在确信上一个任务之前,调度程序将不会启动下一个任务,但是请务必记住IsCompleted 句柄属性将等待其中的所有任务完成。

结论


货柜


  1. 处理容器中的数据时,请不要忘记它们是结构,因此容器中数据的任何覆盖都不会更改它们,而是会再次创建它。
  2. 如果设置分配类型Temp并在任务完成后不清除资源,该怎么办? 错误。
  3. 我可以创建自己的容器吗? 可能在这里详细描述创建自定义容器过程,但是最好再三思:这是否值得,也许会有足够的普通容器!

安全!


.

( Random ), . , — .

?

, , . ECS, , , — . 10 — , — , .

, Job System . , ECS . WebGL , Job System , , .

Source: https://habr.com/ru/post/zh-CN420829/


All Articles