Unity学习—JobSystem

译自官方手册,简述 Unity 另一个多线程实现方式,JobSystem,为 Unity ECS 系统实现的根本

原文

JobSystem

JobSystem 管理一组多核中的工作线程(Work Thread),为避免上下文切换通常一个逻辑核配一个工作线程

JobSystem 持有一个 Job 队列,工作线程从该队列中获取 Job 执行

Job 是执行特定任务的小工作单元,Job 可以互相依赖

线程安全

JobSystem 执行时复制而非引用数据,避免了数据竞争,但 JobSystem 只能使用memcpy复制 blittable types 数据。Blittable types 是 .Net 框架中的数据类型,该类型数据在托管代码与原生代码间传递无需转换

NativeContainer

复制数据来保证线程安全的弊端就是任务的结果也是独立的,因此使用NativeContainer将结果储存在公共内存中

NativeContainer以相对安全的托管类型的方式指向一个非托管的内存地址,使Job 可以直接访问主线程数据而非复制

Unity 自带 NativeContainer类型为 NativeArray,ECS 包又扩展了NativeListNativeHashMapNativeMultiHashMapNativeQueue

默认情况下,Job 同时拥有NativeContainer的读写权限,但 C# Job System 不允许多个 Job 同时拥有对一个NativeContainer的写权限,因此对不需要写权限的NativeContainer加上[ReadOnly]特性,以减少性能影响

[ReadOnly]
public NativeArray<int> input;

JobSystem 支持多个 Job 同时读取同一数据

NativeContainer Allocator

根据 Job 执行时长决定使用哪种 Allocator

  • Allocator.Temp

    最快的分配方法,适用于一帧或几帧的生命时长,不能将该类型分配的数据传给 Job,在方法 Return 前执行Dispose

  • Allocator.TempJob

    分配速度比 Temp 慢比 Persistent 快,4帧的生命时长且线程安全。若四帧内没有调用Dispose,控制台会打印原生代码生成的警告。大部分小任务都使用该类型分配NativeContainer

  • Allocator.Persistent

    是对malloc的包装,能够维持尽可能地生命时长,性能不足的情况下不应使用

NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

创建 Job

  1. 声明实现 IJob 接口的结构体
  2. 添加blittable typesNativeContainer类型的成员变量
  3. 实现Execute方法

当 Job 执行时,Execute在一个核上执行一次

public struct MyJob : IJob
{
    public float a;
    public float b;
    public NativeArray<float> result;

    public void Execute()
    {
        result[0] = a + b;
    }
}

调度 Job

  1. 创建 Job
  2. 填充 Job 数据
  3. 调用Schedule方法

只能在主线程调用Schedule方法,将 Job 放入队列等待执行,一旦 Job 被调度进队列旧无法中断

NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

// 填充数据
MyJob jobData = new MyJob();
jobData.a = 10;
jobData.b = 10;
jobData.result = result;

// 调度 Job
JobHandle handle = jobData.Schedule();

// 等待完成
handle.Complete();

// 所有 NativeArray 指向同一内存,可外部访问
float aPlusB = result[0];

// 释放 result array
result.Dispose();

JobHandle 和 dependencies

JobHandle 是在调用Schedule返回的句柄,可使用该句柄作为参数传入另一个 Job 的Schedule作为依赖,使后者等待前者执行完成再执行

JobHandle firstJobHandle = firstJob.Schedule();
secondJob.Schedule(firstJobHandle);

对于多个依赖的 Job 可使用JobHandle.CombineDependencies组合这些 JobHandle

NativeArray<JobHandle> handles = new NativeArray<JobHandle>(numJobs, Allocator.TempJob);
// 填充 handles
JobHandle jh = JobHandle.CombineDependencies(handles);

调用 JobHandle 的Complete方法可使主线程等待任务执行完成以安全访问该 Job 使用的 NativeContainer,该方法会从内存中刷新 Job 并开始执行然后将该 Job 中的NativeContainer持有权返回主线程

若不需访问数据,但需要立即刷新执行 Job 缓存,则可以使用JobHandle.ScheduleBatchedJobs,但会影响性能

public struct MyJob : IJob
{
    public float a;
    public float b;
    public NativeArray<float> result;

    public void Execute()
    {
        result[0] = a + b;
    }
}

// Job adding one to a value
public struct AddOneJob : IJob
{
    public NativeArray<float> result;
    
    public void Execute()
    {
        result[0] = result[0] + 1;
    }
}

NativeArray<float> result = new NativeArray<float>(1, Allocator.TempJob);

// Setup the data for job #1
MyJob jobData = new MyJob();
jobData.a = 10;
jobData.b = 10;
jobData.result = result;

// Schedule job #1
JobHandle firstHandle = jobData.Schedule();

// Setup the data for job #2
AddOneJob incJobData = new AddOneJob();
incJobData.result = result;

// Schedule job #2
JobHandle secondHandle = incJobData.Schedule(firstHandle);

// Wait for job #2 to complete
secondHandle.Complete();
// All copies of the NativeArray point to the same memory, you can access the result in "your" copy of the NativeArray
float aPlusB = result[0];

// Free the memory allocated by the result array
result.Dispose();

IJobParallelFor

对于IJob,同一时间一个 Job 只能执行一个任务,若想同一时间执行多个相同的任务,则可以使用IJobParallelFor

一种使用场景为 ParallelFor Job 在多核上同时对同一 NativeArray 进行操作,每核仅负责部分工作,ParallelFor Job 的Execute方法会传入index,用于访问数据源

struct IncrementByDeltaTimeJob: IJobParallelFor
{
    public NativeArray<float> values;
    public float deltaTime;

    public void Execute (int index)
    {
        float temp = values[index];
        temp += deltaTime;
        values[index] = temp;
    }
}

在调度 ParallelFor Job 时需规定调度任务总长度和每批次长度,C# Job System 会根据批次长度将任务总长分批,再放入 Unity Job 队列,每批同步执行,每批次任务内仅执行一个 Job

当一个 Native Job 先完成时,它会“窃取”其他 Job 的一半批任务,既优化了性能,又保证了内存访问局部性

批次数越低,线程间的任务分配越均匀,但也会带来额外开销,因此需要逐一测试出最佳性能的批次数


public struct MyParallelJob : IJobParallelFor
{
    [ReadOnly]
    public NativeArray<float> a;
    [ReadOnly]
    public NativeArray<float> b;
    public NativeArray<float> result;

    public void Execute(int i)
    {
        result[i] = a[i] + b[i];
    }
}

NativeArray<float> a = new NativeArray<float>(2, Allocator.TempJob);

NativeArray<float> b = new NativeArray<float>(2, Allocator.TempJob);

NativeArray<float> result = new NativeArray<float>(2, Allocator.TempJob);

a[0] = 1.1;
b[0] = 2.2;
a[1] = 3.3;
b[1] = 4.4;

MyParallelJob jobData = new MyParallelJob();
jobData.a = a;  
jobData.b = b;
jobData.result = result;

// Schedule the job with one Execute per index in the results array and only 1 item per processing batch
JobHandle handle = jobData.Schedule(result.Length, 1);

// Wait for the job to complete
handle.Complete();

// Free the memory allocated by the arrays
a.Dispose();
b.Dispose();
result.Dispose();

ParallelForTransform

专门用于操作 Transform 的 Parallel Job

注意事项

  • 不要使用 Job 访问静态数据

    从 Job 访问静态数据会绕开所有安全系统,可能会导致 Unity 崩溃

  • 使用 JobHandle.ScheduleBatchedJobs 方法立即执行已调度的 Job

    Job 在被调度后会被缓存不会立即执行,该方法可立即清空缓存队列中的 Job 并执行,但会影响性能,或调用 JobHandle.Complete 执行,ECS 系统已经隐式清空了缓存,以你无需主动调用

  • 不要更新 NativeContainer 内容

    由于ref returns的缺陷,无法直接修改 NativeContainer 中的内容,需按如下方式

    MyStruct temp = myNativeArray[i];
    temp.memberVariable = 0;
    myNativeArray[i] = temp;
    
  • 调用 JobHandle.Complete 重获所有权

    在主线程或新的 Job 使用前一 Job 占用的NativeContainer数据前,必须调用JobHandle.Complete重新获取其所有权,该方法会清空安全机制的状态,否则会导致内存泄漏( 不能仅查看JobHandle.IsCompleted状态)

  • 只能在主线程调用 Schedule 和 Complete 方法

    这两种方法只能在主线程调用,若一个 Job 依赖于另一个 Job,则在主线程使用 JobHandle

  • Schedule 和 Complete 的正确时机

    准备数据完成时即可调用 Schedule,仅当需要结果时才调用 Complete,如在一帧的结尾与下一帧开始的空档中调度一个 Job

  • 使用 [ReadOnly] 标记 NativeContainer

    Job 同时用于对 NativeContainer 的读写权限,使用 [ReadOnly] 标记只读 Job 中的 NativeContainer 可提升效率

  • 检查数据依赖

    在 Profiler 窗口中,主线程上的 WaitForJobGroup 标记表明 Unity 在等待一个工作线程的任务完成,该标记可能意味着需要解决的数据依赖,可通过查找JobHandle.Complete找到这些依赖

  • Debugging jobs

    可以调用Run方法取代Schedule在主线程执行 Job

  • 不要在 Job 中分配托管内存

    在 Job 中分配托管内存会非常慢,且无法使用 Burst 编译提升效率

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,406评论 6 503
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,732评论 3 393
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,711评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,380评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,432评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,301评论 1 301
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,145评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,008评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,443评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,649评论 3 334
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,795评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,501评论 5 345
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,119评论 3 328
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,731评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,865评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,899评论 2 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,724评论 2 354