.NET程序的性能要領(lǐng)和優(yōu)化建議
【編者按】Roslyn編譯器的項(xiàng)目經(jīng)理 BillChiles寫了一篇文章《Essential Performance Facts and .NET Framework Tips》的文章,以下是博主寒江獨(dú)釣對(duì)這篇14頁的文章的提煉和思考。本文主要分析了.NET程序的性能優(yōu)化基本要領(lǐng)和大量的優(yōu)化建議,分別從值類型(如結(jié)構(gòu)體)和引用類型(如類)的區(qū)別和使用場(chǎng)景,字符串的操作,裝箱拆箱操作等,這些在CLR Via C# 這本書中有系統(tǒng)的描述和講解。
本文提供了一些性能優(yōu)化的建議,這些經(jīng)驗(yàn)來自于使用托管代碼重寫C# 和 VB編譯器,并以編寫C# 編譯器中的一些真實(shí)場(chǎng)景作為例子來展示這些優(yōu)化經(jīng)驗(yàn)。.NET 平臺(tái)開發(fā)應(yīng)用程序具有極高的生產(chǎn)力。.NET 平臺(tái)上強(qiáng)大安全的編程語言以及豐富的類庫,使得開發(fā)應(yīng)用變得卓有成效。但是能力越大責(zé)任越大。我們應(yīng)該使用.NET框架的強(qiáng)大能力,但同時(shí)如果我們需要處理大量的數(shù)據(jù)比如文件或者數(shù)據(jù)庫也需要準(zhǔn)備對(duì)我們的代碼進(jìn)行調(diào)優(yōu)。
為什么來自新的編譯器的性能優(yōu)化經(jīng)驗(yàn)也適用于您的應(yīng)用程序
微軟使用托管代碼重寫了C#和Visual Basic的編譯器,并提供了一些列新的API來進(jìn)行代碼建模和分析、開發(fā)編譯工具,使得Visual Studio具有更加豐富的代碼感知的編程體驗(yàn)。重寫編譯器,并且在新的編譯器上開發(fā)Visual Studio的經(jīng)驗(yàn)使得我們獲得了非常有用的性能優(yōu)化經(jīng)驗(yàn),這些經(jīng)驗(yàn)也能用于大型的.NET應(yīng)用,或者一些需要處理大量數(shù)據(jù)的APP上。你不需要了解編譯器,也能夠從C#編譯器的例子中得出這些見解。
Visual Studio使用了編譯器的API來實(shí)現(xiàn)了強(qiáng)大的智能感知(Intellisense)功能,如代碼關(guān)鍵字著色,語法填充列表,錯(cuò)誤波浪線提示,參數(shù)提示,代碼問題及修改建議等,這些功能深受開發(fā)者歡迎。Visual Studio在開發(fā)者輸入或者修改代碼的時(shí)候,會(huì)動(dòng)態(tài)的編譯代碼來獲得對(duì)代碼的分析和提示。
當(dāng)用戶和App進(jìn)行交互的時(shí)候,通常希望軟件具有好的響應(yīng)性。輸入或者執(zhí)行命令的時(shí)候,應(yīng)用程序界面不應(yīng)該被阻塞。幫助或者提示能夠迅速顯示出來或者當(dāng)用戶繼續(xù)輸入的時(shí)候停止提示。現(xiàn)在的App應(yīng)該避免在執(zhí)行長(zhǎng)時(shí)間計(jì)算的時(shí)候阻塞UI線程從而讓用戶感覺程序不夠流暢。
想了解更多關(guān)于新的編譯器的信息,可以訪問 .NET Compiler Platform ("Roslyn")
基本要領(lǐng)
在對(duì).NET 進(jìn)行性能調(diào)優(yōu)以及開發(fā)具有良好響應(yīng)性的應(yīng)用程序的時(shí)候,請(qǐng)考慮以下這些基本要領(lǐng):
要領(lǐng)一:不要過早優(yōu)化
編寫代碼比想象中的要復(fù)雜的多,代碼需要維護(hù),調(diào)試及優(yōu)化性能。 一個(gè)有經(jīng)驗(yàn)的程序員,通常會(huì)對(duì)自然而然的提出解決問題的方法并編寫高效的代碼。 但是有時(shí)候也可能會(huì)陷入過早優(yōu)化代碼的問題中。比如,有時(shí)候使用一個(gè)簡(jiǎn)單的數(shù)組就夠了,非要優(yōu)化成使用哈希表,有時(shí)候簡(jiǎn)單的重新計(jì)算一下可以,非要使用復(fù)雜的可能導(dǎo)致內(nèi)存泄漏的緩存。發(fā)現(xiàn)問題時(shí),應(yīng)該首先測(cè)試性能問題然后再分析代碼。
要領(lǐng)二:沒有評(píng)測(cè),便是猜測(cè)
剖析和測(cè)量不會(huì)撒謊。測(cè)評(píng)可以顯示CPU是否滿負(fù)荷運(yùn)轉(zhuǎn)或者是存在磁盤I/O阻塞。測(cè)評(píng)會(huì)告訴你應(yīng)用程序分配了什么樣的以及多大的內(nèi)存,以及是否CPU花費(fèi)了很多時(shí)間在垃圾回收上。
應(yīng)該為關(guān)鍵的用戶體驗(yàn)或者場(chǎng)景設(shè)置性能目標(biāo),并且編寫測(cè)試來測(cè)量性能。通過使用科學(xué)的方法來分析性能不達(dá)標(biāo)的原因的步驟如下:使用測(cè)評(píng)報(bào)告來指導(dǎo),假設(shè)可能出現(xiàn)的情況,并且編寫實(shí)驗(yàn)代碼或者修改代碼來驗(yàn)證我們的假設(shè)或者修正。如果我們?cè)O(shè)置了基本的性能指標(biāo)并且經(jīng)常測(cè)試,就能夠避免一些改變導(dǎo)致性能的回退(regression),這樣就能夠避免我們浪費(fèi)時(shí)間在一些不必要的改動(dòng)中。
要領(lǐng)三:好工具很重要
好的工具能夠讓我們能夠快速的定位到影響性能的***因素(CPU,內(nèi)存,磁盤)并且能夠幫助我們定位產(chǎn)生這些瓶頸的代碼。微軟已經(jīng)發(fā)布了很多性能測(cè)試工具比如:Visual Studio Profiler, Windows Phone Analysis Tool, 以及 PerfView.
PerfView是一款免費(fèi)且性能強(qiáng)大的工具,他主要關(guān)注影響性能的一些深層次的問題(磁盤 I/O,GC 事件,內(nèi)存),后面會(huì)展示這方面的例子。我們能夠抓取性能相關(guān)的 Event Tracing for Windows(ETW)事件并能以應(yīng)用程序,進(jìn)程,堆棧,線程的尺度查看這些信息。PerfView能夠展示應(yīng)用程序分配了多少,以及分配了何種內(nèi)存以及應(yīng)用程序中的函數(shù)以及調(diào)用堆棧對(duì)內(nèi)存分配的貢獻(xiàn)。這些方面的細(xì)節(jié),您可以查看隨工具下載發(fā)布的關(guān)于PerfView的非常詳細(xì)的幫助,Demo以及視頻教程(比如Channel9 上的視頻教程)
要領(lǐng)四:所有的都與內(nèi)存分配相關(guān)
你可能會(huì)想,編寫響應(yīng)及時(shí)的基于.NET的應(yīng)用程序關(guān)鍵在于采用好的算法,比如使用快速排序替代冒泡排序,但是實(shí)際情況并不是這樣。編寫一個(gè)響應(yīng)良好的app的***因素在于內(nèi)存分配,特別是當(dāng)app非常大或者處理大量數(shù)據(jù)的時(shí)候。
在使用新的編譯器API開發(fā)響應(yīng)良好的IDE的實(shí)踐中,大部分工作都花在了如何避免開辟內(nèi)存以及管理緩存策略。PerfView追蹤顯示新的C# 和VB編譯器的性能基本上和CPU的性能瓶頸沒有關(guān)系。編譯器在讀入成百上千甚至上萬行代碼,讀入元數(shù)據(jù)活著產(chǎn)生編譯好的代碼,這些操作其實(shí)都是I/O bound 密集型。UI線程的延遲幾乎全部都是由于垃圾回收導(dǎo)致的。.NET框架對(duì)垃圾回收的性能已經(jīng)進(jìn)行過高度優(yōu)化,他能夠在應(yīng)用程序代碼執(zhí)行的時(shí)候并行的執(zhí)行垃圾回收的大部分操作。但是,單個(gè)內(nèi)存分配操作有可能會(huì)觸發(fā)一次昂貴的垃圾回收操作,這樣GC會(huì)暫時(shí)掛起所有線程來進(jìn)行垃圾回收(比如 Generation 2型的垃圾回收)
常見的內(nèi)存分配以及例子
這部分的例子雖然背后關(guān)于內(nèi)存分配的地方很少。但是,如果一個(gè)大的應(yīng)用程序執(zhí)行足夠多的這些小的會(huì)導(dǎo)致內(nèi)存分配的表達(dá)式,那么這些表達(dá)式會(huì)導(dǎo)致幾百M(fèi),甚至幾G的內(nèi)存分配。比如,在性能測(cè)試團(tuán)隊(duì)把問題定位到輸入場(chǎng)景之前,一分鐘的測(cè)試模擬開發(fā)者在編譯器里面編寫代碼會(huì)分配幾G的內(nèi)存。
裝箱
裝箱發(fā)生在當(dāng)通常分配在線程棧上或者數(shù)據(jù)結(jié)構(gòu)中的值類型,或者臨時(shí)的值需要被包裝到對(duì)象中的時(shí)候(比如分配一個(gè)對(duì)象來存放數(shù)據(jù),活著返回一個(gè)指針給一個(gè)Object對(duì)象)。.NET框架由于方法的簽名或者類型的分配位置,有些時(shí)候會(huì)自動(dòng)對(duì)值類型進(jìn)行裝箱。將值類型包裝為引用類型會(huì)產(chǎn)生內(nèi)存分配。.NET框架及語言會(huì)盡量避免不必要的裝箱,但是有時(shí)候在我們沒有注意到的時(shí)候會(huì)產(chǎn)生裝箱操作。過多的裝箱操作會(huì)在應(yīng)用程序中分配成M上G的內(nèi)存,這就意味著垃圾回收的更加頻繁,也會(huì)花更長(zhǎng)時(shí)間。
在PerfView中查看裝箱操作,只需要開啟一個(gè)追蹤(trace),然后查看應(yīng)用程序名字下面的GC Heap Alloc 項(xiàng)(記住,PerfView會(huì)報(bào)告所有的進(jìn)程的資源分配情況),如果在分配相中看到了一些諸如System.Int32和System.Char的值類型,那么就發(fā)生了裝箱。選擇一個(gè)類型,就會(huì)顯示調(diào)用棧以及發(fā)生裝箱的操作的函數(shù)。
例1 string方法和其值類型參數(shù)
下面的示例代碼演示了潛在的不必要的裝箱以及在大的系統(tǒng)中的頻繁的裝箱操作。
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); } }
這是一個(gè)日志基礎(chǔ)類,因此app會(huì)很頻繁的調(diào)用Log函數(shù)來記日志,可能該方法會(huì)被調(diào)用millons次。問題在于,調(diào)用string.Format方法會(huì)調(diào)用其重載的接受一個(gè)string類型和兩個(gè)Object類型的方法:
String.Format Method (String, Object, Object)
該重載方法要求.NET Framework 把int型裝箱為object類型然后將它傳到方法調(diào)用中去。為了解決這一問題,方法就是調(diào)用id.ToString()和size.ToString()方法,然后傳入到string.Format 方法中去,調(diào)用ToString()方法的確會(huì)導(dǎo)致一個(gè)string的分配,但是在string.Format方法內(nèi)部不論怎樣都會(huì)產(chǎn)生string類型的分配。
你可能會(huì)認(rèn)為這個(gè)基本的調(diào)用string.Format 僅僅是字符串的拼接,所以你可能會(huì)寫出這樣的代碼:
var s = id.ToString() + ':' + size.ToString();
實(shí)際上,上面這行代碼也會(huì)導(dǎo)致裝箱,因?yàn)樯厦娴恼Z句在編譯的時(shí)候會(huì)調(diào)用:
string.Concat(Object, Object, Object);
這個(gè)方法,.NET Framework 必須對(duì)字符常量進(jìn)行裝箱來調(diào)用Concat方法。
解決方法:
完全修復(fù)這個(gè)問題很簡(jiǎn)單,將上面的單引號(hào)替換為雙引號(hào)即將字符常量換為字符串常量就可以避免裝箱,因?yàn)閟tring類型的已經(jīng)是引用類型了。
var s = id.ToString() + ":" + size.ToString();
例2 枚舉類型的裝箱
下面的這個(gè)例子是導(dǎo)致新的C# 和VB編譯器由于頻繁的使用枚舉類型,特別是在Dictionary中做查找操作時(shí)分配了大量?jī)?nèi)存的原因。
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會(huì)告訴你enmu.GetHashCode()由于內(nèi)部實(shí)現(xiàn)的原因產(chǎn)生了裝箱操作,該方法會(huì)在底層枚舉類型的表現(xiàn)形式上進(jìn)行裝箱,如果仔細(xì)看PerfView,會(huì)看到每次調(diào)用GetHashCode會(huì)產(chǎn)生兩次裝箱操作。編譯器插入一次,.NET Framework插入另外一次。
解決方法:
通過在調(diào)用GetHashCode的時(shí)候?qū)⒚杜e的底層表現(xiàn)形式進(jìn)行強(qiáng)制類型轉(zhuǎn)換就可以避免這一裝箱操作。
((int)color).GetHashCode()
另一個(gè)使用枚舉類型經(jīng)常產(chǎn)生裝箱的操作時(shí)enum.HasFlag。傳給HasFlag的參數(shù)必須進(jìn)行裝箱,在大多數(shù)情況下,反復(fù)調(diào)用HasFlag通過位運(yùn)算測(cè)試非常簡(jiǎn)單和不需要分配內(nèi)存。
要牢記基本要領(lǐng)***條,不要過早優(yōu)化。并且不要過早的開始重寫所有代碼。 需要注意到這些裝箱的耗費(fèi),只有在通過工具找到并且定位到最主要問題所在再開始修改代碼。
字符串
字符串操作是引起內(nèi)存分配的***元兇之一,通常在PerfView中占到前五導(dǎo)致內(nèi)存分配的原因。應(yīng)用程序使用字符串來進(jìn)行序列化,表示JSON和REST。在不支持枚舉類型的情況下,字符串可以用來與其他系統(tǒng)進(jìn)行交互。當(dāng)我們定位到是由于string操作導(dǎo)致對(duì)性能產(chǎn)生嚴(yán)重影響的時(shí)候,需要留意string類的Format(),Concat(),Split(),Join(),Substring()等這些方法。使用StringBuilder能夠避免在拼接多個(gè)字符串時(shí)創(chuàng)建多個(gè)新字符串的開銷,但是StringBuilder的創(chuàng)建也需要進(jìn)行良好的控制以避免可能會(huì)產(chǎn)生的性能瓶頸。
例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 { /* ... */ } }
可以看到,在這片代碼中包含有很多字符串操作。代碼中使用類庫方法來將行分割為字符串,來去除空格,來檢查參數(shù)text是否是XML文檔格式的注釋,然后從行中取出字符串處理。
在WriteFormattedDocComment方法每次被調(diào)用時(shí),***行代碼調(diào)用Split()就會(huì)分配三個(gè)元素的字符串?dāng)?shù)組。編譯器也需要產(chǎn)生代碼來分配這個(gè)數(shù)組。因?yàn)榫幾g器并不知道,如果Splite()存儲(chǔ)了這一數(shù)組,那么其他部分的代碼有可能會(huì)改變這個(gè)數(shù)組,這樣就會(huì)影響到后面對(duì)WriteFormattedDocComment方法的調(diào)用。每次調(diào)用Splite()方法也會(huì)為參數(shù)text分配一個(gè)string,然后在分配其他內(nèi)存來執(zhí)行splite操作。
WriteFormattedDocComment方法中調(diào)用了三次TrimStart()方法,在內(nèi)存環(huán)中調(diào)用了兩次,這些都是重復(fù)的工作和內(nèi)存分配。更糟糕的是,TrimStart()的無參重載方法的簽名如下:
namespace System { public class String { public string TrimStart(params char[] trimChars); } }
該方法簽名意味著,每次對(duì)TrimStart()的調(diào)用都回分配一個(gè)空的數(shù)組以及返回一個(gè)string類型的結(jié)果。
***,調(diào)用了一次Substring()方法,這個(gè)方法通常會(huì)導(dǎo)致在內(nèi)存中分配新的字符串。
解決方法:
和前面的只需要小小的修改即可解決內(nèi)存分配的問題不同。在這個(gè)例子中,我們需要從頭看,查看問題然后采用不同的方法解決。比如,可以意識(shí)到WriteFormattedDocComment()方法的參數(shù)是一個(gè)字符串,它包含了方法中需要的所有信息,因此,代碼只需要做更多的index操作,而不是分配那么多小的string片段。
下面的方法并沒有完全解,但是可以看到如何使用類似的技巧來解決本例中存在的問題。C#編譯器使用如下的方式來消除所有的額外內(nèi)存分配。
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() 方法的***個(gè)版本分配了一個(gè)數(shù)組,幾個(gè)子字符串,一個(gè)trim后的子字符串,以及一個(gè)空的params數(shù)組。也檢查了”///”。修改后的代碼僅使用了index操作,沒有任何額外的內(nèi)存分配。它查找***個(gè)非空格的字符串,然后逐個(gè)字符串比較來查看是否以”///”開頭。和使用TrimStart()不同,修改后的代碼使用IndexOfFirstNonWhiteSpaceChar方法來返回***個(gè)非空格的開始位置,通過使用這種方法,可以移除WriteFormattedDocComment()方法中的所有額外內(nèi)存分配。
例4 StringBuilder
本例中使用StringBuilder。下面的函數(shù)用來產(chǎn)生泛型類型的全名:
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實(shí)例的創(chuàng)建上來。代碼中調(diào)用sb.ToString()會(huì)導(dǎo)致一次內(nèi)存分配。在StringBuilder中的內(nèi)部實(shí)現(xiàn)也會(huì)導(dǎo)致內(nèi)部?jī)?nèi)存分配,但是我們?nèi)绻胍@取到string類型的結(jié)果化,這些分配無法避免。
解決方法:
要解決StringBuilder對(duì)象的分配就使用緩存。即使緩存一個(gè)可能被隨時(shí)丟棄的單個(gè)實(shí)例對(duì)象也能夠顯著的提高程序性能。下面是該函數(shù)的新的實(shí)現(xiàn)。除了下面兩行代碼,其他代碼均相同
// 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); }
關(guān)鍵部分在于新的 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; }
上面方法實(shí)現(xiàn)中使用了thread-static字段來緩存StringBuilder對(duì)象,這是由于新的編譯器使用了多線程的原因。很可能會(huì)忘掉這個(gè)ThreadStatic聲明。Thread-static字符為每個(gè)執(zhí)行這部分的代碼的線程保留一個(gè)唯一的實(shí)例。
如果已經(jīng)有了一個(gè)實(shí)例,那么AcquireBuilder()方法直接返回該緩存的實(shí)例,在清空后,將該字段或者緩存設(shè)置為null。否則AcquireBuilder()創(chuàng)建一個(gè)新的實(shí)例并返回,然后將字段和cache設(shè)置為null 。
當(dāng)我們對(duì)StringBuilder處理完成之后,調(diào)用GetStringAndReleaseBuilder()方法即可獲取string結(jié)果。然后將StringBuilder保存到字段中或者緩存起來,然后返回結(jié)果。這段代碼很可能重復(fù)執(zhí)行,從而創(chuàng)建多個(gè)StringBuilder對(duì)象,雖然很少會(huì)發(fā)生。代碼中僅保存***被釋放的那個(gè)StringBuilder對(duì)象來留作后用。新的編譯器中,這種簡(jiǎn)單的的緩存策略極大地減少了不必要的內(nèi)存分配。.NET Framework 和MSBuild 中的部分模塊也使用了類似的技術(shù)來提升性能。
簡(jiǎn)單的緩存策略必須遵循良好的緩存設(shè)計(jì),因?yàn)樗写笮〉南拗芻ap。使用緩存可能比之前有更多的代碼,也需要更多的維護(hù)工作。我們只有在發(fā)現(xiàn)這是個(gè)問題之后才應(yīng)該采緩存策略。PerfView已經(jīng)顯示出StringBuilder對(duì)內(nèi)存的分配貢獻(xiàn)相當(dāng)大。
#p#
LINQ和Lambdas表達(dá)式
使用LINQ 和Lambdas表達(dá)式是C#語言強(qiáng)大生產(chǎn)力的一個(gè)很好體現(xiàn),但是如果代碼需要執(zhí)行很多次的時(shí)候,可能需要對(duì)LINQ或者Lambdas表達(dá)式進(jìn)行重寫。
例5 Lambdas表達(dá)式,List<T>,以及IEnumerable<T>
下面的例子使用LINQ以及函數(shù)式風(fēng)格的代碼來通過編譯器模型給定的名稱來查找符號(hào)。
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 體驗(yàn)基于調(diào)用FindMatchingSymbol,這個(gè)調(diào)用非常頻繁,在此過程中,這么簡(jiǎn)單的一行代碼隱藏了基礎(chǔ)內(nèi)存分配開銷。為了展示這其中的分配,我們首先將該單行函數(shù)拆分為兩行:
Func<Symbol, bool> predicate = s => s.Name == name; return symbols.FirstOrDefault(predicate);
***行中,lambda表達(dá)式 “s=>s.Name==name” 是對(duì)本地變量name的一個(gè)閉包。這就意味著需要分配額外的對(duì)象來為委托對(duì)象predict分配空間,需要一個(gè)分配一個(gè)靜態(tài)類來保存環(huán)境從而保存name的值。編譯器會(huì)產(chǎn)生如下代碼:
// 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);
兩個(gè)new操作符(***個(gè)創(chuàng)建一個(gè)環(huán)境類,第二個(gè)用來創(chuàng)建委托)很明顯的表明了內(nèi)存分配的情況。
現(xiàn)在來看看FirstOrDefault方法的調(diào)用,他是IEnumerable<T>類的擴(kuò)展方法,這也會(huì)產(chǎn)生一次內(nèi)存分配。因?yàn)镕irstOrDefault使用IEnumerable<T>作為***個(gè)參數(shù),可以將上面的展開為下面的代碼:
// 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變量是類型為L(zhǎng)ist<T>的變量。List<T>集合類型實(shí)現(xiàn)了IEnumerable<T>即可并且清晰地定義了一個(gè)迭代器,List<T>的迭代器使用了一種結(jié)構(gòu)體來實(shí)現(xiàn)。使用結(jié)構(gòu)而不是類意味著通常可以避免任何在托管堆上的分配,從而可以影響垃圾回收的效率。枚舉典型的用處在于方便語言層面上使用foreach循環(huán),他使用enumerator結(jié)構(gòu)體在調(diào)用推棧上返回。遞增調(diào)用堆棧指針來為對(duì)象分配空間,不會(huì)影響GC對(duì)托管對(duì)象的操作。
在上面的展開FirstOrDefault調(diào)用的例子中,代碼會(huì)調(diào)用IEnumerabole<T>接口中的GetEnumerator()方法。將symbols賦值給IEnumerable<Symbol>類型的enumerable 變量,會(huì)使得對(duì)象丟失了其實(shí)際的List<T>類型信息。這就意味著當(dāng)代碼通過enumerable.GetEnumerator()方法獲取迭代器時(shí),.NET Framework 必須對(duì)返回的值(即迭代器,使用結(jié)構(gòu)體實(shí)現(xiàn))類型進(jìn)行裝箱從而將其賦給IEnumerable<Symbol>類型的(引用類型) enumerator變量。
解決方法:
解決辦法是重寫FindMatchingSymbol方法,將單個(gè)語句使用六行代碼替代,這些代碼依舊連貫,易于閱讀和理解,也很容易實(shí)現(xiàn)。
public Symbol FindMatchingSymbol(string name) { foreach (Symbol s in symbols) { if (s.Name == name) return s; } return null; }
代碼中并沒有使用LINQ擴(kuò)展方法,lambdas表達(dá)式和迭代器,并且沒有額外的內(nèi)存分配開銷。這是因?yàn)榫幾g器看到symbol 是List<T>類型的集合,因?yàn)槟軌蛑苯訉⒎祷氐慕Y(jié)構(gòu)性的枚舉器綁定到類型正確的本地變量上,從而避免了對(duì)struct類型的裝箱操作。原先的代碼展示了C#語言豐富的表現(xiàn)形式以及.NET Framework 強(qiáng)大的生產(chǎn)力。該著后的代碼則更加高效簡(jiǎn)單,并沒有添加復(fù)雜的代碼而增加可維護(hù)性。
Aync異步
接下來的例子展示了當(dāng)我們?cè)噲D緩存一部方法返回值時(shí)的一個(gè)普遍問題:
例6 緩存異步方法
Visual Studio IDE 的特性在很大程度上建立在新的C#和VB編譯器獲取語法樹的基礎(chǔ)上,當(dāng)編譯器使用async的時(shí)候仍能夠保持Visual Stuido能夠響應(yīng)。下面是獲取語法樹的***個(gè)版本的代碼:
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; } }
可以看到調(diào)用GetSyntaxTreeAsync() 方法會(huì)實(shí)例化一個(gè)Parser對(duì)象,解析代碼,然后返回一個(gè)Task<SyntaxTree>對(duì)象。最耗性能的地方在為Parser實(shí)例分配內(nèi)存并解析代碼。方法中返回一個(gè)Task對(duì)象,因此調(diào)用者可以await解析工作,然后釋放UI線程使得可以響應(yīng)用戶的輸入。
由于Visual Studio的一些特性可能需要多次獲取相同的語法樹, 所以通常可能會(huì)緩存解析結(jié)果來節(jié)省時(shí)間和內(nèi)存分配,但是下面的代碼可能會(huì)導(dǎo)致內(nèi)存分配:
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; } }
代碼中有一個(gè)SynataxTree類型的名為cachedResult的字段。當(dāng)該字段為空的時(shí)候,GetSyntaxTreeAsync()執(zhí)行,然后將結(jié)果保存在cache中。GetSyntaxTreeAsync()方法返回SyntaxTree對(duì)象。問題在于,當(dāng)有一個(gè)類型為Task<SyntaxTree> 類型的async異步方法時(shí),想要返回SyntaxTree的值,編譯器會(huì)生出代碼來分配一個(gè)Task來保存執(zhí)行結(jié)果(通過使用Task<SyntaxTree>.FromResult())。Task會(huì)標(biāo)記為完成,然后結(jié)果立馬返回。分配Task對(duì)象來存儲(chǔ)執(zhí)行的結(jié)果這個(gè)動(dòng)作調(diào)用非常頻繁,因此修復(fù)該分配問題能夠極大提高應(yīng)用程序響應(yīng)性。
解決方法:
要移除保存完成了執(zhí)行任務(wù)的分配,可以緩存Task對(duì)象來保存完成的結(jié)果。
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幫助函數(shù)來保存原始代碼中的GetSyntaxTreeAsync()函數(shù)。GetSyntaxTreeAsync函數(shù)現(xiàn)在使用 null操作符,來表示當(dāng)cachedResult不為空時(shí)直接返回,為空時(shí)GetSyntaxTreeAsync調(diào)用GetSyntaxTreeUncachedAsync()然后緩存結(jié)果。注意GetSyntaxTreeAsync并沒有await調(diào)用GetSyntaxTreeUncachedAsync。沒有使用await意味著當(dāng)GetSyntaxTreeUncachedAsync返回Task類型時(shí),GetSyntaxTreeAsync 也立即返回Task, 現(xiàn)在緩存的是Task,因此在返回緩存結(jié)果的時(shí)候沒有額外的內(nèi)存分配。
其他一些影響性能的雜項(xiàng)
在大的app或者處理大量數(shù)據(jù)的app中,還有幾點(diǎn)可能會(huì)引發(fā)潛在的性能問題。
字典
在很多應(yīng)用程序中,Dictionary用的很廣,雖然字非常方便和高校,但是經(jīng)常會(huì)使用不當(dāng)。在Visual Studio以及新的編譯器中,使用性能分析工具發(fā)現(xiàn),許多dictionay只包含有一個(gè)元素或者干脆是空的。一個(gè)空的Dictionay結(jié)構(gòu)內(nèi)部會(huì)有10個(gè)字段在x86機(jī)器上的托管堆上會(huì)占據(jù)48個(gè)字節(jié)。當(dāng)需要在做映射或者關(guān)聯(lián)數(shù)據(jù)結(jié)構(gòu)需要事先常量時(shí)間查找的時(shí)候,字典非常有用。但是當(dāng)只有幾個(gè)元素,使用字典就會(huì)浪費(fèi)大量?jī)?nèi)存空間。相反,我們可以使用List<KeyValuePair<K,V>>結(jié)構(gòu)來實(shí)現(xiàn)便利,對(duì)于少量元素來說,同樣高校。如果僅僅使用字典來加載數(shù)據(jù),然后讀取數(shù)據(jù),那么使用一個(gè)具有N(log(N))的查找效率的有序數(shù)組,在速度上也會(huì)很快,當(dāng)然這些都取決于的元素的個(gè)數(shù)。
類和結(jié)構(gòu)
不甚嚴(yán)格的講,在優(yōu)化應(yīng)用程序方面,類和結(jié)構(gòu)提供了一種經(jīng)典的空間/時(shí)間的權(quán)衡(trade off)。在x86機(jī)器上,每個(gè)類即使沒有任何字段,也會(huì)分配12 byte的空間 (譯注:來保存類型對(duì)象指針和同步索引塊),但是將類作為方法之間參數(shù)傳遞的時(shí)候卻十分高效廉價(jià),因?yàn)橹恍枰獋鬟f指向類型實(shí)例的指針即可。結(jié)構(gòu)體如果不撞向的話,不會(huì)再托管堆上產(chǎn)生任何內(nèi)存分配,但是當(dāng)將一個(gè)比較大的結(jié)構(gòu)體作為方法參數(shù)或者返回值得時(shí)候,需要CPU時(shí)間來自動(dòng)復(fù)制和拷貝結(jié)構(gòu)體,然后將結(jié)構(gòu)體的屬性緩存到本地便兩種以避免過多的數(shù)據(jù)拷貝。
緩存
性能優(yōu)化的一個(gè)常用技巧是緩存結(jié)果。但是如果緩存沒有大小上限或者良好的資源釋放機(jī)制就會(huì)導(dǎo)致內(nèi)存泄漏。在處理大數(shù)據(jù)量的時(shí)候,如果在緩存中緩存了過多數(shù)據(jù)就會(huì)占用大量?jī)?nèi)存,這樣導(dǎo)致的垃圾回收開銷就會(huì)超過在緩存中查找結(jié)果所帶來的好處。
結(jié)論
在大的系統(tǒng),或者或者需要處理大量數(shù)據(jù)的系統(tǒng)中,我們需要關(guān)注產(chǎn)生性能瓶頸癥狀,這些問題再規(guī)模上會(huì)影響app的響應(yīng)性,如裝箱操作、字符串操作、LINQ和Lambda表達(dá)式、緩存async方法、緩存缺少大小限制以及良好的資源釋放策略、使用Dictionay不當(dāng)、以及到處傳遞結(jié)構(gòu)體等。在優(yōu)化我們的應(yīng)用程序的時(shí)候,需要時(shí)刻注意之前提到過的四點(diǎn):
- 不要進(jìn)行過早優(yōu)化——在定位和發(fā)現(xiàn)問題之后再進(jìn)行調(diào)優(yōu)。
- 專業(yè)測(cè)試不會(huì)說謊——沒有評(píng)測(cè),便是猜測(cè)。
- 好工具很重要。——下載PerfView,然后去看使用教程。
- 內(nèi)存分配決定app的響應(yīng)性。——這也是新的編譯器性能團(tuán)隊(duì)花的時(shí)間最多的地方。
參考資料
- 如果想觀看關(guān)于這一話題的演講,可以在Channel 9 上觀看。
- VS Profiler基礎(chǔ) http://msdn.microsoft.com/en-us/library/ms182372.aspx
- .NET 英語程序性能分析工具一覽 http://msdn.microsoft.com/en-us/library/hh156536.aspx
- Windows Phone性能分析工具http://msdn.microsoft.com/en-us/magazine/hh781024.aspx
- 一些C# 和VB性能優(yōu)化建議 http://msdn.microsoft.com/en-us/library/ms173196(v=vs.110).aspx (注:原文中該鏈接無內(nèi)容,連接地址應(yīng)該使http://msdn.microsoft.com/en-us/library/ms173196(v=vs.100).aspx )
- 一些高級(jí)優(yōu)化建議 http://curah.microsoft.com/4604/improving-your-net-apps-startup-performance
--------------------------------------------------------------------------
以上就是這篇文章的全部?jī)?nèi)容,很多東西其實(shí)都很基礎(chǔ),比如值類型(如結(jié)構(gòu)體)和引用類型(如類)的區(qū)別和使用場(chǎng)景,字符串的操作,裝箱拆箱操作等,這些在CLR Via C# 這本書中有系統(tǒng)的描述和講解。這里面特別需要強(qiáng)調(diào)的是很多時(shí)候我們并沒有意識(shí)到發(fā)生了裝箱操作,比如文中提到的枚舉類型獲取HashCode會(huì)導(dǎo)致裝箱,和這個(gè)相同的一個(gè)問題是,通常在我們將值類型作為Dictionay的key的時(shí)候,Dictionay在內(nèi)部實(shí)現(xiàn)會(huì)調(diào)用key的GetHashCode方法獲取哈希值進(jìn)行散列,默認(rèn)方法就會(huì)導(dǎo)致裝箱操作,之前面試的時(shí)候我也被問到過這個(gè)問題,在很早之前老趙寫過一篇 防止裝箱落實(shí)到底,只做一半也是失敗 就專門討論過這一問題,所以在寫代碼的時(shí)候需要格外注意。
微軟使用托管語言重寫了C# 和Visual Basic編譯器,并取得了比之前的編譯器更好的效果,更重要的是該編譯器已經(jīng)開源,VS的很多強(qiáng)大的功能正是建立在該編譯器的某些編譯和分析結(jié)果之上。這正是編譯器即服務(wù)的體現(xiàn),即“傳統(tǒng)的編譯器像是一個(gè)黑盒,你在一端輸入代碼,而另一端便會(huì)生成.NET程序集或是對(duì)象代碼等等。而這個(gè)黑盒卻很神秘,你目前很難參與或理解它的工作。”現(xiàn)在編譯器開源了,我們可以直接利用其中間生成的一些分析結(jié)果來為實(shí)現(xiàn)一些功能,比如C# Interactive (有時(shí)也稱為REPL,即Read-Eval-Print-Loop)。厲害的話,可以重寫一個(gè)簡(jiǎn)單的Visual Studio了。
文章的作者從使用托管語言編寫C# 和 Visual Baisc編譯器中的性能優(yōu)化實(shí)踐講解了性能優(yōu)化的一些思考和建議,在很多方面,比如StringBuilder分配開銷,async函數(shù)返回值的緩存,LINQ和Lambda表達(dá)式產(chǎn)生的額外內(nèi)存分配方面令人印象深刻。還有一個(gè)很重要的方面就是不要盲目的沒有根據(jù)的優(yōu)化,首先定位和查找到造成產(chǎn)生性能問題的原因點(diǎn)最重要,以前我也經(jīng)常使用CLR Profile, VS Profile以及dotTrace查看和分析應(yīng)用程序的性能,文中提到了的PerfView工具是微軟內(nèi)部.NET Runtime團(tuán)隊(duì)使用的,能夠看到一般工具不能提供的信息,功能很強(qiáng)大,在Channel9 上有該工具如何使用的詳細(xì)介紹。后面會(huì)簡(jiǎn)單介紹下該工具如何使用。
希望本文對(duì)您在優(yōu)化.NET 應(yīng)用程序的性能方面有所幫助。
文章出自: 寒江獨(dú)釣的博客