.NET程式的效能要領和優化建議

發表於2015-07-30

本文提供了一些效能優化的建議,這些經驗來自於使用託管程式碼重寫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方法和其值型別引數

下面的示例程式碼演示了潛在的不必要的裝箱以及在大的系統中的頻繁的裝箱操作。

 

這是一個日誌基礎類,因此app會很頻繁的呼叫Log函式來記日誌,可能該方法會被呼叫millons次。問題在於,呼叫string.Format方法會呼叫其過載的接受一個string型別和兩個Object型別的方法:

該過載方法要求.NET Framework 把int型裝箱為object型別然後將它傳到方法呼叫中去。為了解決這一問題,方法就是呼叫id.ToString()和size.ToString()方法,然後傳入到string.Format 方法中去,呼叫ToString()方法的確會導致一個string的分配,但是在string.Format方法內部不論怎樣都會產生string型別的分配。

你可能會認為這個基本的呼叫string.Format 僅僅是字串的拼接,所以你可能會寫出這樣的程式碼:

實際上,上面這行程式碼也會導致裝箱,因為上面的語句在編譯的時候會呼叫:

這個方法,.NET Framework 必須對字元常量進行裝箱來呼叫Concat方法。

解決方法:

完全修復這個問題很簡單,將上面的單引號替換為雙引號即將字元常量換為字串常量就可以避免裝箱,因為string型別的已經是引用型別了。

例2 列舉型別的裝箱

下面的這個例子是導致新的C# 和VB編譯器由於頻繁的使用列舉型別,特別是在Dictionary中做查詢操作時分配了大量記憶體的原因。

問題非常隱蔽,PerfView會告訴你enmu.GetHashCode()由於內部實現的原因產生了裝箱操作,該方法會在底層列舉型別的表現形式上進行裝箱,如果仔細看PerfView,會看到每次呼叫GetHashCode會產生兩次裝箱操作。編譯器插入一次,.NET Framework插入另外一次。

解決方法:

通過在呼叫GetHashCode的時候將列舉的底層表現形式進行強制型別轉換就可以避免這一裝箱操作。

另一個使用列舉型別經常產生裝箱的操作時enum.HasFlag。傳給HasFlag的引數必須進行裝箱,在大多數情況下,反覆呼叫HasFlag通過位運算測試非常簡單和不需要分配記憶體。

要牢記基本要領第一條,不要過早優化。並且不要過早的開始重寫所有程式碼。 需要注意到這些裝箱的耗費,只有在通過工具找到並且定位到最主要問題所在再開始修改程式碼。

字串

字串操作是引起記憶體分配的最大元凶之一,通常在PerfView中佔到前五導致記憶體分配的原因。應用程式使用字串來進行序列化,表示JSON和REST。在不支援列舉型別的情況下,字串可以用來與其他系統進行互動。當我們定位到是由於string操作導致對效能產生嚴重影響的時候,需要留意string類的Format(),Concat(),Split(),Join(),Substring()等這些方法。使用StringBuilder能夠避免在拼接多個字串時建立多個新字串的開銷,但是StringBuilder的建立也需要進行良好的控制以避免可能會產生的效能瓶頸。

例3 字串操作

在C#編譯器中有如下方法來輸出方法前面的xml格式的註釋。

可以看到,在這片程式碼中包含有很多字串操作。程式碼中使用類庫方法來將行分割為字串,來去除空格,來檢查引數text是否是XML文件格式的註釋,然後從行中取出字串處理。

在WriteFormattedDocComment方法每次被呼叫時,第一行程式碼呼叫Split()就會分配三個元素的字串陣列。編譯器也需要產生程式碼來分配這個陣列。因為編譯器並不知道,如果Splite()儲存了這一陣列,那麼其他部分的程式碼有可能會改變這個陣列,這樣就會影響到後面對WriteFormattedDocComment方法的呼叫。每次呼叫Splite()方法也會為引數text分配一個string,然後在分配其他記憶體來執行splite操作。

WriteFormattedDocComment方法中呼叫了三次TrimStart()方法,在記憶體環中呼叫了兩次,這些都是重複的工作和記憶體分配。更糟糕的是,TrimStart()的無參過載方法的簽名如下:

該方法簽名意味著,每次對TrimStart()的呼叫都回分配一個空的陣列以及返回一個string型別的結果。

最後,呼叫了一次Substring()方法,這個方法通常會導致在記憶體中分配新的字串。

解決方法:

和前面的只需要小小的修改即可解決記憶體分配的問題不同。在這個例子中,我們需要從頭看,檢視問題然後採用不同的方法解決。比如,可以意識到WriteFormattedDocComment()方法的引數是一個字串,它包含了方法中需要的所有資訊,因此,程式碼只需要做更多的index操作,而不是分配那麼多小的string片段。

