天天看點

.NET程式的性能要領和優化建議為什麼來自新的編譯器的性能優化經驗也适用于您的應用程式基本要領常見的記憶體配置設定以及例子結論參考資料

---------------------------------------------------------------------------

本文提供了一些性能優化的建議,這些經驗來自于使用托管代碼重寫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 進行性能調優以及開發具有良好響應性的應用程式的時候,請考慮以下這些基本要領:

編寫代碼比想象中的要複雜的多,代碼需要維護,調試及優化性能。 一個有經驗的程式員,通常會對自然而然的提出解決問題的方法并編寫高效的代碼。 但是有時候也可能會陷入過早優化代碼的問題中。比如,有時候使用一個簡單的數組就夠了,非要優化成使用哈希表,有時候簡單的重新計算一下可以,非要使用複 雜的可能導緻記憶體洩漏的緩存。發現問題時,應該首先測試性能問題然後再分析代碼。

應該為關鍵的使用者體驗或者場景設定性能目标,并且編寫測試來測量性能。通過使用科學的方法來分析性能不達标的原因的步驟如下:使用測評報告來指導, 假設可能出現的情況,并且編寫實驗代碼或者修改代碼來驗證我們的假設或者修正。如果我們設定了基本的性能名額并且經常測試,就能夠避免一些改變導緻性能的 回退(regression),這樣就能夠避免我們浪費時間在一些不必要的改動中。

你可能會想,編寫響應及時的基于.net的應用程式關鍵在于采用好的算法,比如使用快速排序替代冒泡排序,但是實際情況并不是這樣。編寫一個響應良好的app的最大因素在于記憶體配置設定,特别是當app非常大或者處理大量資料的時候。

這部分的例子雖然背後關于記憶體配置設定的地方很少。但是,如果一個大的應用程式執行足夠多的這些小的會導緻記憶體配置設定的表達式,那麼這些表達式會導緻幾百 m,甚至幾g的記憶體配置設定。比如,在性能測試團隊把問題定位到輸入場景之前,一分鐘的測試模拟開發者在編譯器裡面編寫代碼會配置設定幾g的記憶體。

在perfview中檢視裝箱操作,隻需要開啟一個追蹤(trace),然後檢視應用程式名字下面的gc heap alloc 項(記住,perfview會報告所有的程序的資源配置設定情況),如果在配置設定相中看到了一些諸如system.int32和system.char的值類 型,那麼就發生了裝箱。選擇一個類型,就會顯示調用棧以及發生裝箱的操作的函數。

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

該重載方法要求.net framework 把int型裝箱為object類型然後将它傳到方法調用中去。為了解決這一問題,方法就是調用id.tostring()和size.tostring()方法,然後傳入到string.format 方法中去,調用tostring()方法的确會導緻一個string的配置設定,但是在string.format方法内部不論怎樣都會産生string類型的配置設定。

你可能會認為這個基本的調用string.format 僅僅是字元串的拼接,是以你可能會寫出這樣的代碼:

實際上,上面這行代碼也會導緻裝箱,因為上面的語句在編譯的時候會調用:

這個方法,.net framework 必須對字元常量進行裝箱來調用concat方法。

解決方法:

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

下面的這個例子是導緻新的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的建立也需要進行良好的控制以避免可能會産生的性能瓶頸。

在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()方法中的所有額外記憶體配置設定。

本例中使用stringbuilder。下面的函數用來産生泛型類型的全名:

注意力集中到stringbuilder執行個體的建立上來。代碼中調用sb.tostring()會導緻一次記憶體配置設定。在stringbuilder中的内部實作也會導緻内部記憶體配置設定,但是我們如果想要擷取到string類型的結果化,這些配置設定無法避免。

要解決stringbuilder對象的配置設定就使用緩存。即使緩存一個可能被随時丢棄的單個執行個體對象也能夠顯著的提高程式性能。下面是該函數的新的實作。除了下面兩行代碼,其他代碼均相同

關鍵部分在于新的 acquirebuilder()和getstringandreleasebuilder()方法:

如果已經有了一個執行個體,那麼acquirebuilder()方法直接傳回該緩存的執行個體,在清空後,将該字段或者緩存設定為null。否則acquirebuilder()建立一個新的執行個體并傳回,然後将字段和cache設定為null 。

簡單的緩存政策必須遵循良好的緩存設計,因為他有大小的限制cap。使用緩存可能比之前有更多的代碼,也需要更多的維護工作。我們隻有在發現這是個問題之後才應該采緩存政策。perfview已經顯示出stringbuilder對記憶體的配置設定貢獻相當大。

使用linq 和lambdas表達式是c#語言強大生産力的一個很好展現,但是如果代碼需要執行很多次的時候,可能需要對linq或者lambdas表達式進行重寫。

新的編譯器和ide 體驗基于調用findmatchingsymbol,這個調用非常頻繁,在此過程中,這麼簡單的一行代碼隐藏了基礎記憶體配置設定開銷。為了展示這其中的配置設定,我們首先将該單行函數拆分為兩行:

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

現在來看看firstordefault方法的調用,他是ienumerable<t>類的擴充方法,這也會産生一次記憶體配置設定。因為firstordefault使用ienumerable<t>作為第一個參數,可以将上面的展開為下面的代碼:

在上面的展開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 強大的生産力。該着後的代碼則更加高效簡單,并沒有添加複雜的代碼而增加可維護性。

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

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對象來儲存完成的結果。

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

不要進行過早優化——在定位和發現問題之後再進行調優。

專業測試不會說謊——沒有評測,便是猜測。

記憶體配置設定決定app的響應性。——這也是新的編譯器性能團隊花的時間最多的地方。