Unity3d底层数据传递分析
作者:樊松阳,腾讯游戏客户端开发 高级工程师
商业转载请联系腾讯WeTest获得授权,非商业转载请注明出处。
原文链接:http://wetest.qq.com/lab/view/370.html
WeTest 导读
这篇文章主要分析了在Mono框架下,非托管堆、运行时、托管堆如何关联,以及通过哪些方式调用。内存方面,介绍了什么是封送,以及类和结构体的关系和区别。
一、托管交互(Interop)
在Mono的官方文档(http://www.mono-project.com/docs/advanced/embedding/) 中有关于嵌入原理的描述。我们知道Unity3d底层是C++完成的,而C#代码会被编译成CIL(Common Intermediate Language),连接两部分的技术就是MonoRuntime。通常C++部分被称为非托管代码(Unmanaged code),即下图左侧,CIL/.NET部分被称为托管代码(manage code),即下图右侧。
二、封送
在C#中的string,通过内部调用传给C++时,会使用MonoString* ,它是指向托管堆对象的字符串类型指针,这个转换就是封送(Marshalling)。
具体说来,封送是将对象的内存表示,变换为适合存储或发送的数据格式的过程。
对于简单的数据类型,例如整数和浮点数等基础类型,封送是隐式的按位拷贝(blitting)。另一种不必封送的情况是指针传递,例如通过引用传递结构体到非托管代码,只会拷贝结构的指针。当然,也可以通过MarshalAs来自定义封送策略。
需要谨记的是,这两部分内存则完全独立。托管内存分配在GC堆上,非托管内存则完全由C++层的业务代码自己控制。因此堆上的内容被C++访问时,很有可能因为堆的机制被GC掉了。为了防止出现这种情况,可以使用C#的fixed关键字来单边锁定变量。
在P/Invoke模式中没有使用fixed,而采用另一种常见的托管到非托管的封送方式:
Runtime分配一块非托管内存。
托管类数据拷贝到刚申请的非托管内存中。
调用非托管方法时,使用上面的非托管内存数据,而不是原始托管内存数据。这样做是为了,当GC发生时,非托管内存是可用的。
将非托管内存拷回托管内存。
因为不能确定托管堆中的内存会何时失效,在非托管代码中,我们不应该缓存任何托管代码传进来的数据。
另一种情况是返回值,类在非托管代码中,不可以作为值返回,只可以返回指针。因为堆内容无法互通,当返回到托管代码时,会经历以下步骤:
托管代码调用非托管代码,返回了指向在非托管内存中的结构体的指针。
在托管代码中找到对应的托管类并实例化,将非托管内容封送到托管类中。
非托管代码中的内存被Marshal.FreeCoTaskMem()函数释放。
想要避免这种内存分配,可以返回一个IntPtr,并且用Marshal类方法操作指针。关于类与结构体,在后面有更详细的论述。
三、跨域调用
托管代码能通过以下两种方式调用C++,即P/Invoke与内部调用(Embedding)。
P/Invoke
使用P/Invoke调用方式,需要将C++函数声明为public。例如:
然后在C#层添加下面的声明即可:
通过__Internal关键字可以令Mono在当前执行的非托管代码中查找函数,通过自扩展的Marshalling,可以适配大量的数据类型,是最简单的Interop方式。
内部调用
内部调用是在C++中注册调用,并直接访问托管对象,控制Marshall。例如,我们要返回字符串,就先要在C++中显示注册接口。
然后在C#中声明下面的函数:
最后实现在C++中实现这个函数:
通过MonoString和mono_string_new,即完成了字符串的Marshalling过程。
四、内存分配
类与结构体
对于托管代码与非托管代码,类与结构体有不一样的传递方法。
1、类的传递
类是在托管堆上分配的,因此不能以值类型传给非托管代码,而只能传引用。以代码举例来说:
对于下面的非托管代码:
一个可用的类包装(class wrapper),可以是:
在托管代码中,我们需要指定类的数据格式,默认是LayoutKind.Auto。这种分配方式下,运行时会自动选择合适的内存布局来创建非托管内存,因此内存结构不能被外部所知。我们可以使用LayoutKind.Sequential或LayoutKind.Explicit来指定内存分配策略。例如托管代码的定义还可以这样写:
另外,类方法有自己的封送方式。正如前面提到的,很多数据是借助Marshaling进行访问。如果需要制定拷贝规则,要指定关键字[In],[Out],[In,Out],传递方向如下图所示:
当不指定这些属性时,就会根据数据类型(Value或Reference)来决定拷贝方式。
例如,引用类型(类,数组,字符串,接口)作为值传递时,出于性能考虑会被标注为[In]。这也是默认标记,即不做从非托管拷贝回托管的操作。
2、结构体的传递
结构体与类有两点不同:
结构体分配在运行时的栈上(Runtime Stack)。
默认使用Sequential,非托管代码使用时不需要额外设置属性。
在把结构体传递给非托管代码时,有些情况下不会产生内存拷贝:
作为值传递时,结构分配在栈上,并且是可比特化类型(blittable types)
作为引用传递
在上述情况下,不需要指定[Out]作为关键字。反过来说,如果结构体中包含不可比特化的类型,例如:System.Boolean,System.String,或者array,就需要自己完成Marshalling了。
依照上面的非托管代码定义,结构体包装可以是:
结构体在非托管代码中,可以作为值返回,但不可以返回ref或out。所以要想返回指向结构的指针,就必须使用IntPtr,或在外部定义unsafe。如果使用IntPtr做返回值,可以用Marshal.PtrToStructure系列函数,将指针转换为托管结构体。
成员变量
对于类与结构体的成员变量,乖巧的做法是:不要将包含引用类型(比如说类)的类或结构体传给非托管代码。因为非托管代码不能安全的操作非托管引用,托管代码也不一定会深封送数据。因此,打包类中最好不包含数组对象,尤其是string。当然,如果无法绕开,就需要自定义封送。
例如:
或者:
需要注意的是,如此使用必须保证托管代码中有内存分配,例如:
五、GC安全
由于Marshalling是通过数据拷贝实现的,仔细看来其实不太靠谱。如上面所说,通常会用IntPtr和unsafe特性来处理封送拷贝问题。但指针来说,需要注意避免在函数运行时被垃圾回收掉。例如下面的代码:
当执行完c.m()后,GC就会回收C的实例。很有可能非托管代码中的C.OperatOnHandle依然在使用_handle,因为已经跨界了,托管代码是不可能知道这件事的。解决办法是在这种情况下使用HandleRef来替代IntPtr。它可以保证直到非托管代码调用结束之后才GC托管对象。在.NET2.0中,我们也可以查阅文档(http://www.mono-project.com/docs/advanced/safehandles/)使用SafeFileHandle或者SafeWaitHandle。
既然我们要持有,那就要肩负起从托管代码释放非托管代码的责任。简单的做法是,确保所有资源的包装类中都有释放函数,并在使用完成后调用。如果不希望等待统一的GC,可以使用
来防止对象进入析构队列,直接回收资源。
如果觉得手动调用析构不放心,可以用using块来包围,以确保在块结束时自动释放,代码大致如下:
最后提醒一下,由于继承会提升GC权重(promote GC generation),包装类要尽量避免使用虚函数或作为非封存类(non-sealed calss)。如果释放的成员变量是包含其他对象的ArrayList,那么这个List、容器中的子对象、子对象中递归引用的对象,都会被提升GC权重。我们都知道,GC权重越大,被回收的速率越慢。所以优化的策略是:每个析构类都是叶子结点,主干是则是由这些互不引用的叶子组成的树。
六、总结
篇文章主要分析了在Mono框架下,非托管堆、运行时、托管堆如何关联,以及通过哪些方式调用。内存方面,介绍了什么是封送,以及类和结构体的关系和区别。本来准备结合Unity3D做些分析,但文章内容多成这样,恐怕已然没什么人看,拆分一下吧,但愿不要太监了。
参考文献:
http://www.mono-project.com/docs/advanced/embedding/
https://en.wikipedia.org/wiki/Marshalling_(computer_science)
http://www.mono-project.com/docs/advanced/pinvoke/
http://docs.go-mono.com/index.aspx?link=T:System.Runtime.InteropServices.StructLayoutAttribute
https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/fixed-statement
https://msdn.microsoft.com/zh-cn/library/77e6taeh(v=vs.85).aspx
https://docs.microsoft.com/en-us/dotnet/framework/interop/interop-marshaling
http://www.uml.org.cn/c++/201508185.asp、
http://docs.go-mono.com/index.aspx?link=T:System.Runtime.InteropServices.HandleRef
http://docs.go-mono.com/index.aspx?link=F:System.Runtime.InteropServices.LayoutKind.Auto
UPA——
一款针对Unity游戏/产品的深度性能分析工具,由腾讯WeTest和unity官方共同研发打造,可以帮助游戏开发者快速定位性能问题。旨在为游戏开发者提供更完善的手游性能解决方案,同时与开发环节形成闭环,保障游戏品质。
点击http://wetest.qq.com/cube/ 即可使用。
对UPA感兴趣的开发者,欢迎加入QQ群:633065352
如果对使用当中有任何疑问,欢迎联系腾讯WeTest企业
QQ:800024531