下面的方法並沒有完全解,但是可以看到如何使用類似的技巧來解決本例中存在的問題。C#編譯器使用如下的方式來消除所有的額外記憶體分配。

WriteFormattedDocComment() 方法的第一個版本分配了一個陣列,幾個子字串,一個trim後的子字串,以及一個空的params陣列。也檢查了”///”。修改後的程式碼僅使用了index操作,沒有任何額外的記憶體分配。它查詢第一個非空格的字串,然後逐個字串比較來檢視是否以”///”開頭。和使用TrimStart()不同,修改後的程式碼使用IndexOfFirstNonWhiteSpaceChar方法來返回第一個非空格的開始位置,通過使用這種方法,可以移除WriteFormattedDocComment()方法中的所有額外記憶體分配。

例4 StringBuilder

本例中使用StringBuilder。下面的函式用來產生泛型型別的全名:

注意力集中到StringBuilder例項的建立上來。程式碼中呼叫sb.ToString()會導致一次記憶體分配。在StringBuilder中的內部實現也會導致內部記憶體分配,但是我們如果想要獲取到string型別的結果化,這些分配無法避免。

解決方法:

要解決StringBuilder物件的分配就使用快取。即使快取一個可能被隨時丟棄的單個例項物件也能夠顯著的提高程式效能。下面是該函式的新的實現。除了下面兩行程式碼,其他程式碼均相同

關鍵部分在於新的 AcquireBuilder()和GetStringAndReleaseBuilder()方法:

上面方法實現中使用了thread-static欄位來快取StringBuilder物件,這是由於新的編譯器使用了多執行緒的原因。很可能會忘掉這個ThreadStatic宣告。Thread-static字元為每個執行這部分的程式碼的執行緒保留一個唯一的例項。

如果已經有了一個例項,那麼AcquireBuilder()方法直接返回該快取的例項,在清空後,將該欄位或者快取設定為null。否則AcquireBuilder()建立一個新的例項並返回,然後將欄位和cache設定為null 。

當我們對StringBuilder處理完成之後,呼叫GetStringAndReleaseBuilder()方法即可獲取string結果。然後將StringBuilder儲存到欄位中或者快取起來,然後返回結果。這段程式碼很可能重複執行,從而建立多個StringBuilder物件,雖然很少會發生。程式碼中僅儲存最後被釋放的那個StringBuilder物件來留作後用。新的編譯器中,這種簡單的的快取策略極大地減少了不必要的記憶體分配。.NET Framework 和MSBuild 中的部分模組也使用了類似的技術來提升效能。

簡單的快取策略必須遵循良好的快取設計,因為他有大小的限制cap。使用快取可能比之前有更多的程式碼,也需要更多的維護工作。我們只有在發現這是個問題之後才應該採快取策略。PerfView已經顯示出StringBuilder對記憶體的分配貢獻相當大。

LINQ和Lambdas表示式

使用LINQ 和Lambdas表示式是C#語言強大生產力的一個很好體現,但是如果程式碼需要執行很多次的時候,可能需要對LINQ或者Lambdas表示式進行重寫。

例5 Lambdas表示式,List<T>,以及IEnumerable<T>

下面的例子使用LINQ以及函式式風格的程式碼來通過編譯器模型給定的名稱來查詢符號。

新的編譯器和IDE 體驗基於呼叫FindMatchingSymbol,這個呼叫非常頻繁,在此過程中,這麼簡單的一行程式碼隱藏了基礎記憶體分配開銷。為了展示這其中的分配,我們首先將該單行函式拆分為兩行:

第一行中,lambda表示式 “s=>s.Name==name” 是對本地變數name的一個閉包。這就意味著需要分配額外的物件來為委託物件predict分配空間,需要一個分配一個靜態類來儲存環境從而儲存name的值。編譯器會產生如下程式碼:

兩個new操作符(第一個建立一個環境類,第二個用來建立委託)很明顯的表明了記憶體分配的情況。

現在來看看FirstOrDefault方法的呼叫,他是IEnumerable<T>類的擴充套件方法,這也會產生一次記憶體分配。因為FirstOrDefault使用IEnumerable<T>作為第一個引數,可以將上面的展開為下面的程式碼:

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方法,將單個語句使用六行程式碼替代,這些程式碼依舊連貫,易於閱讀和理解,也很容易實現。

程式碼中並沒有使用LINQ擴充套件方法,lambdas表示式和迭代器,並且沒有額外的記憶體分配開銷。這是因為編譯器看到symbol 是List<T>型別的集合,因為能夠直接將返回的結構性的列舉器繫結到型別正確的本地變數上,從而避免了對struct型別的裝箱操作。原先的程式碼展示了C#語言豐富的表現形式以及.NET Framework 強大的生產力。該著後的程式碼則更加高效簡單,並沒有新增複雜的程式碼而增加可維護性。

