Unity手游实战:从0开始SLG——ECS战斗(六)Unity面向数据技术栈(DOTS)

2020-07-10 17:30:58 浏览数 (1)

什么是DOTS?

DOTS是Unity一个阶段性的转变,也是Unity蓝图上一个非常重要的里程碑节点。Unity的官网为它建立了主题链接,甚至打出了阶段性的口号: 重建Unity的核心!,可见Unity对DOTS的重视程度。

那么DOTS的含义是什么呢?看下官网的截图:

高性能多线程式数据导向性技术堆栈 。可以看到DOTS的几个关键词, 高性能多线程 数据导向 堆栈

那么它用什么去保障这些关键词呢?

C# jobs System

jobs System 命中了DOTS里的高性能、多线程和堆栈关键字。上一篇我们讲过CPU执行代码片段的大体流程,那么CPU执行程序的流程也基本和上一篇展示的一样。把代码编译成EXE,然后加载进内存、送进CPU中执行。

更详细的过程可以查看这里:https://www.cnblogs.com/fengliu-/p/9269387.html

进程、线程和协程

现在的计算机结构大都是面向线程设计的了,但在计算机诞生早期的时候,计算机经历过从单一的程序处理逐步演变为多任务处理的过程。但不管是单一任务还是多任务,计算机执行的基础单位都是进程(如果这部分的基础确实不强,你可以粗略认为一个EXE就是一个进程)。每一个进程之间是有独立的资源分配的,包括但不限于文本区域、数据区域和堆栈区域。

文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。那么计算机又是怎么执行多个程序的呢?答案就是操作系统。

操作系统统一管控计算机的各个硬件资源,然后按照调度需求分别给不同的进程执行指定的时间片段,因为计算机的处理速度非常快,所以会让用户感觉在同时运行多个程序(进程)。但是这种模式也不是没有成本的,当并行的进程数量过多的时候,切换进程的代价就会非常大,因为它必须要先把当前的上下文存储,然后加载新的上下文,然后执行片段时间,备份存储,再执行下一个进程片段。切换上下文的代价有时候比执行本身的代价还要大。

线程是CPU执行的最小单位了,现在我们说多线程都是指这个。线程是进程中的实体表现,一个进程可以拥有很多个线程,每个线程受CPU独立调度和分派,可以想象Unity移动游戏开发中,Unity的主线程和网络的socket线程就是一个多线程的表现。

现在的计算机因为多核的并行计算,所以已经程序设计也更多的基于多线程的方式去设计了。(这里要理解一个概念,并发和并行。并发就是进程的执行模式,指多个任务在同一时间段内交替执行;并行是线程的执行模式,不同的线程在同一时间段同时执行。)

线程的另一个表现就是资源共享,同一个进程里的不同线程共享内存地址和资源。它自己本身不会申请系统资源(除了运行时必须的那一小点儿),所有的资源都来自于包含它的进程空间,这让程序处理资源更加的快捷和便利,利用多线程的优势来提高计算效率,当然这也正是多线程编程的难点所在。即使在多核CPU和面向线程设计的计算机结构面世怎么多年,仍然不能普及多线程编程。

协程可以简单的理解为用户自定义线程。对于进程和线程,你一旦创建了之后,就失去了对它的控制权,只能交由内核去分配时间片和执行。但是协程是用户自己创建的一个“线程”,所以从操作系统的层面来说,它不受内核调度,你可以在一个线程里创建无数个协程(硬件允许)来辅助你的代码逻辑,你可以自己控制它的执行时间和状态,也可以通过一个协程拉起另外的协程,而只需要牺牲很小一部分的切换代价。

所以总结来说,一个进程可以拥有很多个线程,每个线程又可以创建很多个协程。进程负责独立的地址空间和资源管理,线程共享进程的这些资源。线程提高了CPU的并行能力,但是进程方便跨平台移植,但这两个都需要消耗计算机的切换上下文的调度时间。协程在线程内执行,避免了无意义的调度,同样的调度责任转移给了开发者,同样因为寄生在线程内部,不能由内核调配,也无法充分利用硬件资源。

多线程编程

前面说了一个线程是内核调度的最小单元。那么根据运行环境和调度组的身份,又可以分为内核线程和用户线程。顾名思义,一个内核线程就是运行在内核环境,由内核分配和调度的线程。用户线程是运行在用户空间,由线程库来调度的。

当一个进程的内核线程获得了CPU的使用权限之后,它就会加载一个用户线程来执行,所以这么看来,内核线程其实就是用户线程的容器。

由于线程之间是共用同一个进程的资源的,所以线程的安全也是多线程编程最需要注意的问题。简单的来说就是如何管理多线程对于同一个资源的访问和修改,确保它们能按照正常的逻辑执行不出问题。

比如线程1需要改写a的值,而线程2需要读取a的值,因为线程的调度由内核控制,所以如果执行的顺序错了,那么结果就会完全偏离(行业术语叫 竞态条件)。

那么解决的方法大概罗列一下(不详细叙述):

  • synchronized 在关键的方法前面标识synchronized,这会让后面需求的线程等待,直到前面持有的线程完成调用。缺点是如果锁住的方法不是静态,那么就会锁住对象本身。那么所有对这个对象的访问都要等待,如果代码中存在多个synchronized 方法会严重影响性能。
  • Lock 这个方法和synchronized 不同在于,Lock是按需去锁,这种就需要自己对于变量有较强的把控。

jobs System的多线程

严格来说,Jobs System并不属于多线程编程的范畴,因为它不能直接对线程进程操作。相应的,它为了保障线程安全,独立封装了多线程的调度框架,用户只要继承一些类和接口,并且使用满足条件的指定数据类型才能完成高性能的计算,所以我个人认为 jobs是一个多线程的调度框架而不是编程框架。

jobs为了避免和主线程的数据发生冲突,所以避免使用引用类型。另外,还定义了一套自定义的数据结构,使用专门的未托管内存进行管理,称之为原生容器(NativeContainer)。包括以下几种:

一个简单的使用jobs的示例代码:

1、定义一个struct继承自Ijob。

2、添加jobs 使用的数据类型,(Blittable types或者NativeContainer类型)Blittable types可以理解为C#的值类型,包括:

3、重写 Execute 方法。

要非常小心的是,除了NativeContainer,其他都是数据的copy。所以要想从主线程访问计算的结果,唯一的方法就是放到NativeContainer里面。

Jobs的使用其实并不是很方便,有很多需要注意的地方,可以参考官方手册查看常见的坑点:

https://docs.unity3d.com/Manual/JobSystemTroubleshooting.html

Unity ECS

ECS 命中了DOTS里的 高性能 、 数据导向 、和 堆栈 关键字。

前面的一些章节,我们已经详细的讲过ECS的思想,以及高性能的原因,和一个基于Unity比较老的插件,Entitas。那么这一部分我们就不再拓展讲解ECS的原理部分,只看看它和我们之前的Entitas有哪些区别。

Unity的ECS组件叫做entities,和Entitas名字很像。但是实现的架构其实完全不一样。

先来看下创建Entity和设置Component:

上面是Unity的ECS,下面是Entitas。

再看下System:

上面是Unity的ECS,下面是Entitas。

毕竟是亲儿子,UnityECS里的System 那是三管齐下了。[BurstCompile]标签,job已经全部用上了。需要详细了解的,文档在这:

https://docs.unity3d.com/Packages/com.unity.entities@0.0/manual/index.html

Burst

Unity目前主推的编译器,号称是比C 编译器还要快的存在。这里直接放官网的描述来看:

这部分的结构主要还是命中 高性能 的关键字。

我们在讲LLVM之前,先简单讲讲Unity一直在使用的技术方案。

打开新版本的Unity(2018.4),在player Setting选项里可以看到这个:

目前默认的是Mono和IL2CPP两个编译选项。

Mono

Mono就不用说了,是Unity跨平台的基础,也是赖以起家的手段。为Unity服务了这么多年,目前已经到了退役的阶段。

作为IL中间件的执行载体,为不同的平台提供了ILR。

看下Mono的执行过程。

虽然为Unity实现了跨平台,但是越来越多的问题累计,导致Unity不得不要抛弃它,另寻出路,主要有几点原因:

  • Mono的版权受限,导致Unity往往不能在最新版中使用C#的最新特性。
  • 性能存在较大问题,毕竟是虚拟机。
  • 维护非常困难,虽然IL是统一标准,但是VM不是!每个新增平台,Unity都需要自己为它们准备VM,Mono是一个开源的项目,但它并不会及时跟进每一个新硬件平台的VM编写,所以Unity得自己移植或者编写。而一些基于Web的平台,几乎要完全重写,比如WEBGL。
  • Mono无法完成64位版本要求。尤其是今年8月谷歌已经强制要求谷歌商店的APP必须同时提供64位版本。IL2CPP是目前满足条件的唯一选择。

IL2CPP

IL2CPP看名字就看出来,这是一个将IL语言转换为CPP语言的工具,看下它的执行方式:

可以看到下面红色的部分,IL2CPP会将编译好的IL代码重写成CPP的代码,这样在使用每个平台的原生编译器,编译为原生平台的可执行文件,由于抛弃了虚拟机,并被原生编译器优化过,所以极大的提升了程序性能。

看下官方给的数据,平均性能提升1.5-2.0倍。

注意,我刚才其实有说IL2CPP抛弃了虚拟机,但是在上面的执行过程图里仍然有I2CPP VM的过程,这是因为C#本身是基于托管代码设计的语言,IL本身也是托管代码执行的,所以IL2CPP即使将IL转为了CPP语言,这部分的设计框架是没法转换的。所以IL2CPP要起一个VM来管理内存,以及分配线程等管理工作。与其说是一个VM其实描述为一个管理器更加贴合。

这里要注意VM和管理器的区别,一个是完全承载代码的解释和执行工作,一个只是负责管理一些内存和特性,所以从大小和复杂程度上后者都远远小于前者。

LLVM

从Unity的专题页面描述可以看到,Burst是基于LLVM来编译的,所以先看下维基百科对LLVM的定义:

LLVM是一个自由软件项目,它是一种编译器基础设施,以C 写成,包含一系列模块化的编译器组件和工具链,用来开发编译器前端和后端。它是为了任意一种编程语言而写成的程序,利用虚拟技术创造出编译时期、链接时期、运行时期以及“闲置时期”的最优化。它最早以C/C 为实现对象,而当前它已支持包括ActionScript、Ada、D语言、Fortran、GLSL、Haskell、Java字节码、Objective-C、Swift、Python、Ruby、Rust、Scala[1]以及C#[2]等语言。

链接:https://zh.wikipedia.org/wiki/LLVM

LLVM提供了完整编译系统的中间层,它会将中间语言(Intermediate Representation,IR)从编译器取出与最优化,最优化后的IR接着被转换及链接到目标平台的汇编语言。LLVM可以接受来自GCC工具链所编译的IR,包含它底下现存的编译器。LLVM也可以在编译时期、链接时期,甚至是运行时期产生可重新定位的代码(Relocatable Code)。

大概来看下过程:

LLVM分为前端、中间件、后端三个部分。

前端:

简单来说就是通过对不同语言的词法,语法、语义分析,产生中间件代码。

中间件:

LLVM的核心是中间件表达式(Intermediate Representation,IR),一种类似汇编的底层语言。IR是一种强类型的精简指令集(Reduced Instruction Set Computing,RISC),并对目标指令集进行了抽象。

一个简单的Hello World程序可以表达为如下的汇编形式:

后端:

最关键的就是它支持与与语言无关的指令集架构和类型系统。(还记得我们上一篇讲过简单指令集和复杂指令集的区别嘛?ARM和X86指令集的区别)

到目前为止,LLVM已经支持多种后端指令集,比如ARM、Qualcomm Hexagon、MIPS、Nvidia并行指令集(PTX;在LLVM文档中被称为NVPTX),PowerPC、AMD TeraScale、AMD Graphics Core Next(GCN)、SPARC、z/Architecture(在LLVM文档中被称为SystemZ)、x86、x86-64和XCore。有部分平台功能并没有完全实现。但x86、x86-64、z/Architecture、ARM和PowerPC的基本所有功能都有实现了。

链接器:

lld链接器子项目旨在为LLVM开发一个内置的,平台独立的链接器,去除对所有第三方链接器的依赖。在2017年5月,lld已经支持ELF、PE/COFF、 和Mach-O。在lld支持不完全的情况下,用户可以使用其他项目,如GNU ld链接器。lld支持链接时优化。当LLVM链接时优化被启用时,LLVM可以输出bitcode而不是本机代码,而本机代码生成由链接器优化处理。

看完LLVM的原理,是不是觉得很熟悉?和Mono很像?都是先把第三方语言转化为中间件,然后再对中间件做兼容处理对吧?但是要注意的是,Mono针对的是运行期,而LLVM针对的是编译期!并且前面说了Mono是针对硬件平台的虚拟机,而LLVM是针对指令集的架构!所以无论是从性能还是数量以及扩展性上来说,LLVM都是远远高于Mono的。(据说Burst编译器最好的时候比C 的快30%)

针对Unity的DOTS目前就是这个全家桶,有很多相关技术视频在官方主题网页里,想要了解更多可以去听一听。本次ECS战斗相关的部分目前只计划了6章,已经全部完成。后面会讲讲UI的框架结构。

0 人点赞