Unity JobSystem:高效多线程编程指南_unity job system
一、前言:
Job System 是 Unity 引擎提供的安全多线程编程框架,旨在充分利用多核 CPU 性能,特别适用于计算密集型任务(如物理模拟、动画、大规模数据处理)。其核心设计逻辑是通过将任务拆分为小型、可并行执行的单元(Job),在避免传统多线程编程复杂性的同时提升性能
二、NativeArray
NativeArray 会向托管代码显示本机内存缓冲区,从而可以在托管数组和本机数组之间共享数据。
注意:要求 T
是 Blittable 类型。
Allocator : 内存分配类型
Temp :具有最快的分配速度。此类型适用于寿命为一帧或更短的分配。不应该使用 Temp 将 NativeContainer
分配传递给作业。
TempJob :分配速度比 Temp
慢,但比 Persistent
快。此类型适用于寿命为四帧的分配,并具有线程安全性。如果没有在四帧内对其执行 Dispose
方法,控制台会输出一条从本机代码生成的警告。大多数小作业都使用这种 NativeContainer
分配类型。
Persistent :是最慢的分配,但可以在您所需的任意时间内持续存在,如果有必要,可以在整个应用程序的生命周期内存在。此分配器是直接调用 malloc 的封装器。持续时间较长的作业可以使用这种 NativeContainer
分配类型。在非常注重性能的情况下不应使用 Persistent
。
例如:
NativeArray result = new NativeArray(1, Allocator.TempJob);
注意:上例中的数字 1 表示 NativeArray
的大小。在此例子中只有一个数组元素(因为只会在 result
中存储一段数据)。
三、Blittable types
核心定义
- Blittable 类型:指在托管代码(C#)和非托管代码(Native)中具有完全相同的内存布局(memory layout)和字节表示(byte representation) 的数据类型。
- 关键特性:这类数据在托管与非托管环境间传递时,无需进行格式转换或序列化,可直接通过内存拷贝(memcpy) 高效复制,性能极高。
为什么在 Unity Job System 中重要?
Job System 默认将 Job 所需数据复制到工作线程的本地内存(避免线程竞争)。若数据类型非 Blittable:
- 需额外执行数据转换(如序列化),显著增加开销。
- 可能破坏线程安全性(如引用类型跨线程共享)。
- Burst 编译器要求:使用 Burst 编译 Job 时,Job 内所有字段必须是 Blittable 类型。
Blittable 类型的判定规则
✅ 典型 Blittable 类型(可直接内存复制)
类别
具体类型
基本数值类型
byte
, sbyte
, short
, ushort
, int
, uint
, long
, ulong
, float
, double
简单结构体
所有字段均为 Blittable 类型的 struct
(如 Vector3
、Matrix4x4
)
枚举
底层类型为整数(如 enum MyEnum : int
)
特定指针
IntPtr
(平台相关指针)
一维数组
元素为 Blittable 类型的数组(如 int[]
, float[]
)
❌ 非 Blittable 类型(禁止在 Job 中直接使用)
类别
原因
引用类型
string
, class
, delegate
, array of non-blittable
(内存布局由 CLR 管理,非固定)
包含引用字段的结构体
如 struct { int id; string name; }
(因 string
字段)
布尔类型
bool
(在 C# 中为 1 字节,但某些 Native 环境可能用 4 字节)
特殊类型
decimal
(内部结构复杂)
四、创建作业
要创建作业,您需要:
- 创建实现
IJob
的结构。 - 添加该作业使用的成员变量(为 blittable 类型和 NativeContainer 类型之一)。
- 在结构中创建一个名为 Execute 的方法,并在其中实现该作业。
执行作业时,Execute
方法在单个核心上运行一次。
注意 :在设计作业时,请记住它们是在数据副本上操作的, 但 NativeContainer
除外。因此,从控制线程中的作业访问数据的唯一方法是写入 NativeContainer
。
// 将两个浮点值相加的作业public struct MyJob : IJob{ public float a; public float b; public NativeArray result; public void Execute() { result[0] = a + b; }}
五、调度作业
调用 Schedule
会将该作业放入作业队列中,以便在适当的时间执行。一旦作业已调度,就不能中断作业。
// 创建单个浮点数的本机数组以存储结果。此示例等待作业完成,仅用于演示目的NativeArray result = new NativeArray(1, Allocator.TempJob);// 设置作业数据MyJob jobData = new MyJob();jobData.a = 10;jobData.b = 10;jobData.result = result;// 调度作业JobHandle handle = jobData.Schedule();// 等待作业完成handle.Complete();// NativeArray 的所有副本都指向同一内存,您可以在\"您的\"NativeArray 副本中访问结果float aPlusB = result[0];// 释放由结果数组分配的内存result.Dispose();
六、JobHandle 和依赖项
调用作业的 Schedule 方法时,将返回 JobHandle。可以在代码中使用 JobHandle
作为其他作业的依赖项。如果一个作业依赖于另一个作业的结果,则可以将第一个作业的 JobHandle
作为参数传递给第二个作业的 Schedule
方法,如下所示:
JobHandle firstJobHandle = firstJob.Schedule();secondJob.Schedule(firstJobHandle);
注意:作业的所有依赖项必须与作业本身安排在同一个控制线程上。
七、在控制线程中等待任务完成
通过 JobHandle
强制控制线程等待任务执行结束。调用 JobHandle
的 Complete()
方法即可实现。此时,控制线程可安全访问任务使用的 NativeArray 数据。
注意:任务在被调度时不会立即执行。若需在控制线程等待任务,并访问任务使用的 NativeArray 数据,可调用 JobHandle.Complete()
。此方法会清空内存缓存中的任务并启动执行流程。
调用 Complete()
会将任务使用的 NativeArray 所有权交还给控制线程。必须调用 Complete()
才能再次从控制线程安全访问这些 NativeArray。也可通过调用依赖任务的 JobHandle.Complete()
交还所有权(例如:直接调用任务A的 Complete()
,或调用依赖任务A的任务B的 Complete()
)。
两种方式都能确保任务A使用的 NativeArray 在 Complete()
调用后可在控制线程安全访问。
关键概念解析:
- 控制线程 (Control Thread)
主线程(通常是Unity的主线程),负责调度任务并协调执行流程。 - JobHandle.Complete() 的核心作用
-
- 同步机制:强制控制线程阻塞等待,直到关联任务及其依赖链全部完成。
- 内存所有权转移:将任务占用的
NativeContainer
(托管原生内存容器)控制权交回主线程,解除并行访问限制。 - 隐式执行触发:清空任务缓存队列,立即启动任务执行(任务调度后默认处于排队状态)。
- 依赖任务的所有权传递
调用JobHandleA.Complete()
或JobHandleB.Complete()
均能释放任务A的NativeContainer
本质:任务B依赖任务A → 完成B需隐式完成A → 所有权自动回溯至根依赖。
4.线程安全访问规则
操作时机
NativeContainer 访问权限
任务调度后未调用Complete
❌ 控制线程访问引发竞态条件
调用Complete后
✅ 安全独占访问
5.设计意图说明
- 延迟执行优化:任务队列缓存允许Unity批量合并调度,减少线程切换开销。
- 显式同步控制:通过
Complete()
精确管理数据竞争风险,避免隐式等待导致的性能不确定性。
实务建议:在帧逻辑末尾集中调用 Complete()
,确保所有并行任务结果就绪后再处理数据,避免主线程频繁阻塞等待。
八、IJobParallelFor
- 设计目的:为纯并行任务设计,自动分割数据并在多个线程上并行执行。
- 实现要求:
- 必须实现
Execute(int index)
方法。 - 任务之间不能有依赖关系(每个
index
独立处理)。
- 必须实现
调度方式:
// arrayLength:要处理的数据总量。// batchSize:每个线程处理的批次大小(影响负载均衡)。JobHandle Schedule(int arrayLength, int batchSize, JobHandle dependency);
- 特点:
自动并行化:Unity 自动分配线程处理不同 index
。
无执行顺序保证:不同 index
的执行顺序不确定。
- 适用场景:大规模数据并行(如顶点处理、网格计算)。
public struct AddJob : IJobParallelFor{ public NativeArray Result; public NativeArray A; public NativeArray B; public void Execute(int i) { Result[i] = A[i] + B[i]; // 每个索引独立计算 }}// 调度var job = new AddJob { Result = result, A = a, B = b };jobHandle = job.Schedule(result.Length, 64, default);
九、IJobFor (更灵活的新方式)
- 设计目的:提供灵活的执行控制,可选择单线程或并行执行。
- 实现要求:
- 同样实现
Execute(int index)
方法。 - 可选择三种调度模式:
- 同样实现
Run()
:主线程同步执行。
Schedule()
:单线程异步执行(顺序执行)。
ScheduleParallel()
:多线程并行执行(类似 IJobParallelFor
)。
- 特点:
灵活性:自由切换执行模式(调试/性能权衡)。
依赖控制:支持更精细的依赖关系管理。
现代 API:与 Unity ECS 集成更紧密。
- 适用场景:
需要灵活控制执行方式的场景。
兼容 ECS 的代码库。
public struct AddJob : IJobFor{ public NativeArray Result; public NativeArray A; public NativeArray B; public void Execute(int i) { Result[i] = A[i] + B[i]; }}// 三种调度方式var job = new AddJob { Result = result, A = a, B = b };// 方式1: 主线程同步执行job.Run(result.Length);// 方式2: 单线程异步顺序执行JobHandle handle1 = job.Schedule(result.Length, default);// 方式3: 多线程并行执行JobHandle handle2 = job.ScheduleParallel(result.Length, 64, default);