首页 > 编程语言 > .NET开发 > .NET程序性能的基本要领
2014
12-02

.NET程序性能的基本要领

  Bill Chiles(Roslyn编译器的程序经理)写了一篇文章《Essential Performance Facts and .NET Framework Tips》,知名博主寒江独钓对该文进行了摘译,文中分享了性能优化的一些建议和思考,比如不要过早优化、好工具很重要、性能的关键,在于内存分配等,并指出开发者不要盲目的没有根据的优化,首先定位和查找到造成产生性能问题的原因点最重要。

  全文如下:

  本文提供了一些性能优化的建议,这些经验来自于使用托管代码重写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 Compiler Platform ("Roslyn")

 基本要领

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

  要领一:不要过早优化

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

  要领二:没有评测,便是猜测

  剖析和测量不会撒谎。测评可以显示CPU是否满负荷运转或者是存在磁盘I/O阻塞。测评会告诉你应用程序分配了什么样的以及多大的内存,以及是否CPU花费了很多时间在 垃圾回收上。

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

  要领三:好工具很重要

  好的工具能够让我们能够快速的定位到影响性能的最大因素(CPU,内存,磁盘)并且能够帮助我们定位产生这些瓶颈的代码。微软已经发布了很多性能测试工具比如: Visual Studio Profiler, Windows Phone Analysis Tool, 以及 PerfView.

  PerfView是一款免费且性能强大的工具,他主要关注影响性能的一些深层次的问题(磁盘 I/O,GC 事件,内存),后面会展示这方面的例子。我们能够抓取性能相关的 Event Tracing for Windows(ETW)事件并能以应用程序,进程,堆栈,线程的尺度查看这些信息。PerfView能够展示应用程序分配了多少,以及分配了何种内存以及应用程序中的函数以及调用堆栈对内存分配的贡献。这些方面的细节,您可以查看随工具下载发布的关于PerfView的非常详细的帮助,Demo以及视频教程(比如 Channel9上的视频教程)

  要领四:所有的都与内存分配相关

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

  在使用新的编译器API开发响应良好的IDE的实践中,大部分工作都花在了如何避免开辟内存以及管理缓存策略。PerfView追踪显示新的C# 和VB编译器的性能基本上和CPU的性能瓶颈没有关系。编译器在读入成百上千甚至上万行代码,读入元数据活着产生编译好的代码,这些操作其实都是I/O bound 密集型。UI线程的延迟几乎全部都是由于垃圾回收导致的。.NET框架对垃圾回收的性能已经进行过高度优化,他能够在应用程序代码执行的时候并行的执行垃圾回收的大部分操作。但是,单个内存分配操作有可能会触发一次昂贵的垃圾回收操作,这样GC会暂时挂起所有线程来进行垃圾回收(比如 Generation 2型的垃圾回收)

 常见的内存分配以及例子

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

  装箱

  装箱发生在当通常分配在线程栈上或者数据结构中的值类型,或者临时的值需要被包装到对象中的时候(比如分配一个对象来存放数据,活着返回一个指针给一个Object对象)。.NET框架由于方法的签名或者类型的分配位置,有些时候会自动对值类型进行装箱。将值类型包装为引用类型会产生内存分配。.NET框架及语言会尽量避免不必要的装箱,但是有时候在我们没有注意到的时候会产生装箱操作。过多的装箱操作会在应用程序中分配成M上G的内存,这就意味着垃圾回收的更加频繁,也会花更长时间。

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

  例1 string方法和其值类型参数

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

public class Logger
{
    public static void WriteLine(string s)
    {
        /*...*/
    }
}
public class BoxingExample
{
    public void Log(int id, int size)
    {
        var s = string.Format("{0}:{1}", id, size);
        Logger.WriteLine(s);
    }
}

  这是一个日志基础类,因此app会很频繁的调用Log函数来记日志,可能该方法会被调用millons次。问题在于,调用string.Format方法会调用其 重载的接受一个string类型和两个Object类型的方法:

String.Format Method (String, Object, Object)

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

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

var s = id.ToString() + ':' + size.ToString();

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

string.Concat(Object, Object, Object);

  这个方法,.NET Framework 必须对字符常量进行装箱来调用Concat方法。

  解决方法:

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

