> 技术文档 > Unity JobSystem:高效多线程编程指南_unity job system

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:

  1. 需额外执行数据转换(如序列化),显著增加开销。
  2. 可能破坏线程安全性(如引用类型跨线程共享)。
  3. Burst 编译器要求:使用 Burst 编译 Job 时,Job 内所有字段必须是 Blittable 类型。

Blittable 类型的判定规则

典型 Blittable 类型(可直接内存复制)

类别

具体类型

基本数值类型

byte, sbyte, short, ushort, int, uint, long, ulong, float, double

简单结构体

所有字段均为 Blittable 类型的 struct(如 Vector3Matrix4x4

枚举

底层类型为整数(如 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 强制控制线程等待任务执行结束。调用 JobHandleComplete() 方法即可实现。此时,控制线程可安全访问任务使用的 NativeArray 数据。

        注意:任务在被调度时不会立即执行。若需在控制线程等待任务,并访问任务使用的 NativeArray 数据,可调用 JobHandle.Complete()。此方法会清空内存缓存中的任务并启动执行流程。

        调用 Complete() 会将任务使用的 NativeArray 所有权交还给控制线程。必须调用 Complete() 才能再次从控制线程安全访问这些 NativeArray。也可通过调用依赖任务的 JobHandle.Complete() 交还所有权(例如:直接调用任务A的 Complete(),或调用依赖任务A的任务B的 Complete())。

        两种方式都能确保任务A使用的 NativeArray Complete() 调用后可在控制线程安全访问。

关键概念解析:

  1. 控制线程 (Control Thread)
    主线程(通常是Unity的主线程),负责调度任务并协调执行流程。
  2. JobHandle.Complete() 的核心作用
    • 同步机制:强制控制线程阻塞等待,直到关联任务及其依赖链全部完成。
    • 内存所有权转移:将任务占用的 NativeContainer(托管原生内存容器)控制权交回主线程,解除并行访问限制。
    • 隐式执行触发:清空任务缓存队列,立即启动任务执行(任务调度后默认处于排队状态)。
  1. 依赖任务的所有权传递

调用JobHandleA.Complete()JobHandleB.Complete()均能释放任务A的NativeContainer

本质:任务B依赖任务A → 完成B需隐式完成A → 所有权自动回溯至根依赖。

 

Unity JobSystem:高效多线程编程指南_unity job system

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);