天天看点

.NET程序的性能要领和优化建议为什么来自新的编译器的性能优化经验也适用于您的应用程序基本要领常见的内存分配以及例子结论参考资料

---------------------------------------------------------------------------

本文提供了一些性能优化的建议,这些经验来自于使用托管代码重写c# 和 vb编译器,并以编写c# 编译器中的一些真实场景作为例子来展示这些优化经验。.net 平台开发应用程序具有极高的生产力。.net 平台上强大安全的编程语言以及丰富的类库,使得开发应用变得卓有成效。但是能力越大责任越大。我们应该使用.net框架的强大能力,但同时如果我们需要处 理大量的数据比如文件或者数据库也需要准备对我们的代码进行调优。

微软使用托管代码重写了c#和visual basic的编译器,并提供了一些列新的api来进行代码建模和分析、开发编译工具,使得visual studio具有更加丰富的代码感知的编程体验。重写编译器,并且在新的编译器上开发visual studio的经验使得我们获得了非常有用的性能优化经验,这些经验也能用于大型的.net应用,或者一些需要处理大量数据的app上。你不需要了解编译 器,也能够从c#编译器的例子中得出这些见解。

visual studio使用了编译器的api来实现了强大的智能感知(intellisense)功能,如代码关键字着色,语法填充列表,错误波浪线提示,参数提 示,代码问题及修改建议等,这些功能深受开发者欢迎。visual studio在开发者输入或者修改代码的时候,会动态的编译代码来获得对代码的分析和提示。

当用户和app进行交互的时候,通常希望软件具有好的响应性。输入或者执行命令的时候,应用程序界面不应该被阻塞。帮助或者提示能够迅速显示出来或者当用户继续输入的时候停止提示。现在的app应该避免在执行长时间计算的时候阻塞ui线程从而让用户感觉程序不够流畅。

在对.net 进行性能调优以及开发具有良好响应性的应用程序的时候,请考虑以下这些基本要领:

编写代码比想象中的要复杂的多,代码需要维护,调试及优化性能。 一个有经验的程序员,通常会对自然而然的提出解决问题的方法并编写高效的代码。 但是有时候也可能会陷入过早优化代码的问题中。比如,有时候使用一个简单的数组就够了,非要优化成使用哈希表,有时候简单的重新计算一下可以,非要使用复 杂的可能导致内存泄漏的缓存。发现问题时,应该首先测试性能问题然后再分析代码。

应该为关键的用户体验或者场景设置性能目标,并且编写测试来测量性能。通过使用科学的方法来分析性能不达标的原因的步骤如下:使用测评报告来指导, 假设可能出现的情况,并且编写实验代码或者修改代码来验证我们的假设或者修正。如果我们设置了基本的性能指标并且经常测试,就能够避免一些改变导致性能的 回退(regression),这样就能够避免我们浪费时间在一些不必要的改动中。

你可能会想,编写响应及时的基于.net的应用程序关键在于采用好的算法,比如使用快速排序替代冒泡排序,但是实际情况并不是这样。编写一个响应良好的app的最大因素在于内存分配,特别是当app非常大或者处理大量数据的时候。

这部分的例子虽然背后关于内存分配的地方很少。但是,如果一个大的应用程序执行足够多的这些小的会导致内存分配的表达式,那么这些表达式会导致几百 m,甚至几g的内存分配。比如,在性能测试团队把问题定位到输入场景之前,一分钟的测试模拟开发者在编译器里面编写代码会分配几g的内存。

在perfview中查看装箱操作,只需要开启一个追踪(trace),然后查看应用程序名字下面的gc heap alloc 项(记住,perfview会报告所有的进程的资源分配情况),如果在分配相中看到了一些诸如system.int32和system.char的值类 型,那么就发生了装箱。选择一个类型,就会显示调用栈以及发生装箱的操作的函数。

下面的示例代码演示了潜在的不必要的装箱以及在大的系统中的频繁的装箱操作。

该重载方法要求.net framework 把int型装箱为object类型然后将它传到方法调用中去。为了解决这一问题,方法就是调用id.tostring()和size.tostring()方法,然后传入到string.format 方法中去,调用tostring()方法的确会导致一个string的分配,但是在string.format方法内部不论怎样都会产生string类型的分配。

你可能会认为这个基本的调用string.format 仅仅是字符串的拼接,所以你可能会写出这样的代码:

实际上,上面这行代码也会导致装箱,因为上面的语句在编译的时候会调用:

这个方法,.net framework 必须对字符常量进行装箱来调用concat方法。

解决方法:

完全修复这个问题很简单,将上面的单引号替换为双引号即将字符常量换为字符串常量就可以避免装箱,因为string类型的已经是引用类型了。

下面的这个例子是导致新的c# 和vb编译器由于频繁的使用枚举类型,特别是在dictionary中做查找操作时分配了大量内存的原因。

问题非常隐蔽,perfview会告诉你enmu.gethashcode()由于内部实现的原因产生了装箱操作,该方法会在底层枚举类型的表现形式上进行装箱,如果仔细看perfview,会看到每次调用gethashcode会产生两次装箱操作。编译器插入一次,.net framework插入另外一次。