var s = id.ToString() + ":" + size.ToString();

  例2 枚举类型的装箱

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

public enum Color { Red, Green, Blue }
public class BoxingExample
{
    private string name;
    private Color color;
    public override int GetHashCode()
    {
        return name.GetHashCode() ^ color.GetHashCode();
    }
}

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

  解决方法:

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

((int)color).GetHashCode()

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

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

  字符串

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

  例3 字符串操作

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

public void WriteFormattedDocComment(string text)
{
    string[] lines = text.Split(new[] {"\r\n", "\r", "\n"},
        StringSplitOptions.None);
    int numLines = lines.Length;
    bool skipSpace = true;
    if (lines[0].TrimStart().StartsWith("///"))
    {
        for (int i = 0; i < numLines; i++)
        {
            string trimmed = lines[i].TrimStart();
            if (trimmed.Length < 4 || !char.IsWhiteSpace(trimmed[3]))
            {
                skipSpace = false;
                break;
            }
        }
        int substringStart = skipSpace ? 4 : 3;
        for (int i = 0; i < numLines; i++)
            Console.WriteLine(lines[i].TrimStart().Substring(substringStart));
    }
    else
    {
        /* ... */
    }
}

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

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

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

namespace System
{ 
    public class String 
    { 
        public string TrimStart(params char[] trimChars);
    }
}

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

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

  解决方法:

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

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

private int IndexOfFirstNonWhiteSpaceChar(string text, int start)
{
    while (start < text.Length && char.IsWhiteSpace(text[start])) 
        start++;
    return start;
}

private bool TrimmedStringStartsWith(string text, int start, string prefix)
{
    start = IndexOfFirstNonWhiteSpaceChar(text, start); 
    int len = text.Length - start; 
    if (len < prefix.Length) return false;
    for (int i = 0; i < len; i++)
    {
        if (prefix[i] != text[start + i]) 
            return false;
    }
    return true;
}

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

  例4 StringBuilder

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

public class Example 
{ 
    // Constructs a name like "SomeType<T1, T2, T3>" 
    public string GenerateFullTypeName(string name, int arity) 
    { 
        StringBuilder sb = new StringBuilder();
        sb.Append(name);
        if (arity != 0)
        { 
            sb.Append("<");
            for (int i = 1; i < arity; i++)
            {
                sb.Append("T"); sb.Append(i.ToString()); sb.Append(", ");
            } 
            sb.Append("T"); sb.Append(i.ToString()); sb.Append(">");
        }
        return sb.ToString(); 
    }
}

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

  解决方法:

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

// Constructs a name like "Foo<T1, T2, T3>" 
public string GenerateFullTypeName(string name, int arity)
{
    StringBuilder sb = AcquireBuilder(); /* Use sb as before */ 
    return GetStringAndReleaseBuilder(sb);
}

  关键部分在于新的 AcquireBuilder()GetStringAndReleaseBuilder()方法:

[ThreadStatic]
private static StringBuilder cachedStringBuilder;

private static StringBuilder AcquireBuilder()
{
    StringBuilder result = cachedStringBuilder;
    if (result == null)
    {
        return new StringBuilder();
    } 
    result.Clear(); 
    cachedStringBuilder = null; 
    return result;
}

private static string GetStringAndReleaseBuilder(StringBuilder sb)
{
    string result = sb.ToString(); 
    cachedStringBuilder = sb; 
    return result;
}

  上面方法实现中使用了 thread-static字段来缓存StringBuilder对象,这是由于新的编译器使用了多线程的原因。很可能会忘掉这个ThreadStatic声明。Thread-static字符为每个执行这部分的代码的线程保留一个唯一的实例。

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

  当我们对StringBuilder处理完成之后,调用GetStringAndReleaseBuilder()方法即可获取string结果。然后将StringBuilder保存到字段中或者缓存起来,然后返回结果。这段代码很可能重复执行,从而创建多个StringBuilder对象,虽然很少会发生。代码中仅保存最后被释放的那个StringBuilder对象来留作后用。新的编译器中,这种简单的的缓存策略极大地减少了不必要的内存分配。.NET Framework 和 MSBuild中的部分模块也使用了类似的技术来提升性能。

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

编程技巧