在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系统即可工作,因此只有
NativeArray和
NativeSlice可供
我们使用 。
在进行实际操作之前,有必要分析最重要的一点-实例的创建。
创建容器
如前所述,这些容器代表了一个桥梁,线程之间的数据在该桥梁上同步。 任务系统在开始工作之前打开此桥,在完成工作后关闭它。 打开过程称为“
分配 ”(
Allocation )或
“内存分配” ,关闭过程称为“
资源释放 ”(
Dispose )。
分配决定了任务可以使用容器中数据的时间-换句话说,桥接器将打开多长时间。
为了更好地理解这两个过程,让我们看一下下面的图片。

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

白色条
分配显示容器的寿命。 在第一个框架中,将
分配容器-打开桥,直到此刻该容器不存在为止,在完成任务中的所有计算之后,该容器将释放内存,而在第9帧中,该桥被关闭。
同样在该条带(
分配 )上有时间段(
Temp ,
TempJob和
Presistent ),每个时间段都显示了容器的估计寿命。
为什么需要这些细分! 事实是,按持续时间执行任务可能会有所不同,我们可以使用创建时所用的相同方法直接执行任务,如果执行起来很复杂,则可以延长任务执行时间,这些片段显示了任务可以使用数据的紧急程度和持续时间在容器中。
如果仍然不清楚,我将通过一个示例分析每种分配类型。现在我们继续进行创建容器的实际操作,为此,请返回
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) {} }
此外,由于任务的运行时间超过一帧,因此我们将以协程形式提供不同的
Awake和
Start方法来执行和收集任务的结果。 为此,请
稍微更改
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(); }
完成后,您可以运行并查看结果。
要了解
IJob和
IJobParallelFor之间的
区别 ,
请看下面的图片。
例如,在
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() {} }
ReadOnly和
WriteOnly属性显示与容器内数据关联的操作的流限制。
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(); }
实际上,这里实际上并不需要输入数组 ,因为可以将方向向量仅传递给任务,但是我认为最好理解为什么根本需要这些ReadOnly和WriteOnly属性。在
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的工作。
无效且无还有两种分配类型Invalid和None ,但是调试它们需要更多类型,并且不参与工作。
作业手柄
另外,值得分析任务句柄的功能,因为除了检查任务执行过程之外,这个小句柄仍可以通过依赖关系创建整个任务网络(尽管我更喜欢将它们称为队列)。
例如,如果您需要按一定顺序执行两个任务,那么您只需要将一个任务的句柄附加到另一个任务的句柄即可。
看起来像这样。

每个单独的句柄最初都包含其自己的任务,但是当组合在一起时,我们将获得一个包含两个任务的新句柄。
开始 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
句柄属性将等待其中的所有任务完成。
结论
货柜
- 处理容器中的数据时,请不要忘记它们是结构,因此容器中数据的任何覆盖都不会更改它们,而是会再次创建它。
- 如果设置分配类型Temp并在任务完成后不清除资源,该怎么办? 错误。
- 我可以创建自己的容器吗? 可能在这里详细描述了创建自定义容器的过程,但是最好再三思:这是否值得,也许会有足够的普通容器!
安全!
.(
Random ), . , — .
?, , .
ECS, , , — . 10 — , — , .
,
Job System . ,
ECS .
WebGL ,
Job System , , .