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 TypeNon-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
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
// WaveCubes.cs

using System;
using System.Collections.Generic;
using Unity.Profiling;
using UnityEngine;

namespace JobsTutorials.Lesson0.Scripts.OOD
{
public class WaveCubes : MonoBehaviour
{
public GameObject cubeArchetype;
[Range(1, 100)] public int xHalfCount = 40;
[Range(1, 100)] public int zHalfCount = 40;
private List<Transform> _cubesList;

private static readonly ProfilerMarker<int> profilerMarker =
new ProfilerMarker<int>("WaveCubes UpdateTransform", "Objects Count");

private void Start()
{
_cubesList = new List<Transform>();
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);
_cubesList.Add(cube.transform);
}
}
}

private void Update()
{
using (profilerMarker.Auto(_cubesList.Count))
{
for (var i = 0; i < _cubesList.Count; i++)
{
var distance = Vector3.Distance(_cubesList[i].position, Vector3.zero);
_cubesList[i].localPosition += Vector3.up * Mathf.Sin(Time.time * 3f + distance * 0.2f);
}
}
}
}
}

首先给出我们的面向对象写法。此代码是创建 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
{
[BurstCompile]
struct WaveJobs : IJobParallelForTransform
{
[ReadOnly] 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;
[Range(1, 100)] public int xHalfCount = 40;
[Range(1, 100)] 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 类型并不满足这两者中的任一,因此需要 TransformAccessArrayTransformAccess 类型来提供对其的访问。而 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
2
3
var jhA = jobA.Schedule();
var jhB = jobB.Schedule(jhA);
var jhC = jobC.Schedule(jhB);

多个 Job 依赖于一个 Job

1
2
3
var jhA = jobA.Schedule();
var jhC = jobC.Schedule(jhA);
var jhB = jobB.Schedule(jhA);

一个 job 同时依赖于多个 job

1
2
3
4
5
var jhA = jobA.Schedule();
var jhB = jobB.Schedule();
// 构建一个虚拟 handle 来合并多个 job 依赖
var jhAB = JobHandle.CombineDependencies(jhA, jhB);
var jhC = jobC.Schedule(jhAB);

注意:依赖关系必须是无环的

Job Safety Checks

JobSystem 的安全检查是基于谨慎性而设置的,有些场景下也不一定很智能:

比如两个 job 分别访问同一个 Native Container 对象的一部分数据,例如此处访问的是同一个 NativeArray 中的不同 Slice,虽然二者访问的数据没有交集,并不冲突。但安全检查仍会认为这是不安全的,并抛出异常。此时我们可以单独为这些需要访问的数据设置安全检查的覆写标签。

1
2
3
4
5
6
7
8
9
10
11
[BurstCompile]
public struct xxxJob : IJob
{
[NativeDisableContainerSafetyRestriction]
public NativeArray<xxx> xxxArray;

public void Extute()
{
// do something...
}
}

如果在我们确保 Native Container 没有写入操作的时候,更好的办法是使用 [ReadOnly] 属性标签来修饰它。这样被多个 job 访问时,也不会抛出异常。


参考: