第三回.让.NET CF CLR有条不紊
摘要:前面文章的描述中已经提到了资源有限的移动设备应用的性能问题要比桌面程序更为棘手。对于托管的应用程序,CLR使你的应用程序从与OS直接耦合的状态中解放出来。然而CLR却常常因为程序员不恰当的编码方式而变得“烦躁”,从而导致JIT的代码膨胀以及程序性能的下降。本文主要阐述了在移动设备上CLR与程序性能有哪些密切联系,以及应当以何种方式编码以使.NET CF CLR的运行更加smooth。Keywords.NET Compact Framework, CLR, Performance, GC, Generics .etc
正确地理解CLR和OS以及应用程序性能之间的关系,对我们开发强壮而高效的移动设备程序是非常重要的。当然要把这个完全弄清楚不是和很容易,不过现在(本文)我们关心的是有关在.NET CF中如何以较好的方式编写强壮而高效的代码,其他的CLR相关的内容暂且不谈。好了,言规正传吧,下面阐述的是几个最为主要的方面。
1. 有关垃圾回收(GC)
CLR为每一个托管的进程分配了32MB的虚拟地址空间。每一个应用程序在托管对象的虚拟地址空间上都有一个GC堆。这个GC堆是由N多个在内存上开辟的段组成的,每个段的大小为64KB。在应用程序运行当中,随着越来越多托管对象被创建,GC Heap也会膨胀,但是不会超过1MB。GC的回收行为会在以下情况下触发:
a). 某个托管的进程已经达到了1MB的GC Heap上限
b). 某个应用程序被移到了后台执行
c). 某个应用程序受到了OS发来的WM_HIBERNATE消息
d). 某次内存分配失败的时候会触发GC
e). 代码中显示的调用GC.Collect()的时候会发生
一旦上述条件之一满足,GC会立即执行回收工作。
注意,这里并不是说在没发生上述情况的时候GC就没有运行,要知道为了获得你应用程序中某些资源的状态GC始终在背后监视着他们。
在.NET Compact Framework中GC的工作行为可以这样描述:
首先它会确保当前进程的所有线程处于一个“非运行态”,也就是说使他们在CLR下没有执行任何托管代码。这时,所有位于GC heap上的所有托管对象将会被检查,那些仍然处于被引用状态的对象会被标记。
接着,没有被标记的对象会被释放掉,不过有意思的是,只要程序中没有定义析构器(finalizer),这些对象所使用的内存段并不会立即返还给OS,因为GC heap本身会把这些段cache起来,直到达到1MB的上限。 而那些定义了析构器的部分会被直接排进析构队列中。这时,如果GC heap分段非常零碎,它可能会被压缩或者叫整理,就像PC中的磁盘碎片整理一样。
CLR把JITed代码缓存在内存中,使得JIT编译器不必每次都重新编译。这样的缓存当然不会一直保持下去,一旦出现内存危机还是会把缓存free掉,或者有选择的pitch掉。这个我在中就提到了。接下来,进程中所有的线程回复到运行态,而后台则由一个专门线程负责消化析构队列。
另外,要注意的是除了用关键字new显式构造的对象,CLR还会隐式地构造一些对象。比如曾提到过装箱拆箱的过程产生的额外对象。又比如特别的unmutable的String在改变的时候会创建新的String对象。还有一点需要注意的是,由于CLR自己会自动在恰当时候使用GC回收资源,所以一般不需要调用GC.Collect()做强制回收。
很显然,如果垃圾对象过多,应用程序和OS的性能都是会受到明显的影响的。由于GC是一个根内存使用紧密联系的随时间变化的动态过程,显示的调用GC.Collect()并不一定能提高多少性能,因为你不知道CLR本身会在什么时候执行GC,而CLR自然会选择最恰当的时候。
2. 方法调用
应用程序离不开方法调用,在资源有限的智能设备上同一个功能而采取不同的方法调用方式很可能带来截然不同的性能效果。下面是几种常见的方法调用的方式,而我们需要了解的是他们会对程序性能都产生什么程度的影响。
a) 本地代码调用也就是Windows API调用
b) 实例方法调用和静态方法调用都是编译时行为性能损耗约是a的2到3倍
c) 虚方法调用虚方法需要动态绑定到具体实现性能损耗约为a的3到5倍
d) P/Invoke调用在托管代码中,从Windows本地DLL中调用那些暴露出来的函数性能损耗约为a的10到15倍
e) COM调用在托管代码中访问COM对象的接口方法性能损耗约为a的10到15倍
3. P/Invoke 与 COM
在移动设备上,我们应该尽量多的避免互操作的调用。就像前面提到的,他们要比直接调用托管实例要慢得多。.NET CF中p/invoke的性能问题我已经在之前的后半部分提到。为了提高性能,您应当在每一个调用中做尽量多的工作,减少调用次数。另外尽量使用“易于”转化的通用类型作为参数,减少Marshal的开销。在那篇文章中我还提到了使用预链接的方式提高性能,预链接(Prelink)使得JIT预先编译所有互操作段的调用代码,这样后面执行起来就不需要再去链接了,而是直接执行编译号的本地代码。现在我们就来看一个当时没有列举的例子:
下面是不使用Prelink的输出:下面是使用Prelink的输出:PS:这里均是使用Windows Mobile 6 Classic Emulator的测试结果
可以看到,从时间上前者为后者的1.5倍,当然,这里也许还不是太明显,但是当你有很多的方法要从本地DLL调用的话,效果就十分显著了。不过大家要注意的是,Prelink同时会造成程序启动之初的一些额外的性能损失,这个是需要根据实际的情况去进行权衡的。这个就像我们在做文件浏览器的时候,需要考虑到底是一次循环递归还是即时递归一样。
4. 虚方法调用(Virtual Calls)
Virtual calls相比其他的托管调用要慢一些。它应当归属一种“解释型”的调用过程,其特点在于:它的方法表(vtable)并不在JIT编译时生成。也就是说,当一个Virtual call发起时,.NET CF CLR会索引类型层次目录并定位该方法。而对实例方法,CLR会为该类型的所有实例方法生成vtable,直接查该类型的vtable可以获得该类型的“方法指针”。另外,实例方法的调用会受到CLR以内联(inlining)的方式优化。而Virtual Calls是不会被内联的。 下面我们来看一个例子:
类型的层次关系也很明显:AnimalßFoxßBigFox。后两者均覆载了方法Eats()。另外BigFox还有一个实例方法作为比较。好了,现在我们来测试一下:
在Main函数中调用该函数,测试结果如下:
我们可以看到来自BigFox的虚方法调用是调用它的实例方法所需时间的10倍。不过这个数字是与类型的层次结构的深度有关的,越深的层次上调用虚方法CLR的开销越大,这个是好理解的。还有一点要说明的是,这里这个测试例子只是为了演示,真正要比较的话,应该是两个一组的比较,最好不要同时这样调用。因为在调用的过程中,会影响到父类的初始化,而使测试结果不太准确。
总而言之,在.Net CF中Virtual Calls是应当尽量避免的。如果您的类型设计使得某些时候非得使用这样的方式,那么也应当尽量减少调用次数和类型层次的深度。
5. 有关数学计算
在.NET CF中32位的整形数和浮点数的运算效率还是不错的,几乎和本地代码编写的运算效率一样。但是很遗憾,托管的64位的整形运算却要比本地代码做的64位运算慢上5到10倍。
6. 反射(Reflection)的问题
反射,在.NET Compact Framework中是一种相当“奢侈”的技术。必须在你确实非常需要它的时候再考虑使用它。反射的开销存在于它复杂的过程中,从程序集清单(manifest)获取类型信息,类型比较,访问类型成员等等。由于反射还允许你实例化反射的类型和动态加载程序集,这将使反射的过程比普通的JIT过程慢上10到100倍。所以是否真的需要适用反射,是一个.Net Compact Framework的程序员必须仔细评价的一个问题。
7. 也谈谈CF中的泛型
泛型出现在.Net CF 2.0以后的版本中,它为托管的应用程序提供了运行时的参数多态性支持。前两天AppleSeeker的中介绍了CF2.0中的泛型支持情况,这里我们通过一个例子做一下性能上的比较。使用泛型最明显的好处在于可以在某些集合类型中,避免过度的值类型的装箱拆箱问题。所以使用自然要更快一些。下面这个例子很好的比较了这个现象:
输出结果如下:
从结果上我们可以看到,这个例子中使用泛型要比直接使用ArrayList速度要快7倍左右。但同时我们也应该注意,使用泛型很容易造成JIT以后的代码膨胀。使用泛型类型参数的一个方法在JIT编译时,CLR获取方法的IL,替换指定的类型参数,然后针对那个方法在指定数据类型上的操作创建特有的本地代码。这期间CLR需要为每一组方法/类型对生成相应的本地代码(特别是值类型,引用类型CLR已经做了一些优化)。这就是所谓的“代码爆炸”(Code explosion)。所以使用泛型的时候也应当对这些开销做一个权衡。总结
前面说了很多有可能增加CLR的负担而影响程序性能的方面,最后来总结一下吧。看看实际的开发过程中,应该注意哪些方面:
首先,在一开始设计的时候就应当把性能问题考虑进去。因为对于设备应用程序来说,性能通常是首要的问题。不过还好,M$总算在.Net CF的Powertoys里面提供了一些相关的调试工具。您可以通过张欣同学的这次课程了解到更多相关知识。另外,由于方法调用以及某些内存问题,你必须在类型设计的时候就应当考虑到可能带来的性能影响,就像前面的VCall的例子。
既然是.Net Compact Framework就应该遵循”Compact”的原则。使用尽量少的托管对象,控件,尽量少的代码,尽量少的层次结构。多生成一个对象实际上意味着多了很多工作:构造和初始化,分配内存,GC等等。
恰当的使用多线程提高用户体验。这里主要是说的我们有时候会经常需要更新用户界面,而这时可以考虑多线程的方式无阻塞(no block)地更新UI以显得更为友好。还应尽量使用异步的I/O,在显示方面,尽量使用双缓冲技术来操作和显示位图。 文中所有示例代码
Enjoy it !---
©Freesc Huang
黄季冬<fox23>@HUST 2008/3/5