跳转至

Job System 与 Burst

第一次用 Job

刚开始用 Job System 时,我把一个循环改成 Job,然后在同一行 Complete(),结果不仅没变快,主线程还多了一次调度开销。

后来才明白,Job 的收益来自“把可并行的计算挪走,并且在真正需要结果之前不等待”。如果提交完马上等结果,等于只是换了一个写法。1

一个最小 Job

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

[BurstCompile]
public struct IntegrateJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float3> Velocity;
    public NativeArray<float3> Position;
    public float DeltaTime;

    public void Execute(int index)
    {
        Position[index] += Velocity[index] * DeltaTime;
    }
}

调度时不要立刻 Complete:

var job = new IntegrateJob
{
    Velocity = _velocity,
    Position = _position,
    DeltaTime = Time.deltaTime
};

_integrateHandle = job.Schedule(_position.Length, 64);

64 是每批处理的数量。数量太小,调度次数会变多;数量太大,负载不容易平均到各个 Worker。可以先用 32、64、128 测一轮,看实际耗时再定。

等主线程真的需要读 _position 时再完成:

_integrateHandle.Complete();

// 这里才能安全读取 _position

如果后面的 Job 依赖这一步,可以把 Handle 传进去,让它自己排顺序:

var sortHandle = sortJob.Schedule(_integrateHandle);
var uploadHandle = uploadJob.Schedule(sortHandle);

不要在中间随便插入 Complete,否则后面的任务又会被拉回主线程。

Burst 能做什么

Burst 适合处理纯数据计算,例如位置积分、视锥裁剪、寻路评分和批量插值。它不能直接操作 GameObject、Transform、MonoBehaviour,也不适合依赖反射或托管异常。

使用 [BurstCompile] 后,最好打开 Burst Inspector 看生成的代码和编译日志。仅仅加上特性不代表一定编译成功,也不代表一定比手写代码快。2

适合 不适合
NativeArray<T>、float3、数学函数 托管对象和大部分 Unity API
固定大小的纯数据计算 动态分配、反射、异常处理
批量循环和 SIMD 逻辑复杂但数据量很小的代码

数据布局比线程数更重要

如果 Job 只需要位置和速度,就不要把整个角色对象都塞进去。把同类字段放在连续内存里,缓存命中率会好很多。

// AoS:一个结构体里放所有字段
public struct Agent
{
    public float3 Position;
    public float3 Velocity;
    public int Health;
    public int Team;
}

// SoA:按字段拆成多个数组
public NativeArray<float3> Positions;
public NativeArray<float3> Velocities;

SoA 不是所有情况都更好。数据量小、访问模式不固定时,AoS 更直观,代码也更好维护。只有在热点循环里反复批量读取同一组字段时,拆分才有明显收益。

Native 容器的生命周期

Allocator 适合
Allocator.Temp 单次调用,不能跨帧
Allocator.TempJob 短时间 Job 链,需要尽快释放
Allocator.Persistent 长期 Buffer,需要手动 Dispose

NativeArray<T> 不受 GC 管理,但忘记释放一样会泄漏。3 长期 Buffer 最好交给一个明确的系统管理,在 OnDestroy 或场景卸载时统一释放。

什么时候不用 Job

  • 数据只有几十个,调度成本比计算还高。
  • 逻辑必须访问场景对象,搬到 Job 里只会增加同步。
  • 每帧都要马上读结果,无法把等待藏到其他工作后面。
  • 数据结构还没稳定,过早并行会让调试变难。

先让 Profiler 证明主线程确实是瓶颈,再决定要不要把代码改成 Job。否则很容易把一段好调试的代码,换成一段更难维护的并行代码。