想了解更多關於新的編譯器的信息,能夠訪問 .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型的垃圾回收)app
這部分的例子雖然背後關於內存分配的地方不多。可是,若是一個大的應用程序執行足夠多的這些小的會致使內存分配的表達式,那麼這些表達式會致使幾百M,甚至幾G的內存分配。好比,在性能測試團隊把問題定位到輸入場景以前,一分鐘的測試模擬開發者在編譯器裏面編寫代碼會分配幾G的內存。框架
裝箱發生在當一般分配在線程棧上或者數據結構中的值類型,或者臨時的值須要被包裝到對象中的時候(好比分配一個對象來存放數據,活着返回一個指針給一個Object對象)。.NET框架因爲方法的簽名或者類型的分配位置,有些時候會自動對值類型進行裝箱。將值類型包裝爲引用類型會產生內存分配。.NET框架及語言會盡可能避免沒必要要的裝箱,可是有時候在咱們沒有注意到的時候會產生裝箱操做。過多的裝箱操做會在應用程序中分配成M上G的內存,這就意味着垃圾回收的更加頻繁,也會花更長時間。
在PerfView中查看裝箱操做,只須要開啓一個追蹤(trace),而後查看應用程序名字下面的GC Heap Alloc 項(記住,PerfView會報告全部的進程的資源分配狀況),若是在分配相中看到了一些諸如System.Int32和System.Char的值類型,那麼就發生了裝箱。選擇一個類型,就會顯示調用棧以及發生裝箱的操做的函數。
下面的示例代碼演示了潛在的沒必要要的裝箱以及在大的系統中的頻繁的裝箱操做。
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();
下面的這個例子是致使新的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的建立也須要進行良好的控制以免可能會產生的性能瓶頸。
在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()方法中的全部額外內存分配。
本例中使用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中的部分模塊也使用了相似的技術來提高性能。
2,
本文分享了性能優化的一些建議和思考,好比不要過早優化、好工具很重要、性能的關鍵,在於內存分配等。開發者不要盲目的沒有根據的優化,首先定位和查找到形成產生性能問題的緣由點最重要。
使用LINQ 和Lambdas表達式是C#語言強大生產力的一個很好體現,可是若是代碼須要執行不少次的時候,可能須要對LINQ或者Lambdas表達式進行重寫。
下面的例子使用 LINQ以及函數式風格的代碼來經過編譯器模型給定的名稱來查找符號。
class Symbol { public string Name { get; private set; } /*...*/ } class Compiler { private List<Symbol> symbols; public Symbol FindMatchingSymbol(string name) { return symbols.FirstOrDefault(s => s.Name == name); } }
新的編譯器和IDE 體驗基於調用FindMatchingSymbol,這個調用很是頻繁,在此過程當中,這麼簡單的一行代碼隱藏了基礎內存分配開銷。爲了展現這其中的分配,咱們首先將該單行函數拆分爲兩行:
Func<Symbol, bool> predicate = s => s.Name == name; return symbols.FirstOrDefault(predicate);
第一行中, lambda表達式「s=>s.Name==name」 是對本地變量name的一個 閉包。這就意味着須要分配額外的對象來爲 委託對象predict分配空間,須要一個分配一個靜態類來保存環境從而保存name的值。編譯器會產生以下代碼:
// Compiler-generated class to hold environment state for lambda private class Lambda1Environment { public string capturedName; public bool Evaluate(Symbol s) { return s.Name == this.capturedName; } } // Expanded Func<Symbol, bool> predicate = s => s.Name == name; Lambda1Environment l = new Lambda1Environment() { capturedName = name }; var predicate = new Func<Symbol, bool>(l.Evaluate);
兩個new操做符(第一個建立一個環境類,第二個用來建立委託)很明顯的代表了內存分配的狀況。
如今來看看FirstOrDefault方法的調用,他是IEnumerable<T>類的擴展方法,這也會產生一次內存分配。由於FirstOrDefault使用IEnumerable<T>做爲第一個參數,能夠將上面的展開爲下面的代碼:
// Expanded return symbols.FirstOrDefault(predicate) ... IEnumerable<Symbol> enumerable = symbols; IEnumerator<Symbol> enumerator = enumerable.GetEnumerator(); while (enumerator.MoveNext()) { if (predicate(enumerator.Current)) return enumerator.Current; } return default(Symbol);
symbols變量是類型爲List<T>的變量。List<T>集合類型實現了IEnumerable<T>便可而且清晰地定義了一個 迭代器,List<T>的迭代器使用了一種結構體來實現。使用結構而不是類意味着一般能夠避免任何在託管堆上的分配,從而能夠影響垃圾回收的效率。枚舉典型的用處在於方便語言層面上使用foreach循環,他使用enumerator結構體在調用推棧上返回。遞增調用堆棧指針來爲對象分配空間,不會影響GC對託管對象的操做。
在上面的展開FirstOrDefault調用的例子中,代碼會調用IEnumerabole<T>接口中的GetEnumerator()方法。將symbols賦值給IEnumerable<Symbol>類型的enumerable 變量,會使得對象丟失了其實際的List<T>類型信息。這就意味着當代碼經過enumerable.GetEnumerator()方法獲取迭代器時,.NET Framework 必須對返回的值(即迭代器,使用結構體實現)類型進行裝箱從而將其賦給IEnumerable<Symbol>類型的(引用類型) enumerator變量。
解決方法:
解決辦法是重寫FindMatchingSymbol方法,將單個語句使用六行代碼替代,這些代碼依舊連貫,易於閱讀和理解,也很容易實現。
public Symbol FindMatchingSymbol(string name) { foreach (Symbol s in symbols) { if (s.Name == name) return s; } return null; }
代碼中並無使用LINQ擴展方法,lambdas表達式和迭代器,而且沒有額外的內存分配開銷。這是由於編譯器看到symbol 是List<T>類型的集合,由於可以直接將返回的結構性的枚舉器綁定到類型正確的本地變量上,從而避免了對struct類型的裝箱操做。原先的代碼展現了C#語言豐富的表現形式以及.NET Framework 強大的生產力。該着後的代碼則更加高效簡單,並無添加複雜的代碼而增長可維護性。
接下來的例子展現了當咱們試圖緩存一部方法返回值時的一個廣泛問題:
Visual Studio IDE 的特性在很大程度上創建在新的C#和VB編譯器獲取語法樹的基礎上,當編譯器使用async的時候仍可以保持Visual Stuido可以響應。下面是獲取語法樹的第一個版本的代碼:
class Parser { /*...*/ public SyntaxTree Syntax { get; } public Task ParseSourceCode() { /*...*/ } } class Compilation { /*...*/ public async Task<SyntaxTree> GetSyntaxTreeAsync() { var parser = new Parser(); // allocation await parser.ParseSourceCode(); // expensive return parser.Syntax; } }
能夠看到調用GetSyntaxTreeAsync() 方法會實例化一個Parser對象,解析代碼,而後返回一個Task<SyntaxTree>對象。最耗性能的地方在爲Parser實例分配內存並解析代碼。方法中返回一個Task對象,所以調用者能夠await解析工做,而後釋放UI線程使得能夠響應用戶的輸入。
因爲Visual Studio的一些特性可能須要屢次獲取相同的語法樹, 因此一般可能會緩存解析結果來節省時間和內存分配,可是下面的代碼可能會致使內存分配:
class Compilation { /*...*/ private SyntaxTree cachedResult; public async Task<SyntaxTree> GetSyntaxTreeAsync() { if (this.cachedResult == null) { var parser = new Parser(); // allocation await parser.ParseSourceCode(); // expensive this.cachedResult = parser.Syntax; } return this.cachedResult; } }
代碼中有一個SynataxTree類型的名爲cachedResult的字段。當該字段爲空的時候,GetSyntaxTreeAsync()執行,而後將結果保存在cache中。GetSyntaxTreeAsync()方法返回SyntaxTree對象。問題在於,當有一個類型爲Task<SyntaxTree> 類型的async異步方法時,想要返回SyntaxTree的值,編譯器會生出代碼來分配一個Task來保存執行結果(經過使用Task<SyntaxTree>.FromResult())。Task會標記爲完成,而後結果立馬返回。分配Task對象來存儲執行的結果這個動做調用很是頻繁,所以修復該分配問題可以極大提升應用程序響應性。
解決方法:
要移除保存完成了執行任務的分配,能夠緩存Task對象來保存完成的結果。
class Compilation { /*...*/ private Task<SyntaxTree> cachedResult; public Task<SyntaxTree> GetSyntaxTreeAsync() { return this.cachedResult ?? (this.cachedResult = GetSyntaxTreeUncachedAsync()); } private async Task<SyntaxTree> GetSyntaxTreeUncachedAsync() { var parser = new Parser(); // allocation await parser.ParseSourceCode(); // expensive return parser.Syntax; } }
代碼將cachedResult 類型改成了Task<SyntaxTree> 而且引入了async幫助函數來保存原始代碼中的GetSyntaxTreeAsync()函數。GetSyntaxTreeAsync函數如今使用 null操做符,來表示當cachedResult不爲空時直接返回,爲空時GetSyntaxTreeAsync調用GetSyntaxTreeUncachedAsync()而後緩存結果。注意GetSyntaxTreeAsync並無await調用GetSyntaxTreeUncachedAsync。沒有使用await意味着當GetSyntaxTreeUncachedAsync返回Task類型時,GetSyntaxTreeAsync 也當即返回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不當、以及處處傳遞結構體等。在優化咱們的應用程序的時候,須要時刻注意以前提到過的四點: