DOTS 数据导向技术栈

理解 Unity ECS、Job System 与 Burst 编译器——用数据导向架构突破主线程瓶颈。

学习目标

  • 能解释数据竞争与锁的本质问题,以及 Unity 主线程为何是性能瓶颈
  • 能说出 ECS 中 Entity、Component、System 各自的职责,以及 ECS 为什么天然适合并行
  • 能回答:你想把项目中的 AI 寻路系统加速 10 倍——先拆 Component 还是先加 [BurstCompile]?为什么?

原书边界:Chapter 9

Packt 第三版把本章命名为 The Data-Oriented Technology Stack。官方目录列出的核心范围如下;这些条目共同构成本章的一对一边界:

  • The problem of multithreading:纳入本章实验与验收,不拆到别章重复计数。
  • The Unity Job System:纳入本章实验与验收,不拆到别章重复计数。
  • The new ECS:纳入本章实验与验收,不拆到别章重复计数。
  • The burst compiler:纳入本章实验与验收,不拆到别章重复计数。

通过连续数据、显式读写依赖、可并行 Job 与 Burst 向量化,把大量同构工作从对象图改为数据流。第三版描述的是 2019/2020 早期 DOTS;现代 Entities API 已变化,必须保留原理并用当前包版本重写接口。 因此,本章既保留 2019/2020 语境,也明确标出迁移到现代 Unity 时哪些是稳定原理、哪些只是版本相关接口。

主线程挤满了,多核却在睡觉

前一章你把内存管得干干净净——纹理不再翻倍、GC 不再抖帧。可帧率还是上不去。打开 Profiler,八个核心只有一颗在 80% 忙,其余七颗在 20% 摸鱼。你的 CPU 有十六个核,但 Update 只跑在一个上。

这就是传统 GameObject 模式的终极天花板:所有逻辑跑在主线程,多核几乎用不上。更糟的是——多线程编程本身就难:数据竞争让你结果不可预测,加锁又让并行变串行。

这章要讲的 DOTS(Data-Oriented Technology Stack),就是 Unity 对这个问题的系统性回答:把数据和控制流彻底拆开,让数据导向并行、让编译器吃透代码

像厨房切菜:一个人切一根胡萝卜 vs 四个人各切一根

想象厨房里有一堆胡萝卜要切片。一个人(主线程)一根接一根切——再快也有上限。四个人(多核)各拿一根同时切——快四倍。但如果四人要抢同一把刀(共享变量无锁),就会切到手(数据竞争);如果每次只准一人拿刀(加锁),那实际上还是排着队切。

Job System 相当于给每人发一根胡萝卜、一把专用刀:「切自己的,不许碰别人的」——无锁、无竞争、真并行。ECS 则把「胡萝卜」和「切的动作」彻底分开:胡萝卜是纯数据(Component)、切的动作是纯逻辑(System)、至于这根胡萝卜叫什么编号——只是个标签(Entity)。Burst 是给刀换了把激光刀——同一段切菜代码,编译成机器码后快了几十倍。

数据竞争:为什么多线程这么难

多线程编程最根本的矛盾:多个线程访问同一块内存

是多线程 bug 的万恶之源。看一个最简单的例子:两个线程同时对 int x = 0x++。你期望结果是 2,但实际可能是 1——因为 x++ 在 CPU 层面是「读取 → 加 1 → 写回」三步,两步之间另一线程可能读到旧值。

多线程为什么难?数据竞争(Data Race)int x = 0线程 A:x++线程 B:x++结果:x 可能是 1(而非 2)——竞态条件加锁(Lock)的代价线程 A 持有锁线程 B 等待A 释放锁继续🔒正确但变串行——多核白买了高频加锁 = 上下文切换 = CPU 空转Unity 主线瓶颈主线程:Update · LateUpdate · 渲染 · 物理 · 动画……全挤一条线GameObject API 不是线程安全的 → 几乎不能从 Job 里直接操作DOTS 解法:结构体数据(非托管)→ Job System 安全并行 → Burst 极限速度
数据竞争导致不可预测结果;加锁消除竞争却引入等待开销;Unity 主线承载所有更新与渲染。DOTS 用结构体数据脱离主线瓶颈。

解决了正确性,但换来新的代价:持有锁的线程阻塞其他线程,上下文切换消耗 CPU,高频加锁 ≈ 单线程——多核又白买了。

Unity 主线瓶颈,本质上是这个困境的系统级体现:GameObject 的 Transform、Component 等 API 不是线程安全的——你几乎不能从别的线程安全地操作它们。于是所有更新、渲染、物理、动画全挤在主线程做,多核闲着。

Job System:把工作拆成「Job」,丢给 Worker

Unity 的 C# Job System 是这个困境的第一个解法。核心思路是:把数据从 GameObject 身上剥下来,变成纯数据数组,Job 只吃数据、不碰 GameObject

Unity Job SystemIJob(单次作业)struct MyJob : IJobpublic float data;void Execute() { data *= 2; }schedule → 一个 Worker 执行一次Worker 1 → Execute()IJobParallelFor(并行作业)struct ParallelJob : IJobForNativeArrayExecute(int i) { arr[i] *= 2; }schedule(arr.Length, 64) → 分片并行W1: [0..63]W2: [64..127]W3: [128..]Safety System(安全系统)NativeArray 追踪依赖同一容器同一时刻不能并行写编译期 / 运行时双重检查NativeArray / NativeList / NativeHashMap → 管理生命周期 · Dispose 释放JobHandle.Complete() 等待完成,主线程取回结果核心思路:数据 + 纯函数 = 安全并行,无锁无竞争
IJob 单个 Worker 执行一次;IJobParallelFor 将数组切片分发给多个 Worker 并行。Safety System 在编译/运行时阻止数据竞争。

IJob:单次作业

IJob 用于单次执行的计算任务。你定义一个实现了 IJob 接口的结构体,填充数据字段,再实现 Execute() 方法:

[BurstCompile]
public struct MyCalculationJob : IJob
{
    public NativeArray<float> results;
    public float multiplier;
 
    public void Execute()
    {
        for (int i = 0; i < results.Length; i++)
            results[i] *= multiplier;
    }
}

schedule 后,一个 Worker 线程执行一次 Execute。主线程通过 JobHandle.Complete() 等待完成、取回结果。

IJobParallelFor:数组自动切片并行

IJobParallelFor 是 Job System 真正的杀手锏——把一个 NativeArray 自动切片分给多个 Worker 并行处理:

[BurstCompile]
public struct ParallelMoveJob : IJobParallelFor
{
    public NativeArray<float3> positions;
    public float deltaTime;
    public float speed;
 
    public void Execute(int index)
    {
        positions[index] += new float3(0, 0, speed * deltaTime);
    }
}
 
// 调度:10000 个位置,每批 64 个,自动分给所有可用 Worker
var handle = new ParallelMoveJob { positions = positions, ... }
    .Schedule(positions.Length, 64);
handle.Complete();

你只管「第 i 个元素怎么处理」,Job System 负责把 [0,1,…,N-1] 切成分片喂给不同 Worker,数据天然不相交 = 无锁并行

在编译期和运行时双重检查依赖关系:同一时刻不能有两个 Job 对同一个 NativeArray 写入。如果你忘了 Complete() 就去读还在跑的 Job 的结果,直接报错——绝不让你写出静默的数据竞争

是 Job 间的共享数据桥梁:NativeArray<T> 用于连续数组、NativeList<T> 用于可变列表、NativeHashMap<K,V> 用于键值对。它们都是非托管内存,用 Dispose() 手动释放——不进 GC 堆

ECS:彻底打散 Class,只剩「数据」和「逻辑」

Job System 解决了「怎么并行」,但 「数据从哪来」 还没解决。ECS(Entity Component System)就是数据组织的终极答案。

