原生对象和托管对象浅谈(转)
https://developer.unity.cn/projects/6152e4fbedbc2a0020584027
Unity技术开放日 | 绝对干货 - 揭秘Unity的黑盒世界,原生对象和托管对象浅谈
Unity技术博客
阅读 3216
2021年9月28日
在 Unity 技术开放日-成都站活动中,Unity 专家为大家揭秘了 Unity 的黑盒世界,深入浅出地讲解了原生对象和托管对象的底层原理。

今天我给大家带来的一个主题,如果大家看了之前的几场Unity技术开放日的话,应该都知道我是在讲底层黑盒原理解密,但是每一场的内容都不一样,之前已经讲过底层的内存,也讲了ShaderLab相关的底层原理;今天带来第三个底层原理,Unity底层是如何去处理原生对象和托管对象(Native & Managed Objects)的。
首先我问一下,有没有小伙伴至今还认为Unity是一个C#的引擎?之前培训的时候,我们一讲到Unity源代码相关的原理时,有的公司里的小伙伴特别惊讶,然后说Unity里面怎么还有C++的代码呢?
Unity其实是一个C++引擎,但是底层有很多东西在实际运行时,大部分的逻辑或者内存管理实际上是在native这一层进行的。所以Unity创建一个对象我们一般会分两部分看,一部分叫本地对象,或者叫native对象,还有一部分是更常见的托管对象,用的是C#。
我们创建一个本地对象它要做什么事呢?我们先简单回顾一下。
这部分在北京站的分享里有更详细的解读,如果大家有兴趣可以回去看回放。今天这里简单回顾一下:
Unity 内存的底层原理分享:
https://www.bilibili.com/video/BV1r44y1z7X3?p=2 https://www.bilibili.com/video/BV1r44y1z7X3?p=2
首先我们在底层创建一个对象的时候(即Native对象),这个对象包括大家平时常用的数据,比如加载一个纹理,加载一个mesh,加载一个prefab等等。在创建的过程中,我们会首先调用Unity_New创建出对象的实例。
然后我们会通过Transfer(即反序列化)为这个实例去填写一些数据,比如大家在设置的时候会选择纹理是否要开read and write,这些都包括在内。这些数据都是在transfer的时候,才从已经打出来的包重新返回到内存当中。
在做完这两步之后,我们最后还会把它唤醒(Awake),但是这个Awake和大家写的Awake多多少少有点不一样,这个Awake一般来讲是在主线程中做的。
我们通过这三步实际上是完成了写C++代码的时候去用函数构建实例的过程,只不过Unity把它分成了三个步骤来分别进行。那么最后其实还有一个小步骤,就是左边这个问号,这里我们一会儿再来揭秘,大家先知道我们这儿有一个东西没有讲就好了。

我们在去创建一个托管对象的时候,是做了类似的事情,但是比原生对象要简单一些。
首先托管对象这边大部分是不需要进行反序列化的,当然也有反序列化,一会儿会讲。但是正常情况下,我们在运行时创建的托管对象大部分就是通过il2cpp或者是mono new 的方法直接生成的。它生成的依据实际上就是我们在运行时的原信息,如果大家想进一步研究的话,也可以在Unity里面打一个il2cpp的工程,然后去观察托管对象如何被创建出来。创建的过程中,会用到叫il2cp_codegen_new的一个方法,这个方法里面会根据这个参数(C#提供的源信息),比如说这个类在内存中占多大,是不是要有基类等等这些信息,去创建出一个真正的内存实例,然后把这个内存实例交给垃圾回收(GC)去管理。当然这个过程当中我们也有一个东西没有讲,一会儿会讲到。

其实刚才我们谈论的过程中,都提到了一个非常关键的词叫序列化。什么是序列化呢?
首先,提到序列化很多人第一个反应就是打 Assert Bundle 的时候进行序列化了,它把我创建出来的Prefab、实例写到一个文件里。没错,这是一个序列化,但是其实这个更准确地讲是持久化,所以我们往往会先告诉大家序列化不等于持久化。

为什么这么说呢?序列化的过程实际上,是把内存中的一个结构重新叙述成一个可进行序列输出的结构的过程,但是这个过程并不一定要最终写到磁盘上。
Unity在什么地方会用到序列化?在大家使用Unity时的方方面面都会用到。比如说大家在Inspector (观察)的观察框里面去看一个对象的时候,就会看到它的各种参数,也会去设置各种参数,当你去修改/看这各种参数的时候,实际上就是序列化在起作用。

另外还有一个大家比较常见的序列化过程,就是把一个prefab进行实例化,实际上这个过程也是一个Prefab在进行序列化、反序列化的过程。
还有什么地方呢?比如说copy/Paste,这个过程其实也是序列化和反序列化在发挥作用。是序列化和反序列化先把你要拷贝的component序列化成一段可复制、可移动的描述性序列文件,然后重新在另外一个地方把它反序列化。

然后另外一个很容易被大家忽略的地方,就是Unity里面的Redo和Undo,这个过程实际上也用到了序列化和反序列化。
当我们去修改一个东西的时候,Unity记录的是两次序列化出来的可序列化数例之间的差异,所以当我们进行redo和Undo的时候,实际上Unity是把这样的差异再重新用回到之前的那个版本上。
另外还有两个就是我们在Asset Bundle的时候会收集的dependency,Unity在收集dependency的时候实际上也是通过序列化结构来进行收集和剔除的。另外就是我们最终在运行时会用到asset,实际上在这个过程中也会用到序列化和反序列化。
所以在Unity内部培训的时候就说Unity引擎各个方面都会用到序列化和反序列化,它是Unity非常核心的一个模块。

说到序列化和反序列化,其实一般我们说的是三种事情。我们说有原生的序列化,有托管的序列化,以及一个特殊的序列化叫Blobfication,我们简称它叫Blob。

这三个分别是什么呢?我们先看看native序列化是怎么做的。
当我们需要去序列化一个native对象的时候,我们真正要序列化的是什么?我们真正需要序列化的是它的各个数据成员,而不是它的方法。
在Unity里面序列化,使用了一套叫做transfer系统,transfer系统简要来说就是一套反向控制系统。什么叫反向控制系统?
正常的正向控制系统里,我们设计一个函数,然后把数据传给这个函数,函数返回给我们一个处理过的数据,是这样的过程。我有一个行为,你给我数据,我来做动作,这个叫正向控制。
反向控制刚好相反,反向控制是你给我一个行为,我帮你把它做了。什么意思?
正向控制的时候我们要去序列化一个有ABC三个成员的类,就需要对A、B、C分别做一次行为,如果我的行为是不一致的,那就意味着我要写出三套不同的行为来一一对应。比如三种不同的行为,第一种是读,第二种是写,第三种是比较,那么我就要写出三套不同的行为分别来对应,即九套控制方法(3×3)。
那么反向控制的好处是针对同样的一个东西,我只传一个固定的行为模式给你,而我不管这个行为模式在数据里面具体做了什么事情。
简单来说就像图里这样,我们首先传了一个行为给他,一个transfer,我们首先会做一个Init,Init的作用是保证我们在不同版本的transfer之间进行调用的时候,仍然在一定程度上具有可兼容性。

在经过Init之后,就开始transfer这个data。transfer这个data的时候,系统首先会判断当前transfer的这个data是不是一个base data(简单类型的数据)。比如它是一个int,是一个简单类型的数据,那么久会用transfer Base来进行transfer的过程。
这个transfer可能代表很多不同的实际行为,比如写Assert Bundle,此时这个transfer的作用就是把这个数写到文件对应的位置上。
但是我们还有其他不同的transfer。比如说我这个transfer是向外读取,那么它的作用就是把数据对应在该文件里相应位置上的数字读回给data,至于transfer Base到底干了什么,是由每一个传进transfer的行为来具体定义的,从数据的角度来讲是不清楚的。
如果Data是一个基本类型,那么我们就直接执行最简单的transfer Base,然后开始看下一个数据。如果它不是基本类型,我们会再判断它是不是一种叫PPtr的特殊类型(PPtr在Unity内部可以简单理解为指针,但是它是一种经过封装的特殊指针,稍后会讲)。
如果是一个PPtr,也就是说当前的数据要指向另外一个类。那么这个时候有两种可能,第一种即PPtr已经被创建过了,这个时候要指向操作的那个类是存在的,用户继续调用要操作的PPtr指向的那个类,把它的transfer进行一个递归,就可以了。
如果当前的PPtr是一个空的,就是当前还没有创建,那么系统也会进行一个递归调用,就是去创建这样一个新的类并且优先完成它的transfer。
通过这样一系列的操作,就可以把我们之前打到Assert Bundle里面的数据,通过一系列的反向序列化的操作,依次填充回当前在内存中创建的实例当中,以供后面来使用。
还有一种transfer是最常见的,就是我们在Unity里面去创建的一个prefab的YAML,它经过了YAML transfer这样一个转换,做出来从左边变成了右边,实际上这就是通过刚才的那个流程图不断地对这些对象进行递归调用,然后形成了这样一个YAML。

我们举一个例子,首先在一开始调用的时候,我会先序列化出一个game object来,然后向下依次进行序列化。当我发现第一个是基础类型时,当前这个YAML transfer它要做的事情就是把它的名字以及值写到这个地方,然后紧接着我向下依次调用,直到遇到一个PPtr类型的数据。以component为例,component是一个指针,它指向了下面的东西,所以这个地方它就是一个PPtr。然后进行序列化,序列化到这个时候给一个ID,然后去生成一个transfer,所以大家能看到ID是有对应关系的。实际上这个指针指向我底下这一块,底下这一块是component,这些在Unity看来都是object,执行了同样的操作。我依次向下,直到把所有东西做完。
这里面有一个东西比较特殊,就是这个(PPT图示)。我发现大部分东西都是指向一个内部结构的,而如果你是指向一个外部资源,那么这个时候如果你的外部资源不存在,这个指针解析的时候就会报错了。
所以我们之前在讲Assert Bundle的时候大家经常听到这样一个说法,就是什么时候是打开一个Assert Bundle最后的时机?就是当你要加载的某一个component需要引入这个资源,而这个资源在另外一个Assert Bundle的时候,你需要保证这个Assert Bundle的资源被加载的时候是被打开的。这就是因为当我们对这个Prefab进行反序列化的时候,反序列化到这一步我们需要把这个PPtr变成一个真正的实例,这个时候就需要数据源,即另外一个Assert Bundle,是打开的。

我们现在有多少种不同的Transfer呢?里面像大家常见的像 StreamedBinaryRead、StreamedBinaryWrite,这两个东西是配合的,一个是序列化,一个是反序列化,一个写,一个读,最常见的就是在打包的时候它会形成一些二进制文件。

然后还有比较常见的是YAML read,YAML write,这两个东西是编辑器中经常见的。还有一个比较常见的是Generate typetree Transfer。如果你们经常用Assert Bundle的话,你会特别熟悉一个选项叫 disable typetree。如果你把disable typetree选项打开,它会第一省内存,第二省CPU时间,第三省包体大小,但是它有可能会导致你的Assert Bundle不能再跨版本使用。
为什么?因为这个transfer是比较特殊的,前面transfer关心的大部分是它的值,而这个transfer关心的是它的类型和名字的匹配情况。也就是说,当我在一个新版本里面,比如说里面有某一个选项不存在了,那我如何能保证继续正确地把这个东西反序列化出来?这就需要通过typetree进行匹配。所以如果你在Assert Bundle里面打开typetree,那么正常地去加载Assert Bundle的时候,就会经历两次反序列化。
第一次反序列化会先把typetree的信息反序列化出来,第二步再根据typetree反序列化出来的信息里,反序列化数值的那个部分,保证你的反序列化是正确的。所以你比别人要都走一次反序列化,你的内存要比别人多一份反序列化数据,你的包体要比别人多一份反序列化数据。但好处是,可以换版本。
所以有很多同事做优化建议的时候告诉大家说这个东西如果你确定你的Player版本和Assert Bundle版本是严格一致的,这个你可以放心关掉,否则的话还请把这个东西打开。
然后再说一下刚刚提到的PPtr,PPtr是Unity里面自定义的一种指针,这种指针它可以简单地被认为是ID映射。因为Unity运行时会通过InstanceID去索引一个真正的指针,但是这些真正的指针有的时候会产生一些变化,尤其是导入编辑器的时候可能会产生变化,所以PPtr会配合前面的RemapPPtrtransfer进行一个映射的改变。
大家经常在哪儿会用到这个InstanceID呢?就是做内存优化时,在内存优化面板里面会看到这个东西在占用内存,对象越多就会变得越大,是一个正比例关系。

再简单说一下托管,托管内存现在能支持有限的几种序列化的和反序列化的方式。
托管首先没有一个独立的transfer Function,另外不具有版本兼容性。所以如果大家去打Assert Bundle,然后修改了script,尤其是修改了成员变量,删除或者增加了成员变量,你再用删除或者增加之后的版本去加载这个Assert Bundle的时候,你就会获得一个报错信息。说这个版本不匹配,你需要重新去打一下你的Assert Bundle,就是这个东西在搞鬼。

我们为什么不能直接用呢?最直接原因是托管内存和非托管内存的数据结构很多是不一样的,而且类型需要经过映射。
所以在这个地方我们实际上是对每个托管的类做了这样一系列的工作:
首先我们先看一下当前这个托管类之前有没有被进行过序列化和反序列化,就是它的结构有没有被解析过,如果没有被解析过,那么我们首先会构建一个解析队列(Command queue),这个解析队列里面会依次遍历你当前托管内里面所有的Field,对于每个Field的类型我们会有一套规定好的规则,规定它怎么样进行序列化和反序列化。遍历完所有的Field之后就对这个类产生了一整套的规则序列,然后我们去执行这个规则序列。执行的过程中实际上我们最终还是会调用到刚才说的一系列的transfer Function,从而完成托管的序列化过程。

最后一种叫做Blobfication,简单理解就是一大堆的内存,这个东西什么时候最常见呢?比如在有大量数据的时候,我们的Animation里面都是有大量的且紧凑的数据,那么如果我们仍然按照刚才的方式一个个去做,一定会非常影响效率。
内存是紧凑的,它不存在跨内存块的指针引用,可以完全直接读进来然后用内存指针指到它就可以了,所以在Unity里面有这样一个特殊的方式叫Blobfication。

它的优点是可以打非常紧凑的数据,这个紧凑不仅是数据本身紧凑,Blobfication本身也有一定去冗余的过程,但是它也有限制,比如不能有外部指针等等。

所以我们也引入了一个概念,叫OffsetPtr,即可以用内部指针。那么内部指针是怎么实现的呢?
这里不可能是一个真正指针,因为真正指针存储的内容是它当前的物理地址,但是经过序列化和反序列化到另外一台机器上的时候,你的物理地址一定是会变的,所以这个指针就变成一个无效指针了。但是我可以使用它的一个相对位移,比如说以这样的一个类型,左边是一个class,右边是复制的过程,那么我们在这个地方会看到有两个OffsetPtr,叫偏移指针或者叫内部指针。
我们首先会创建出这样一块内容,容纳这个东西的一块内存,然后依次向里面进行赋值,当赋值到第一个OffsetPtr的时候,我直接点过去,不需要任何操作。当我给到第二个指针的时候,我首先丈量出了这个东西的整个长度,然后给出了它的一个outside (向后偏移128位),然后在这个尾部存储指针的具体值,所以在这个地方指针存储的不再是一个真正的物理地址,而是基于当前位置的一个偏移量,这样就可以保证它是一个可以移动的内存块了,它无论移动到任何地方,都可以找到正确的去解析这个指针。

最后简单揭秘一下开始那里的内容。
我们刚才说了很多,实际上大部分的native对象和托管对象合作之后都有一个串联过程,也就是说,当你去创建一个native对象时,往往意味着你创建了一个托管对象,创建一个托管对象也往往意味着你创建了一个非托管对象。

我们通过一个简单的例子去加载一个资源,我这里加载的是texture,大家有没有想过一个问题,这个指针指向的是谁?这里是一个简单的demo,当我们去load这个texture的时候,实际上大部分同学都会意识到这里实际上是在非托管内存里面同时去创建了一个texture 2D这样一个内实例,我们管这个实例叫封装层。这个封装层它的生命周期和大家想象的其实不太一样,大家可以回去试一下。当我们解除了引用,然后去GC的时候这个东西依然存在,什么时候会把它彻底放掉?只有我们做这个的时候或者做资源UnloadUnusedAsset或者Destory的时候,native这一层对象没有的时候它才会真正放掉。如果大家想深入观察,可以用Memory Profiler去抓一下帧,然后去对比托管和非托管对象之间的地址关联,是可以找到这个对象的,它们俩是严格绑定在一起的。

最后做一个广告,如果你想继续深入学习这些东西,我们推荐两个地方,一个就是Unity中文课堂,另外就是Unity技术专栏。如果大家对我们的代码或者底层原理非常有兴趣的话,我们也有一整套的培训课程,帮助大家从系统上更加清晰认识整个Unity到底怎样运作的。

如果你在学习过程当中遇到一些问题,也可以通过这样几个手段来找到我们:
第一个关注Unity的B站,我们现在定期不定期的都会有一些直播和交流活动,大家可以关注B站然后进行在线交流和学习。
Unity B站:
https://space.bilibili.com/386224375 https://space.bilibili.com/386224375
另外一个就是Unity问答社区,也是在Unity官方网站开发者社区里面,一个是Unity官方论坛,大家可以在里面提一些问题,我们工程师每天会定期看,找一些问题回答。
Unity 问答专区:
https://developer.unity.cn/ask/home https://developer.unity.cn/ask/home
最后还有一个就是Unity Hub,你们在Unity Hub会看到这样一个小标识,点开这个小标识,后面是我。想问技术问题的可以通过这个在线实时交流,大家感兴趣的可以到时候找我。

点击此标识,随时在线交流
今天时间比较紧,希望我的分享能够给大家带来一定收获,谢谢大家。
发布于技术交流

浙公网安备 33010602011771号