通过在调用gethashcode的时候将枚举的底层表现形式进行强制类型转换就可以避免这一装箱操作。

另一个使用枚举类型经常产生装箱的操作时enum.hasflag。传给hasflag的参数必须进行装箱,在大多数情况下,反复调用hasflag通过位运算测试非常简单和不需要分配内存。

要牢记基本要领第一条,不要过早优化。并且不要过早的开始重写所有代码。 需要注意到这些装箱的耗费,只有在通过工具找到并且定位到最主要问题所在再开始修改代码。

字符串操作是引起内存分配的最大元凶之一,通常在perfview中占到前五导致内存分配的原因。应用程序使用字符串来进行序列化,表示json和 rest。在不支持枚举类型的情况下,字符串可以用来与其他系统进行交互。当我们定位到是由于string操作导致对性能产生严重影响的时候,需要留意 string类的format(),concat(),split(),join(),substring()等这些方法。使用stringbuilder能够避免在拼接多个字符串时创建多个新字符串的开销,但是stringbuilder的创建也需要进行良好的控制以避免可能会产生的性能瓶颈。

在c#编译器中有如下方法来输出方法前面的xml格式的注释。

可以看到,在这片代码中包含有很多字符串操作。代码中使用类库方法来将行分割为字符串,来去除空格,来检查参数text是否是xml文档格式的注释,然后从行中取出字符串处理。

在writeformatteddoccomment方法每次被调用时,第一行代码调用split()就会分配三个元素的字符串数组。编译器也需要产生代码来分配这个数组。因为编译器并不知道,如果splite()存储了这一数组,那么其他部分的代码有可能会改变这个数组,这样就会影响到后面对writeformatteddoccomment方法的调用。每次调用splite()方法也会为参数text分配一个string,然后在分配其他内存来执行splite操作。

writeformatteddoccomment方法中调用了三次trimstart()方法,在内存环中调用了两次,这些都是重复的工作和内存分配。更糟糕的是,trimstart()的无参重载方法的签名如下:

该方法签名意味着,每次对trimstart()的调用都回分配一个空的数组以及返回一个string类型的结果。

最后,调用了一次substring()方法,这个方法通常会导致在内存中分配新的字符串。

和前面的只需要小小的修改即可解决内存分配的问题不同。在这个例子中,我们需要从头看,查看问题然后采用不同的方法解决。比如,可以意识到writeformatteddoccomment()方法的参数是一个字符串,它包含了方法中需要的所有信息,因此,代码只需要做更多的index操作,而不是分配那么多小的string片段。

下面的方法并没有完全解,但是可以看到如何使用类似的技巧来解决本例中存在的问题。c#编译器使用如下的方式来消除所有的额外内存分配。

writeformatteddoccomment() 方法的第一个版本分配了一个数组,几个子字符串,一个trim后的子字符串,以及一个空的params数组。也检查了”///”。修改后的代码仅使用了index操作,没有任何额外的内存分配。它查找第一个非空格的字符串,然后逐个字符串比较来查看是否以”///”开头。和使用trimstart()不同,修改后的代码使用indexoffirstnonwhitespacechar方法来返回第一个非空格的开始位置,通过使用这种方法,可以移除writeformatteddoccomment()方法中的所有额外内存分配。

本例中使用stringbuilder。下面的函数用来产生泛型类型的全名:

注意力集中到stringbuilder实例的创建上来。代码中调用sb.tostring()会导致一次内存分配。在stringbuilder中的内部实现也会导致内部内存分配,但是我们如果想要获取到string类型的结果化,这些分配无法避免。

要解决stringbuilder对象的分配就使用缓存。即使缓存一个可能被随时丢弃的单个实例对象也能够显著的提高程序性能。下面是该函数的新的实现。除了下面两行代码,其他代码均相同

关键部分在于新的 acquirebuilder()和getstringandreleasebuilder()方法:

如果已经有了一个实例,那么acquirebuilder()方法直接返回该缓存的实例,在清空后,将该字段或者缓存设置为null。否则acquirebuilder()创建一个新的实例并返回,然后将字段和cache设置为null 。

简单的缓存策略必须遵循良好的缓存设计,因为他有大小的限制cap。使用缓存可能比之前有更多的代码,也需要更多的维护工作。我们只有在发现这是个问题之后才应该采缓存策略。perfview已经显示出stringbuilder对内存的分配贡献相当大。

使用linq 和lambdas表达式是c#语言强大生产力的一个很好体现,但是如果代码需要执行很多次的时候,可能需要对linq或者lambdas表达式进行重写。

新的编译器和ide 体验基于调用findmatchingsymbol,这个调用非常频繁,在此过程中,这么简单的一行代码隐藏了基础内存分配开销。为了展示这其中的分配,我们首先将该单行函数拆分为两行:

两个new操作符(第一个创建一个环境类,第二个用来创建委托)很明显的表明了内存分配的情况。

现在来看看firstordefault方法的调用,他是ienumerable<t>类的扩展方法,这也会产生一次内存分配。因为firstordefault使用ienumerable<t>作为第一个参数,可以将上面的展开为下面的代码:

