UnityECS学习09-Jobs基础知识
Jobs
基础知识
首先我们需要一些知识来前置了解一下 Unity Jobs System
:
Jobs 可以利用多核计算平台来简单安全编写并执行多线程代码,其可以与 ECS 组合使用也可以单独使用。编写时我们无需关心编写平台和 CPU 核心资源情况。
值得注意的是:JobSystem 内部是通过创建 job 而并非线程来管理多线程代码。其内部会跨多个核心来管理一组工作线程,以避免上下文的切换。开始时,JobSystem 会将我们创建的 job 任务加入到 job 等待队列当中,工作线程会从等待队列中获取并执行相应的工作。JobSystem 每次调度的时候,并不是一个 job 一个 job 地进行调度,而是会根据具体任务的复杂度来调度多个 job。工作线程的数量与硬件核心数量有关。
数据相关
No Race Conditions
:
一般(并不是不会发生)在每个 job 当中,只访问数据的拷贝或者转换一段 buffer 的所有权给 job 任务(也就是 Native Container
)
我们在使用 csharp 接口的 JobSystem 时,其实使用的就是其引擎底层的 c++ JobSystem。此处我们的游戏线程和引擎是没有上下文切换的开销的,但是 c++ 与 csharp 内存管理与值的类型定义不同,因此我们需要区Blittable Type
和 Non-Blittable Type
。其实 Blittable Type
就是在托管代码和非托管代码内存中表示形式相同的类型。其实大部分类型都符合我们的默认习惯,此处需注意:
System.Boolean
:在 csharp 当中使用四字节表示, 属于Blittable Type
。System.Char
:属于Blittable Type
。System.Array
:这个很好理解,毕竟其在 csharp 中属于引用类型,自然在Blittable Type
的范畴。
除使用 Blittable Type 之外,我们还可以使用一些 Native Container。其是 c++ 上的非托管堆数据,unity 将其封装:
- 其都是非托管堆上分配的。
- 使用
DisposeSentinel
来避免内存泄漏。 - 有安全检查,使用
AtomicSafetyHandle
来跟踪所有权和权限,避免race condition
错误。 - 需要手动调用
Dispose
释放。
同时这些 container 在分配内存的时候还会涉及到不同的分配类型(Allocation Type
):
Persistent
:长生命周期的内存TempJob
:只在 job 中存在的短生命周期,通常存在 4 帧及以下(4 帧以上会收到警告)Temp
:一个函数返回前的短声明周期内存
OOD 与 DOD 写法对比
1 | // WaveCubes.cs |
首先给出我们的面向对象写法。此代码是创建 6400 个预制体 Cube,然后按照 sin 函数的形状上下移动的代码。此处使用了一个小技巧能使得在 Profiler
窗口中查看数据:使用一个 ProfilerMarker<int>
来记录 Cube 的数量。
接下来运行看看此代码运行时在 Profiler
中的数据:
可以看到这个操作在我的电脑上面主线程耗时在 3ms 左右。
接着我们使用 JobSystem 来改造其为 DOD 的模式:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67// WaveCubesWithJobs.cs
using System;
using Unity.Burst;
using Unity.Collections;
using Unity.Profiling;
using UnityEngine;
using UnityEngine.Jobs;
namespace JobsTutorials.Lesson0.Scripts.DOD
{
[ ]
struct WaveJobs : IJobParallelForTransform
{
[public float elapsedTime; ]
public void Execute(int index, TransformAccess transform)
{
var distance = Vector3.Distance(transform.position, Vector3.zero);
transform.position += Vector3.up * Mathf.Sin(elapsedTime * 3f + distance * 0.2f);
}
}
public class WaveCubesWithJobs : MonoBehaviour
{
public GameObject cubeArchetype;
[public int xHalfCount = 40; ]
[public int zHalfCount = 40; ]
private TransformAccessArray _transformAccessArray;
private static readonly ProfilerMarker<int> profilerMarker =
new ProfilerMarker<int>("WaveCubes UpdateTransform", "Objects Count");
private void Start()
{
_transformAccessArray = new TransformAccessArray(4 * xHalfCount * zHalfCount);
for (var x = -xHalfCount; x <= xHalfCount; x++)
{
for (var z = -zHalfCount; z <= zHalfCount; z++)
{
var cube = Instantiate(cubeArchetype);
cube.transform.position = new Vector3(x * 1.1f, 0, z * 1.1f);
_transformAccessArray.Add(cube.transform);
}
}
}
private void Update()
{
using (profilerMarker.Auto(_transformAccessArray.length))
{
var job = new WaveJobs
{
elapsedTime = Time.time
};
var waveCubesJobHandle = job.Schedule(_transformAccessArray);
// 等待工作线程,同步到主线程当中
waveCubesJobHandle.Complete();
}
}
private void OnDestroy()
{
_transformAccessArray.Dispose();
}
}
}
接着我们运行:
可以很清楚看到,我们的主线程耗时被优化到了 0.3ms 左右,下面也可以看出我们更新物体 transform 的操作很多被塞进了工作线程之内。
代码解析及 job 调度方式
代码解析
上面我们刚说过,JobSystem 只能使用 Blittable Type 和非托管堆上面的内存。而我们的 Transform
类型并不满足这两者中的任一,因此需要 TransformAccessArray
和 TransformAccess
类型来提供对其的访问。而 Vector3
类型是 Blittable Type 组成的,因此也支持 JobSystem 访问。
Job 调度方式
Job 的调度方式:
Run
:主线程立即执行Schedule
:单个主线程或者工作线程,每个 Job 按顺序执行ScheduleParallel
:在多个工作线程上执行,性能最好,但是多个工作线程访问同一数据时可能会发生冲突
注意:名字带有 Parallel 的 Job 类型,只提供 Schedule
。但其功能和 ScheduleParallel
还是一样的。
Job Dependencies
给出如下场景:
此处的 job A, B 都通过 Schedule 调度访问 NativeArray 时,此时 JobSystem 的安全检查就会检测到出现 Race Condition 的问题,接着抛出异常。
一种解决办法是我们可以在 Job B 调度之前调用 Job A 的 Complete
方法来避免冲突,但是每次需要重新回到主线程,再重新调度,这种方法并不高效。
更好的做法是:在调度 Job B 时传递 Job A 的 Handle 句柄,这样使得 A 成为 B 的依赖,由此保证 Job A 在 Job B 之前完成。
Job 链式依赖
1 | var jhA = jobA.Schedule(); |
多个 Job 依赖于一个 Job
1 | var jhA = jobA.Schedule(); |
一个 job 同时依赖于多个 job
1 | var jhA = jobA.Schedule(); |
注意:依赖关系必须是无环的
Job Safety Checks
JobSystem 的安全检查是基于谨慎性而设置的,有些场景下也不一定很智能:
比如两个 job 分别访问同一个 Native Container 对象的一部分数据,例如此处访问的是同一个 NativeArray 中的不同 Slice,虽然二者访问的数据没有交集,并不冲突。但安全检查仍会认为这是不安全的,并抛出异常。此时我们可以单独为这些需要访问的数据设置安全检查的覆写标签。1
2
3
4
5
6
7
8
9
10
11[ ]
public struct xxxJob : IJob
{
[ ]
public NativeArray<xxx> xxxArray;
public void Extute()
{
// do something...
}
}
如果在我们确保 Native Container 没有写入操作的时候,更好的办法是使用 [ReadOnly]
属性标签来修饰它。这样被多个 job 访问时,也不会抛出异常。
参考: