WeTest腾讯质量开发平台 Unity3d 底层数据传递分析

腾讯WeTest · 2018年03月22日 · 1392 次阅读

作者:樊松阳,腾讯游戏客户端开发 高级工程师
商业转载请联系腾讯 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,而采用另一种常见的托管到非托管的封送方式:

  1. Runtime 分配一块非托管内存。

  2. 托管类数据拷贝到刚申请的非托管内存中。

  3. 调用非托管方法时,使用上面的非托管内存数据,而不是原始托管内存数据。这样做是为了,当 GC 发生时,非托管内存是可用的。

  4. 将非托管内存拷回托管内存。

因为不能确定托管堆中的内存会何时失效,在非托管代码中,我们不应该缓存任何托管代码传进来的数据。

另一种情况是返回值,类在非托管代码中,不可以作为值返回,只可以返回指针。因为堆内容无法互通,当返回到托管代码时,会经历以下步骤

  1. 托管代码调用非托管代码,返回了指向在非托管内存中的结构体的指针。

  2. 在托管代码中找到对应的托管类并实例化,将非托管内容封送到托管类中。

  3. 非托管代码中的内存被 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、结构体的传递

结构体与类有两点不同:

  1. 结构体分配在运行时的栈上(Runtime Stack)。

  2. 默认使用 Sequential,非托管代码使用时不需要额外设置属性。

在把结构体传递给非托管代码时,有些情况下不会产生内存拷贝:

  1. 作为值传递时,结构分配在栈上,并且是可比特化类型(blittable types)

  2. 作为引用传递

在上述情况下,不需要指定 [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/77e6taehv=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

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册