在上面的展开firstordefault调用的例子中,代码会调用ienumerabole<t>接口中的getenumerator()方法。将symbols赋值给ienumerable<symbol>类型的enumerable 变量,会使得对象丢失了其实际的list<t>类型信息。这就意味着当代码通过enumerable.getenumerator()方法获取迭代器时,.net framework 必须对返回的值(即迭代器,使用结构体实现)类型进行装箱从而将其赋给ienumerable<symbol>类型的(引用类型) enumerator变量。

解决办法是重写findmatchingsymbol方法,将单个语句使用六行代码替代,这些代码依旧连贯,易于阅读和理解,也很容易实现。

代码中并没有使用linq扩展方法,lambdas表达式和迭代器,并且没有额外的内存分配开销。这是因为编译器看到symbol 是list<t>类型的集合,因为能够直接将返回的结构性的枚举器绑定到类型正确的本地变量上,从而避免了对struct类型的装箱操作。原先的代码展示了c#语言丰富的表现形式以及.net framework 强大的生产力。该着后的代码则更加高效简单,并没有添加复杂的代码而增加可维护性。

接下来的例子展示了当我们试图缓存一部方法返回值时的一个普遍问题:

visual studio ide 的特性在很大程度上建立在新的c#和vb编译器获取语法树的基础上,当编译器使用async的时候仍能够保持visual stuido能够响应。下面是获取语法树的第一个版本的代码:

可以看到调用getsyntaxtreeasync() 方法会实例化一个parser对象,解析代码,然后返回一个task<syntaxtree>对象。最耗性能的地方在为parser实例分配内存并解析代码。方法中返回一个task对象,因此调用者可以await解析工作,然后释放ui线程使得可以响应用户的输入。

由于visual studio的一些特性可能需要多次获取相同的语法树, 所以通常可能会缓存解析结果来节省时间和内存分配,但是下面的代码可能会导致内存分配:

代码中有一个synataxtree类型的名为cachedresult的字段。当该字段为空的时候,getsyntaxtreeasync()执行,然后将结果保存在cache中。getsyntaxtreeasync()方法返回syntaxtree对象。问题在于,当有一个类型为task<syntaxtree> 类型的async异步方法时,想要返回syntaxtree的值,编译器会生出代码来分配一个task来保存执行结果(通过使用task<syntaxtree>.fromresult())。task会标记为完成,然后结果立马返回。分配task对象来存储执行的结果这个动作调用非常频繁,因此修复该分配问题能够极大提高应用程序响应性。

要移除保存完成了执行任务的分配,可以缓存task对象来保存完成的结果。

在大的app或者处理大量数据的app中,还有几点可能会引发潜在的性能问题。

在很多应用程序中,dictionary用的很广,虽然字非常方便和高校,但是经常会使用不当。在visual studio以及新的编译器中,使用性能分析工具发现,许多dictionay只包含有一个元素或者干脆是空的。一个空的dictionay结构内部会有10个字段在x86机器上的托管堆上会占据48个字节。当需要在做映射或者关联数据结构需要事先常量时间查找的时候,字典非常有用。但是当只有几个元素,使用字典就会浪费大量内存空间。相反,我们可以使用list<keyvaluepair<k,v>>结构来实现便利,对于少量元素来说,同样高校。如果仅仅使用字典来加载数据,然后读取数据,那么使用一个具有n(log(n))的查找效率的有序数组,在速度上也会很快,当然这些都取决于的元素的个数。

不甚严格的讲,在优化应用程序方面,类和结构提供了一种经典的空间/时间的权衡(trade off)。在x86机器上,每个类即使没有任何字段,也会分配12 byte的空间 (译注:来保存类型对象指针和同步索引块),但是将类作为方法之间参数传递的时候却十分高效廉价,因为只需要传递指向类型实例的指针即可。结构体如果不撞 向的话,不会再托管堆上产生任何内存分配,但是当将一个比较大的结构体作为方法参数或者返回值得时候,需要cpu时间来自动复制和拷贝结构体,然后将结构 体的属性缓存到本地便两种以避免过多的数据拷贝。

性能优化的一个常用技巧是缓存结果。但是如果缓存没有大小上限或者良好的资源释放机制就会导致内存泄漏。在处理大数据量的时候,如果在缓存中缓存了过多数据就会占用大量内存,这样导致的垃圾回收开销就会超过在缓存中查找结果所带来的好处。

在大的系统,或者或者需要处理大量数据的系统中,我们需要关注产生性能瓶颈症状,这些问题再规模上会影响app的响应性,如装箱操作、字符串操作、linq和lambda表达式、缓存async方法、缓存缺少大小限制以及良好的资源释放策略、使用dictionay不当、以及到处传递结构体等。在优化我们的应用程序的时候,需要时刻注意之前提到过的四点:

不要进行过早优化——在定位和发现问题之后再进行调优。

专业测试不会说谎——没有评测,便是猜测。

内存分配决定app的响应性。——这也是新的编译器性能团队花的时间最多的地方。