ECS 架构:彻底分离三大概念Entity(实体)只是一个 int ID无数据 · 无方法等同于「数据库行号」Component(组件)IComponentData 标记结构体只含非托管字段struct Translation { float3; }System(系统)纯函数逻辑 · 无状态Entities.ForEach 查询只在满足条件的 Entity 上跑挂载查询关键洞察:为什么 ECS 能并行?Component 是纯数据(无引用、无虚方法)→ 内存连续排布、CPU Cache-FriendlySystem 只吃数据、无副作用 → Job System 安全并行 → Burst 编译到 LLVMGameObject 模式(继承 · 虚调用 · 散乱内存)vs ECS 模式(组合 · 纯数据 · 连续内存)数据布局决定性能上限
Entity 只是 ID;Component 是纯数据非托管结构体;System 是无状态纯函数只处理满足条件的 Entity。三者彻底分离使数据连续排布、逻辑安全并行。

Entity:只是一个号码牌

是 ECS 里的原子单位。它不是 GameObject,没有 Transform,没有 name,没有层级——只是一个 int 型的 ID。可以把 Entity 理解为「数据库里的一行」,这一行的列(数据)来自它挂载的 Component。

Component:纯数据,无逻辑,零引用

与传统 MonoBehaviour 完全相反:

  • struct,不是 class
  • 只含 <Term def="可直接按比特拷贝到非托管内存的类型,如 bool/int/float/float3/quaternion,不含 class 引用、string、数组。">blittable</Term> 字段(floatintfloat3quaternion` 等)
  • 不含任何方法、不含虚函数、不含 GameObject 引用
  • 在内存中紧挨着连续排布——遍历 Component 数组就是一段连续内存的线性扫描,CPU 缓存命中率极高
public struct Translation : IComponentData
{
    public float3 Value;
}
 
public struct Health : IComponentData
{
    public float Current;
    public float Max;
}

一个 Enemy Entity = Entity(42) + <Translation, Health, MoveSpeed>——组合而非继承,需要什么挂什么。

System:纯逻辑,只吃匹配合约的 Entity

是 ECS 的执行者:

[BurstCompile]
public partial struct EnemyMoveSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        float dt = SystemAPI.Time.DeltaTime;
        foreach (var (transform, speed) in
            SystemAPI.Query<RefRW<Translation>, RefRO<MoveSpeed>>())
        {
            transform.ValueRW.Value += new float3(0, 0, speed.ValueRO.Value * dt);
        }
    }
}

这条 foreach 看起来像遍历,实际上在运行时等价于:只选出同时有 Translation + MoveSpeed 的 Entity,把它们在内存中紧密排列的 Component 数组一块喂给 Job,多个 Worker 各算各的分片。表驱动调度——数据决定了执行计划。

Burst:同一段 C#,快 100 倍

Job 能在多核上跑了,但每个核上的代码跑得够快吗?Mono JIT 和 IL2CPP AOT 产生的机器码,和手写 C++ 仍有非常大差距。

就是这个差距的填平者:在已有的 C# 代码上只加 [BurstCompile] 一个标签,编译时就把你的 C# 先转成 LLVM 中间表示,再用 LLVM 的优化管线——自动向量化(SIMD)、循环展开、常量折叠、内联——生成性能逼近手写汇编的机器码。

什么能 Burst

  • IJobIJobParallelForISystem 的结构体(加 [BurstCompile]
  • 只操作 floatintfloat3quaternionNativeArray 等非托管类型
  • 数学函数(math.mulmath.length 等)——Burst 是 Unity.Mathematics 的深度受益者

什么不能 Burst

  • classstringList<T>、任何引用类型
  • Debug.LogGameObject 相关 API
  • 虚方法、反射、foreach 遍历托管集合

简单记法:struct + 纯数据 = 能 Burst;class + 引用 = 不能 Burst

动手:把 GameObject 换成 Entity——五步渐进

整个 DOTS 堆栈的执行流很清晰,但这不意味着你要把现有项目推倒重来。下面走通「把 Enemy 的移动逻辑从 MonoBehaviour 迁到 ECS」的完整过程。

猜一猜:你有一个 MonoBehaviour 移动系统,每帧遍历 5000 个敌人数组算位置。迁移 ECS 后,这五个步骤中哪一步真正省了 CPU 时间?

分步1 / 5

① 拆数据:MonoBehaviour → 多个 IComponentData

写 C# 时习惯了 class Enemy : MonoBehaviour {"{"} float hp; Vector3 pos; {"}"}——一个类包所有。ECS 的第一步是把数据打散:

public struct Translation : IComponentData {"{"} public float3 Value; {"}"}
public struct Health : IComponentData {"{"} public float Current; {"}"}
public struct MoveSpeed : IComponentData {"{"} public float Value; {"}"}

每项只是一个结构体,只含数据、不含行为。需要什么组合就挂什么 Component。

容易踩的坑

小结

  • 数据竞争 是多线程编程的根本难题:多线程同时读写同一变量 → 不可预测结果;加锁 解决正确性但引入等待开销
  • Job System 把数据剥成 NativeArray、Job 只吃数据不碰 GameObject → IJob 单次执行、IJobParallelFor 自动切片并行
  • ECS 彻底打散 Class:Entity 只是 ID、Component 是纯数据 struct、System 是纯函数逻辑 → 数据连续排布 + Cache-Friendly + 天然并行
  • Burst Compiler 同一段 C# 加 [BurstCompile] 走 LLVM → SIMD 向量化 + 循环展开 + 内联 → 可达 Mono 的 10–100 倍
  • 迁移策略:拆数据 → 写 System → 加 Burst → 混合运行 → 观察验证——逐系统替换,非全量重写

练习

问题 1(改代码型) 写一个 IJobParallelFor 的 Job:输入 NativeArray<float>,在 Execute 中把每个值乘以 2。用 Schedule 执行,Complete 等完成。测 100 万个元素,对比主循环 for 和 Job 的耗时。

问题 2(问答型) Entity 为什么只是一个 int?如果 Entity 也像 GameObject 一样存 Transform 引用,ECS 能并行吗?

问题 3(实战型) 在你的项目里找一个 MonoBehaviour.Update 中遍历数组做简单计算的系统(如敌人移动、资源生成)。拆成:① 创建一个 IComponentData 存数据 ② 写一个 ISystem 替代 Update ③ 加 [BurstCompile] ④ 用 Profiler 对比迁移前后主线程占用。

名词解释

本章出现的专业名词,用大白话再讲一遍。

数据竞争(Data Race)

两个或多个线程同时读写同一变量且至少一个为写操作,最终结果取决于线程执行顺序——不可预测的并发 bug。详见 ThreadingProblemDiagram 左侧。

锁(Lock)

同步原语,确保同一时刻只有一个线程进入临界区。解决数据竞争但引入等待开销——高频加锁 ≈ 单线程。

Job System

Unity C# 多线程任务调度框架。将纯数据数组分发给 Worker 线程并行处理,Safety System 编译/运行时双重检查杜绝数据竞争。

Safety System(安全系统)

Job System 内置的依赖与冲突检测:同一 NativeContainer 同一时刻不能被两个 Job 同时写入;忘记 Complete 就读取 = 运行时报错。

NativeContainer

Job System 的非托管数据容器(NativeArray/List/HashMap),由 Safety System 追踪生命周期与读写依赖,必须 Dispose 释放。

Entity

ECS 的原子单位——只是一个 int ID,不含任何数据或行为。等价于数据库里的行号/主键。

Component(ECS 组件)

IComponentData 标记的非托管结构体,只含 blittable 字段(float/int/float3 等),无引用类型、无虚方法。与传统 MonoBehaviour 完全相反。

blittable

可直接按比特拷贝到非托管内存的类型(bool/int/float/float3/quaternion 等),不含 class 引用、string、数组。Burst 和 Job System 的唯一准入类型。

System(ECS 系统)

ECS 的执行者。纯函数逻辑、无状态,通过 EntityQuery 选匹配 Entity,只操作其 Component 数据。推荐 ISystem + [BurstCompile]。

Burst Compiler

Unity 高性能 C# 编译器。IL→LLVM→SIMD 机器码,加 [BurstCompile] 即可。对向量/数学密集型计算加速 10–100 倍。详见 BurstCompilerDiagram。

DOTS(Data-Oriented Technology Stack)

数据导向技术栈 = ECS + Job System + Burst Compiler 的统称,是 Unity 针对多核性能瓶颈的系统性解决方案。

资料与写作方式声明

本章以Packt《Unity Game Optimization》第3版 Chapter 9权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…