.NET程式效能的基本要領

寒江獨釣發表於2014-08-27

  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對記憶體的分配貢獻相當大。

相關文章