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 進行性能調製以及基本要領
在對.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 Frameworkint型裝箱為object 類型裝箱為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# 和VBBary由於頻繁的使用枚舉操作了大量記憶體的原因。
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()
例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對記憶體的分配貢獻相當大。