Aync非同步

接下來的例子展示了當我們試圖快取一部方法返回值時的一個普遍問題:

例6 快取非同步方法

Visual Studio IDE 的特性在很大程度上建立在新的C#和VB編譯器獲取語法樹的基礎上,當編譯器使用async的時候仍能夠保持Visual Stuido能夠響應。下面是獲取語法樹的第一個版本的程式碼:

可以看到呼叫GetSyntaxTreeAsync() 方法會例項化一個Parser物件,解析程式碼,然後返回一個Task<SyntaxTree>物件。最耗效能的地方在為Parser例項分配記憶體並解析程式碼。方法中返回一個Task物件,因此呼叫者可以await解析工作,然後釋放UI執行緒使得可以響應使用者的輸入。

由於Visual Studio的一些特性可能需要多次獲取相同的語法樹, 所以通常可能會快取解析結果來節省時間和記憶體分配,但是下面的程式碼可能會導致記憶體分配:

程式碼中有一個SynataxTree型別的名為cachedResult的欄位。當該欄位為空的時候,GetSyntaxTreeAsync()執行,然後將結果儲存在cache中。GetSyntaxTreeAsync()方法返回SyntaxTree物件。問題在於,當有一個型別為Task<SyntaxTree> 型別的async非同步方法時,想要返回SyntaxTree的值,編譯器會生出程式碼來分配一個Task來儲存執行結果(通過使用Task<SyntaxTree>.FromResult())。Task會標記為完成,然後結果立馬返回。分配Task物件來儲存執行的結果這個動作呼叫非常頻繁,因此修復該分配問題能夠極大提高應用程式響應性。

解決方法:

要移除儲存完成了執行任務的分配,可以快取Task物件來儲存完成的結果。

程式碼將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不當、以及到處傳遞結構體等。在優化我們的應用程式的時候,需要時刻注意之前提到過的四點:

  1. 不要進行過早優化——在定位和發現問題之後再進行調優。
  2. 專業測試不會說謊——沒有評測,便是猜測。
  3. 好工具很重要。——下載PerfView,然後去看使用教程。
  4. 記憶體分配決定app的響應性。——這也是新的編譯器效能團隊花的時間最多的地方。

 

參考資料

————————————————————————–

以上就是這篇文章的全部內容,很多東西其實都很基礎,比如值型別(如結構體)和引用型別(如類)的區別和使用場景,字串的操作,裝箱拆箱操作等,這些在CLR Via C# 這本書中有系統的描述和講解。這裡面特別需要強調的是很多時候我們並沒有意識到發生了裝箱操作,比如文中提到的列舉型別獲取HashCode會導致裝箱,和這個相同的一個問題是,通常在我們將值型別作為Dictionaykey的時候,Dictionay在內部實現會呼叫keyGetHashCode方法獲取雜湊值進行雜湊,預設方法就會導致裝箱操作,之前面試的時候我也被問到過這個問題,在很早之前老趙寫過一篇 防止裝箱落實到底,只做一半也是失敗 就專門討論過這一問題,所以在寫程式碼的時候需要格外注意。

微軟使用託管語言重寫了C# 和Visual Basic編譯器,並取得了比之前的編譯器更好的效果,更重要的是該編譯器已經開源,VS的很多強大的功能正是建立在該編譯器的某些編譯和分析結果之上。這正是編譯器即服務的體現,即“傳統的編譯器像是一個黑盒,你在一端輸入程式碼,而另一端便會生成.NET程式集或是物件程式碼等等。而這個黑盒卻很神祕,你目前很難參與或理解它的工作。”現在編譯器開源了,我們可以直接利用其中間生成的一些分析結果來為實現一些功能,比如C# Interactive (有時也稱為REPL,即Read-Eval-Print-Loop)。厲害的話,可以重寫一個簡單的Visual Studio了。

文章的作者從使用託管語言編寫C# 和 Visual Baisc編譯器中的效能優化實踐講解了效能優化的一些思考和建議,在很多方面,比如StringBuilder分配開銷,async函式返回值的快取,LINQLambda表示式產生的額外記憶體分配方面令人印象深刻。還有一個很重要的方面就是不要盲目的沒有根據的優化,首先定位和查詢到造成產生效能問題的原因點最重要,以前我也經常使用CLR ProfileVS Profile以及dotTrace檢視和分析應用程式的效能,文中提到了的PerfView工具是微軟內部.NET Runtime團隊使用的,能夠看到一般工具不能提供的資訊,功能很強大,在Channel9 上有該工具如何使用的詳細介紹。後面會簡單介紹下該工具如何使用。

希望本文對您在優化.NET 應用程式的效能方面有所幫助。

相關文章