Bill Chiles(Roslyn編譯器的程式經理)寫了一篇文章《Essential Performance Facts and .NET Framework Tips》,知名博主寒江獨釣對該文進行了摘譯,文中分享了效能最佳化的一些建議和思考,比如不要過早最佳化、好工具很重要、效能的關鍵,在於記憶體配置等,並指出開發人員不要盲目的沒有根據的最佳化,首先定位和尋找到造成產生效能問題的原因點最重要。
全文如下:
本文提供了一些效能最佳化的建議,這些經驗來自於使用Managed 程式碼重寫C# 和 VB編譯器,並以編寫C# 編譯器中的一些真實情境作為例子來展示這些最佳化經驗。.NET 平台開發應用程式具有極高的生產力。.NET 平台上強大安全的程式設計語言以及豐富的類庫,使得開發應用變得卓有成效。但是能力越大責任越大。我們應該使用.NET架構的強大能力,但同時如果我們需要處理大量的資料比如檔案或者資料庫也需要準備對我們的代碼進行調優。
為什麼來自新的編譯器的效能最佳化經驗也適用於您的應用程式
微軟使用Managed 程式碼重寫了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對記憶體的分配貢獻相當大。