.NET程序性能優化的基本要領


  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 進行性能調優以及開發具有良好響應性的應用程序的時候,請考慮以下這些基本要領:

  要領一:不要過早優化

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

  要領二:沒有評測,便是猜測

  剖析和測量不會撒謊。測評可以顯示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方法和其值類型參數

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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類型的方法:

1
String.Format Method (String, Object, Object)

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

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

1
var s = id.ToString() + ':' + size.ToString();

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

1
string.Concat(Object, Object, Object);

  這個方法,.NET Framework 必須對字符常量進行裝箱來調用Concat方法。

  解決方法:

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

1
var s = id.ToString() + ":" + size.ToString();

  例2 枚舉類型的裝箱

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

1
2
3
4
5
6
7
8
9
10
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的時候將枚舉的底層表現形式進行強制類型轉換就可以避免這一裝箱操作。

1
((int)color).GetHashCode()

  另一個使用枚舉類型經常產生裝箱的操作時enum.HasFlag。傳給HasFlag的參數必須進行裝箱,在大多數情況下,反復調用HasFlag通過位運算測試非常簡單和不需要分配內存。

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

  字符串

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

  例3 字符串操作

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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()的無參重載方法的簽名如下:

1
2
3
4
5
6
7
namespace System
{
     public class String
     {
         public string TrimStart(params char[] trimChars);
     }
}

  該方法簽名意味着,每次對TrimStart()的調用都回分配一個空的數組以及返回一個string類型的結果。

  最后,調用了一次Substring()方法,這個方法通常會導致在內存中分配新的字符串。

  解決方法:

  和前面的只需要小小的修改即可解決內存分配的問題不同。在這個例子中,我們需要從頭看,查看問題然后采用不同的方法解決。比如,可以意識到WriteFormattedDocComment()方法的參數是一個字符串,它包含了方法中需要的所有信息,因此,代碼只需要做更多的index操作,而不是分配那么多小的string片段。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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。下面的函數用來產生泛型類型的全名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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對象的分配就使用緩存。即使緩存一個可能被隨時丟棄的單個實例對象也能夠顯著的提高程序性能。下面是該函數的新的實現。除了下面兩行代碼,其他代碼均相同

1
2
3
4
5
6
// 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()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[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對內存的分配貢獻相當大。

LINQ和Lambdas表達式

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
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,這個調用非常頻繁,在此過程中,這么簡單的一行代碼隱藏了基礎內存分配開銷。為了展示這其中的分配,我們首先將該單行函數拆分為兩行:

1
2
Func<Symbol, bool> predicate = s => s.Name == name;
return symbols.FirstOrDefault(predicate);

  第一行中, lambda表達式s=>s.Name==name” 是對本地變量name的一個 閉包。這就意味着需要分配額外的對象來為 委托對象predict分配空間,需要一個分配一個靜態類來保存環境從而保存name的值。編譯器會產生如下代碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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>作為第一個參數,可以將上面的展開為下面的代碼:

1
2
3
4
5
6
7
8
9
// 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方法,將單個語句使用六行代碼替代,這些代碼依舊連貫,易於閱讀和理解,也很容易實現。

1
2
3
4
5
6
7
8
9
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 強大的生產力。該着后的代碼則更加高效簡單,並沒有添加復雜的代碼而增加可維護性。

 Aync異步

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

  例6 緩存異步方法

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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的一些特性可能需要多次獲取相同的語法樹, 所以通常可能會緩存解析結果來節省時間和內存分配,但是下面的代碼可能會導致內存分配:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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對象來保存完成的結果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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不當、以及到處傳遞結構體等。在優化我們的應用程序的時候,需要時刻注意之前提到過的四點:

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

 參考資料


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM