Async 和 Await的性能(.NET4.5新異步編程模型)

異步編程長時間以來一直都是那些技能高超、喜歡挑戰自個人開發人員涉足的領域 — 這些人願意花費時間,充滿熱情並擁有心理承受能力,可以在非線性的控制流程中不斷地琢磨回調,以後再回調。 隨着 Microsoft .NET Framework 4.5 的推出,C# 和 Visual Basic 讓咱們其餘人也能處理異步工做,普通程序員也能夠像編寫同步方法同樣輕鬆編寫異步方法。 再也不使用回調。 再也不須要將代碼從一個同步環境顯式封送到另外一個同步環境。 再也不須要擔憂結果或異常的流動。 再也不須要千方百計地改造現有語言功能而簡化異步開發。 簡言之,沒有麻煩了。程序員

固然,如今很容易開始編寫異步方法(請參閱本期 MSDN 雜誌中 Eric Lippert 和 Mads Torgersen 的文章),可是想要真正作好,仍然須要瞭解後臺的工做原理。 不管什麼時候,當某種語言或框架提升了開發人員能夠編程的抽象級別時,也老是隱含了不可見的性能成本。 在許多狀況下,這些成本是微不足道,要實現大量方案的廣大開發人員能夠也應該忽略它們。 然而,對於更高級的開發人員來講,仍是有必要去真正瞭解都有哪些成本,若是這些成本最終成爲阻礙,咱們可以採起必要的步驟予以免。 對於 C# 和 Visual Basic 中的異步方法功能,就存在這種狀況。編程

在本文中,我將探討異步方法的各類細節,使您可以全面瞭解異步方法的內在實現方式,並討論其中涉及的其餘一些略有區別的成本。 請注意,我要傳達的信息並非要鼓勵您以追求微優化和性能的名義,將可讀代碼改形成不可維護的代碼。 它只是爲提供您一些信息,幫助您診斷可能遇到的問題,並提供一套幫助您解決此類潛在問題的工具。 另請注意,本文以 .NET Framework 4.5 的預覽版本爲基礎,具體實施細節可能在最終版本發佈以前有所變更。數組

得到正確的思惟模式

幾十年來,開發人員一直使用 C#、Visual Basic、F# 和 C++ 等高級語言開發高效的應用程序。 這方面的經驗使得開發人員瞭解了各類操做的相關成本,這種瞭解也推進了最佳開發實踐。 例如,對於大多數用例,調用同步方法相對比較便宜,而當編譯器可以將被調用方內嵌到調用點時則更加便宜。 所以,開發人員學會了將代碼重構爲小的、可維護的方法,通常無需考慮因爲方法調用數量增長而帶來的負面影響。 這些開發人員對調用方法的意義有着特定的思惟模式。緩存

在引入異步方法後,他們須要有新的思惟模式。 C# 和 Visual Basic 語言以及編譯器會讓人產生異步方法就是其同步方法的對應版本的錯覺,但實際狀況並非如此。 編譯器最終會代替開發人員生成大量代碼,這些代碼與過去實現異步時必須由開發人員手動編寫並維護的樣板代碼的數量相似。 此外,編譯器生成的代碼會在 .NET Framework 中調用庫代碼,再次代替開發人員完成更多的工做。 要得到正確的思惟模式並使用這一模式作出合適的開發決策,重要的一點是瞭解編譯器代替您生成了哪些內容。性能優化

積極思考,不要多話

使用同步代碼時,附帶空白主體的方法幾乎是免費的。 但對異步方法來講,狀況並不是如此。 如下面的異步方法爲例,其主體包含一個語句(而且因爲缺少等待而最終同步運行):網絡

  1.  
  2.           public static async Task SimpleBodyAsync() {
  3.   Console.WriteLine("Hello, Async World!");
  4. }
  5.         

中間語言 (IL) 反編譯器在編譯完成後將揭示此函數的本質,輸出相似於圖 1 所示內容。 簡單的單行式命令已擴展到兩個方法,而且其中一個存在於幫助程序狀態機類中。 首先,有一個包含了基本簽名的存根方法,其簽名與開發人員編寫的基本簽名相同(該方法具備相同的名稱,具備相同的可視性,接受相同的參數並保留了返回類型),可是此存根不包含開發人員編寫的任何代碼。 相反,它包含了設置樣板。 設置代碼對用來表明異步方法的狀態機進行初始化,而後調用狀態機上的輔助 MoveNext 方法來啓動狀態機。 此狀態機類型將保留異步方法的狀態,若有必要,容許在異步等待點之間保持該狀態。 它也包含用戶編寫的方法的主體,但已通過改造,從而容許結果和異常提高到返回的 Task;並維持方法的當前位置以便完成等待後在此處恢復執行等等。框架

圖 1 異步方法樣板異步

  1.  
  2.           [DebuggerStepThrough]     
  3. public static Task SimpleBodyAsync() {
  4.   <SimpleBodyAsync>d__0 d__ = new <SimpleBodyAsync>d__0();
  5.   d__.<>t__builder = AsyncTaskMethodBuilder.Create();
  6.   d__.MoveNext();
  7.   return d__.<>t__builder.Task;
  8. }
  9.  
  10. [CompilerGenerated]
  11. [StructLayout(LayoutKind.Sequential)]
  12. private struct <SimpleBodyAsync>d__0 : <>t__IStateMachine {
  13.   private int <>1__state;
  14.   public AsyncTaskMethodBuilder <>t__builder;
  15.   public Action <>t__MoveNextDelegate;
  16.  
  17.   public void MoveNext() {
  18.     try {
  19.       if (this.<>1__state == -1return;
  20.       Console.WriteLine("Hello, Async World!");
  21.     }
  22.     catch (Exception e) {
  23.       this.<>1__state = -1;
  24.       this.<>t__builder.SetException(e);
  25.       return;
  26.     }
  27.  
  28.     this.<>1__state = -1;
  29.     this.<>t__builder.SetResult();
  30.   }
  31.  
  32.   ...
  33.           }
  34.         

當考慮要調用的異步方法的成本時,請牢記這同樣板。 MoveNext 方法中的 try/catch 塊可能會阻止樣板被實時 (JIT) 編譯器內嵌,因此至少咱們如今擁有了方法調用成本而在同步狀況下則可能不會(有如此小的方法主體)。 咱們有多個對 Framework 例程(如 SetResult)的調用, 而且咱們在狀態機類型上有多個寫入字段。 固然,咱們須要針對 Console.WriteLine 的成本權衡這一切,由於它可能會主宰全部其餘涉及到的成本(它須要鎖定,它執行 I/O 等)。 此外,請注意基礎結構爲您作出的優化。 例如,狀態機類型就是一個結構。 若是此方法由於正在等待還沒有完成的實例(在這簡單的方法中,實例永遠不會完成)而須要暫停其執行,該結構只會被封裝到堆。 所以,異步方法的樣板將不會產生任何分配。 編譯器和運行時共同努力,以儘可能減小基礎結構中涉及的分配數量。async

瞭解什麼時候不使用異步

.NET Framework 應用多種優化,嘗試爲異步方法生成高效的異步實現。 可是,開發人員一般具有領域知識,而後產生一些優化,若是考慮所追求的通用性,這些優化若是由編譯器和運行來自動應用,是有風險和不明智的。 明確這一點後,實際上會有利於開發人員,避免在一小部分用例中使用異步方法,特別是對於將以一種更精準的方式訪問的庫方法。 通常來講,若是已知方法實際上能夠同步完成(由於方法依賴的數據是已經可用的),尤爲如此。ide

設計異步方法時,Framework 開發人員每每花費大量的時間優化對象分配。 這是由於分配是異步方法基礎結構中可能出現的最大性能成本之一。 分配一個對象的行爲一般至關便宜。 分配對象相似於向購物車中放入商品,此時將商品放入車內不須要花費太多的精力;而當您實際結帳時則須要拿出錢包並投入大量的資源。雖然分配一般開銷很低,但談到應用程序的性能時,產生的垃圾收集實在使人不爽。 垃圾收集行爲包括掃描當前已分配的部分對象和查找再也不被引用的對象。 分配的對象越多,執行這種標記的時間就越長。 此外,分配的對象越大而且分配的數量越多,垃圾收集發生的頻率就越大。 經過這種方式,分配會對系統產生全局影響:異步方法生成的垃圾越多,整個程序的運行速度就越慢,即便異步方法自身的微基準不顯示明顯的成本。

對於實際產生執行的異步方法(因爲等待還沒有完成的對象),異步方法基礎結構須要分配一個 Task 對象以從方法返回,而此 Task 對象將用做這一特殊調用的惟一引用。 然而,許多異步方法調用無需產生便可完成。 在這種狀況下,異步方法基礎結構可能返回一個已緩存並完成的 Task,而該 Task 能夠反覆使用以免分配沒必要要的 Task。 可是,能這麼作的狀況很少,例如當異步方法是一個非泛型 Task、Task<Boolean> 或 Task<TResult>(其中 TResult 是引用類型)時,異步方法的結果爲空。 雖然這一組合在將來可能會擴大,但若是您具有正在實施的操做的領域知識,您能夠作的更好。

考慮實現相似 MemoryStream 的類型。 MemoryStream 由 Stream 派生而來,所以能夠覆蓋 Stream 的新的 .NET 4.5 ReadAsync、WriteAsync 和 FlushAsync 方法,從而優化 MemoryStream 的實現。 因爲讀取操做與內存中的緩衝區恰好背道而馳,所以若是 ReadAsync 同步運行,僅僅複製內存就可得到更好的性能。使用異步方法實現這一操做應該相似於以下所示:

  1.  
  2.           public override async Task<int> ReadAsync(
  3.   byte [] buffer, int offset, int count,
  4.   CancellationToken cancellationToken)
  5. {
  6.   cancellationToken.ThrowIfCancellationRequested();
  7.   return this.Read(buffer, offset, count);
  8. }
  9.         

很是簡單。 因爲 Read 是一個同步調用,而且此方法中沒有會產生控制的等待,所以 ReadAsync 的全部調用實際上會同步完成。 如今,讓咱們考慮流的一個標準使用模式,例如複製操做:

  1.  
  2.           byte [] buffer = new byte[0x1000];
  3. int numRead;
  4. while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) {
  5.   await source.WriteAsync(buffer, 0, numRead);
  6. }
  7.         

這裏要注意的是,源流上針對這一特定調用系列的 ReadAsync 老是與同一計數參數(緩衝區的長度)同時調用,所以極有可能返回值(讀取的字節數)也將重複。 除了某些極少數的狀況外,ReadAsync 的異步方法實現不可能使用緩存的 Task 得到返回值,可是您能夠。

請考慮如圖 2 所示重寫此方法。 利用此方法的一些特色及其常見的使用方案,咱們如今可以用不尋常的方式優化分配,而不期待底層基礎結構實現此優化。 所以,每次調用 ReadAsync 都會檢索與以前調用時數量相同的字節,咱們能夠經過返回與以前調用返回的相同的 Task 來完全避免 ReadAsync 方法產生的調用開銷。而對於像這樣的低級別操做,咱們指望可以快速並重復調用,這樣的優化能夠產生顯著的變化,特別是在垃圾收集數量方面。

圖 2 優化任務分配

  1.  
  2.           private Task<int> m_lastTask;
  3.  
  4. public override Task<int> ReadAsync(
  5.   byte [] buffer, int offset, int count,
  6.   CancellationToken cancellationToken)
  7. {
  8.   if (cancellationToken.IsCancellationRequested) {
  9.     var tcs = new TaskCompletionSource<int>();
  10.     tcs.SetCanceled();
  11.     return tcs.Task;
  12.   }
  13.  
  14.   try {
  15.       int numRead = this.Read(buffer, offset, count);
  16.       return m_lastTask != null && numRead == m_lastTask.Result ?
  17.           m_lastTask : (m_lastTask = Task.FromResult(numRead));
  18.   }
  19.   catch(Exception e) {
  20.     var tcs = new TaskCompletionSource<int>();
  21.     tcs.SetException(e);
  22.     return tcs.Task;
  23.   }
  24. }
  25.         

當方案中規定了緩存時,可能會執行相關的優化以免任務分配。 考慮一種旨在下載特定網頁內容並緩存其成功下載的內容以備未來訪問的方法。 這種功能可能使用異步方法編寫,以下所示(使用 .NET 4.5 中新的 System.Net.Http.dll 庫):

  1.  
  2.           private static ConcurrentDictionary<string,string> s_urlToContents;
  3.  
  4. public static async Task<string> GetContentsAsync(string url)
  5. {
  6.   string contents;
  7.   if (!s_urlToContents.TryGetValue(url, out contents))
  8.   {
  9.     var response = await new HttpClient().GetAsync(url);
  10.     contents = response.EnsureSuccessStatusCode().Content.ReadAsString();
  11.     s_urlToContents.TryAdd(url, contents);
  12.   }
  13.   return contents;
  14. }
  15.         

這是一個簡單的實現。 而對於沒法從緩存獲得知足的 GetContentsAsync 調用,爲表示這次下載而構建的新 Task<string> 的開銷與網絡成本相比是能夠忽略不計的。 可是,若是內容能夠從緩存獲得知足,它可能就是不可忽略的成本,對象分配只是包裝並退回已經可用的數據。

要避免此成本(爲了知足性能目標而這樣作),您能夠如圖 3 所示重寫此方法。 咱們如今有兩種方法:同步公共方法和公共方法委託的異步私有方法。 字典如今緩存生成的任務而不是自身的內容,因此要將來嘗試下載已經成功下載的頁面,能夠經過訪問字典以返回已存在的任務而獲得實現。 在內部,咱們也利用 Task 上的 ContinueWith 方法,它容許咱們在任務完成後將其儲存到字典中 — 但僅限下載成功的狀況下。 固然,此代碼較爲複雜並須要花費更多的心思編寫和維護,而全部的性能優化都同樣,除非性能測試證實即便複雜卻會產生重大且必要的影響,不然無需花費時間編寫這樣的代碼。 這種優化是否發揮做用實際上要取決於使用方案。 您會但願引入一組表明常見使用模式的測試,並分析這些測試以判斷這種複雜可否以一種有意義的方式提升代碼的性能。

圖 3 手動緩存任務

  1.  
  2.           private static ConcurrentDictionary<string,Task<string>> s_urlToContents;
  3.  
  4. public static Task<string> GetContentsAsync(string url) {
  5.   Task<string> contents;
  6.   if (!s_urlToContents.TryGetValue(url, out contents)) {
  7.       contents = GetContentsAsync(url);
  8.       contents.ContinueWith(delegate {
  9.         s_urlToContents.TryAdd(url, contents);
  10.       }, CancellationToken.None,
  11.         TaskContinuationOptions.OnlyOnRanToCompletion |
  12.           TaskContinuatOptions.ExecuteSynchronously,
  13.         TaskScheduler.Default);
  14.   }
  15.   return contents;
  16. }
  17.  
  18. private static async Task<string> GetContentsAsync(string url) {
  19.   var response = await new HttpClient().GetAsync(url);
  20.   return response.EnsureSuccessStatusCode().Content.ReadAsString();
  21. }
  22.         

另外一個須要考慮的與任務相關的優化是,您是否須要從異步方法返回的任務。 C# 和 Visual Basic 都支持建立返回 void 的異步方法,在這種狀況下,永遠都不須要爲方法分配任務。 您始終應該編寫從庫中公開的異步方法以返回 Task 或 Task<TResult>,由於做爲庫的開發人員,您不知道使用者是否願意等待方法完成。可是,對於某些內部使用方案,返回 void 的異步方法仍然佔有一席之地。 返回 void 的異步方法存在的主要緣由是支持現有的事件驅動環境,如 ASP.NET 和 Windows Presentation Foundation (WPF)。 它們經過使用 async 和 await,使得實施按鈕處理程序、頁面加載事件等變得很容易。 若是您確實考慮使用異步 void 方法,請注意異常的處理:轉義異步 void 方法的異常會在異步 void 方法被調用時冒出並進入 SynchronizationContext 當時的狀態。

注意環境

.NET Framework 中有不少類型的「環境」:LogicalCallContext、SynchronizationContext、HostExecutionContext、SecurityContext、ExecutionContext 等(單純從數量上看,您可能會認爲 Framework 的開發人員是受到金錢的激勵而推出這麼多新環境,可是我能夠向您保證咱們不是)。 這些環境中的一部分與異步方法關係很是密切,不只僅是在功能上,它們對異步方法的性能也有很大的影響。  

SynchronizationContext SynchronizationContext 在異步方法中扮演着重要角色。 「同步環境」是對封送能力的抽象,即以給定庫或框架規定的方式封送委託調用的能力。 例如,WPF 提供一個 DispatcherSynchronizationContext 用來表示調度程序的 UI 線程:向此同步環境發佈委託會致使該委託排隊等待被其線程上的調度程序執行。 ASP.NET 提供一個 AspNetSynchronizationContext,用於確保在處理 ASP.NET 請求過程當中出現的異步操做按順序執行並與正確的 HttpContext 狀態相關聯。 其餘在此就不一一列舉了。 總之,在 NET Framework 中 SynchronizationContext 約有 10 種具體的實現,一些是公共實現而一些則是內部實現。

當等待 .NET Framework 提供的任務和其餘可等待類型時,這些類型的「等待程序」(如 TaskAwaiter)在等待發出時將捕獲當前的 SynchronizationContext。 可等待類型完成時,若是已經捕獲一個當前的 SynchronizationContext,則表明異步方法其他部分的延續將發佈到 SynchronizationContext。 這樣,正在編寫從 UI 線程調用的異步方法的開發人員不須要爲了修改 UI 控件手動將調用封送回 UI 線程:這樣的封送由 Framework 基礎結構自動處理。

遺憾的是,這樣的封送也涉及到成本。 對於使用 await 實現其控制流的應用程序開發人員而言,這種自動封送一般都是正確的解決方案。 可是,庫每每不太同樣。 應用程序開發人員一般須要這樣的封送是由於他們的代碼會關注自身正在運行的環境,如可以訪問 UI 控件或可以訪問 HttpContext 以得到正確的 ASP.NET 請求。 可是大多數的庫並不受此約束。 所以,這種自動封送常常是徹底沒必要要的成本。 再次之前文所示的將數據從一個流複製到另外一個流的代碼爲例:

  1.  
  2.           byte [] buffer = new byte[0x1000];
  3. int numRead;
  4. while((numRead = await source.ReadAsync(buffer, 0, buffer.Length)) > 0) {
  5.   await source.WriteAsync(buffer, 0, numRead);
  6. }
  7.         

若是此複製操做是從 UI 線程調用,那麼每個等待的讀、寫操做都將強制完成回 UI 線程。 對於 1 MB 的源數據和異步完成讀、寫的流(大多數的流都是這樣),這意味着從後臺到 UI 線程有多達 500 個躍點。 爲解決這一問題,Task 和 Task<TResult> 類型提供了 ConfigureAwait 方法。 ConfigureAwait 接受一個控制此封送行爲的 Boolean continueOnCapturedContext 參數。 若是使用默認的 true,等待將在捕獲的 SynchronizationContext 上自動完成。 可是若是使用 false,SynchronizationContext 將被忽略而且 Framework 將嘗試在以前異步操做完成的位置繼續執行。 將此操做合併到流複製代碼會產生下列更高效的版本:

  1.  
  2.           byte [] buffer = new byte[0x1000];
  3. int numRead;
  4. while((numRead = await
  5.   source.ReadAsync(buffer, 0, buffer.Length).ConfigureAwait(false)) > 0) {
  6.   await source.WriteAsync(buffer, 0, numRead).ConfigureAwait(false);
  7. }
  8.         

對庫開發人員來講,這種性能影響自身已足夠保證一直使用 ConfigureAwait,除非在極少數狀況下,庫對其環境具備領域知識而且不須要訪問正確的環境以執行方法的主體。

除性能以外還有一個在庫代碼中使用 ConfigureAwait 的緣由。 假設上述的代碼沒有 ConfigureAwait,並處於一個從 WPF UI 線程調用的名爲 CopyStreamToStreamAsync 的方法中,以下所示:

  1.  
  2.           private void button1_Click(object sender, EventArgs args) {
  3.   Stream src = …, dst = …;
  4.   Task t = CopyStreamToStreamAsync(src, dst);
  5.   t.Wait(); // deadlock!
  6.           }
  7.         

在此,開發人員應該已經寫好 button1_Click 做爲異步方法而後等待 Task,而不是使用它的同步 Wait 方法。Wait 方法有很是重要的用途,可是將其用在像這樣的 UI 線程中等待老是出錯。 直到 Task 完成以後 Wait 方法纔會返回。 若是是 CopyStreamToStreamAsync,包含的等待嘗試發佈回到捕獲的 SynchronizationContext,而且當這些發佈完成後方法纔會完成(由於發佈會用於處理方法的其他部分)。可是這些發佈沒法完成,由於處理它們的 UI 線程在調用 Wait 時中斷。 這是一個循環的依賴關係,會致使死鎖。 若是 CopyStreamToStreamAsync 改成使用 ConfigureAwait(false) 編寫,將不會產生循環依賴關係和死鎖。

ExecutionContext ExecutionContext 是 .NET Framework 不可或缺的部分,可是大多數開發人員都沒有意識到它的存在。 ExecutionContext 是環境的鼻祖,它封裝了其餘多個環境如 SecurityContext 和 LogicalCallContext,並表明代碼中應該自動跨異步點流動的一切。 不管您什麼時候在 Framework 中使用 ThreadPool.QueueUserWorkItem、Task.Run、Delegate.BeginInvoke、Stream.BeginRead、WebClient.DownloadStringAsync 或其餘異步操做,若是可能,其實是捕獲了 ExecutionContext(經過 ExecutionContext.Capture),而後捕獲的環境將被用於處理提供的委託(經過 ExecutionContext.Run)。 例如,若是調用 ThreadPool.QueueUserWorkItem 的代碼當時正在模擬 Windows 身份標識,則將模擬相同的 Windows 身份標識來運行提供的 WaitCallback 委託。 若是調用 Task.Run 的代碼首先將數據存儲到 LogicalCallContext,則相同的數據可經過提供的 Action 委託中的 LogicalCallContext 訪問。ExecutionContext 也在任務的等待間流動。

Framework 中已有多個優化,以免在沒必要要時在捕獲的 ExecutionContext 中捕獲和運行,由於這樣作會很是昂貴。 可是,像模擬 Windows 身份標識或將數據存儲到 LogicalCallContext 等操做會阻礙這些優化。 避免執行 ExecutionContext 的操做(如 WindowsIdentity.Impersonate 和 CallContext.LogicalSetData)將在使用異步方法以及使用通常異步功能時帶來更好的性能。

走出垃圾收集的困擾

當涉及到局部變量時,異步方法提供一個很好的假象。 在同步方法中,C# 和 Visual Basic 中的局部變量都基於堆棧,所以在存儲這些局部變量時是無需堆分配的。 但在異步方法中,當異步方法在等待點暫停時,方法的堆棧將消失。 等待回覆後,方法要使用的數據則必須存儲在某處。 所以,C# 和 Visual Basic 編譯器將局部變量「提高」到狀態機結構中,而後會在首次暫停的等待以後被封裝到堆,這樣局部變量就能夠在等待點之間繼續存續。

在前文中,我介紹了分配的對象數量如何影響垃圾收集的成本和頻率,同時分配的對象的大小也會影響垃圾收集的頻率。 分配的對象越大,垃圾收集運行的次數就越多。 所以,在異步方法中,須要提高到堆的局部變量越多,垃圾收集發生的頻率就越多。

在撰寫此文時,C# 和 Visual Basic 編譯器有時會提高一些沒必要要的局部變量。 如下面的代碼段爲例:

  1.  
  2.           public static async Task FooAsync() {
  3.   var dto = DateTimeOffset.Now;
  4.   var dt  = dto.DateTime;
  5.   await Task.Yield();
  6.   Console.WriteLine(dt);
  7. }
  8.         

在等待點以後根本就不讀取 dto 變量,所以在等待以前寫入的值在經過等待後不須要保留。 可是,編譯器生成的用來存儲局部變量的狀態機類型仍然包含 dto 引用,如圖 4 所示。

圖 4 局部變量提高

  1.  
  2.           [StructLayout(LayoutKind.Sequential), CompilerGenerated]
  3. private struct <FooAsync>d__0 : <>t__IStateMachine {
  4.   private int <>1__state;
  5.   public AsyncTaskMethodBuilder <>t__builder;
  6.   public Action <>t__MoveNextDelegate;
  7.  
  8.   public DateTimeOffset <dto>5__1;
  9.   public DateTime <dt>5__2;
  10.   private object <>t__stack;
  11.   private object <>t__awaiter;
  12.  
  13.   public void MoveNext();
  14.   [DebuggerHidden]
  15.   public void <>t__SetMoveNextDelegate(Action param0);
  16. }
  17.         

這稍微增大了真正必要的堆對象。 若是您發現垃圾收集發生的頻率超過了預期,請考慮您是否真的須要全部這些已經編碼到異步方法的臨時變量。 要避免狀態機類出現過多字段,請按如下示例重寫:

  1.  
  2.           public static async Task FooAsync() {
  3.   var dt = DateTimeOffset.Now.DateTime;
  4.   await Task.Yield();
  5.   Console.WriteLine(dt);
  6. }
  7.         

此外,.NET 垃圾收集器 (GC) 是分代收集器,也就是說,它將對象組分紅小組,稱爲一代:從更高層次來講,新對象分配在第 0 代,而後在收集期間存續的全部對象則提升一代(.NET GC 目前使用第 0、1 和 2 代)。 這樣一來,GC 會從已知的對象空間的子集頻繁收集,從而可以加速收集過程。 它所依據的原理是新分配的對象很快也會消失,而已經出現很長時間的對象則將繼續出現很長時間。 這就是說,若是一個對象在第 0 代存續,它最後可能會出現一段時間,但卻由於這一額外時間而繼續對系統施加壓力。 這也意味着咱們確實要確保再也不須要的對象當即能夠進行垃圾收集。

藉助上文說起的提高,局部變量將提高到在異步方法執行期間仍然保留在根級的類的字段中(只要等待的對象能正確維護對委託的引用,以在等待完成後當即調用)。 在同步方法中,JIT 編譯器可以跟蹤局部變量什麼時候不能再訪問,而且此時能夠幫助 GC 忽視這些做爲根的變量,從而使得再也不被引用到其餘任何地方的引用對象能夠進行垃圾收集。 可是,在異步方法中,這些局部變量仍然能夠引用,這意味着若是它們真正成爲局部變量,則這些引用的對象就能存續更長時間。 若是您發現對象使用以後仍然保持有效,請考慮在您使用之後歸零引用這些對象的局部變量。 再次強調,只有在您發現它確實致使性能問題時才執行這一操做,不然將使代碼沒必要要地複雜化。 此外,C# 和 Visual Basic 編譯器能夠會在最終版本中作出更新,或者將來可代替開發人員處理更多的此類方案,因此今天編寫的這類代碼未來極可能會被淘汰。

避免複雜性

C# 和 Visual Basic 編譯器在容許您使用 await 方面特別使人印象深入:幾乎任何地方均可以使用。 Await 表達式可能用做更大表達式的一部分,從而容許您等待可能有其餘任何返回值的表達式中的 Task<TResult>。例如,如下代碼將返回三個任務結果之和:

  1.  
  2.           public static async Task<int> SumAsync(
  3.   Task<int> a, Task<int> b, Task<int> c)
  4. {
  5.   return Sum(await a, await b, await c);
  6. }
  7.  
  8. private static int Sum(int a, int b, int c)
  9. {
  10.   return a + b + c;
  11. }
  12.         

C# 編譯器容許您將「await b」表達式用做 Sum 函數的參數。 可是,此處有多個等待結果以參數形式傳遞到 Sum,而且因爲計算順序規則和異步在編譯器中的實現方式,這個特定的示例須要編譯器「分散」前兩個等待的臨時結果。 正如您以前看到的那樣,局部變量經過提高到狀態機類的字段中而在等待點之間保持不變。可是,在這個示例中,值是 CLR 計算堆棧上的類,這些值不會提高到狀態機而是分散到單個的臨時對象,而後再被狀態機引用。 當您在首個任務上完成等待並轉而等待第二個時,編譯器會生成封送首個結果的代碼並將封送的對象存儲到狀態機上的 <>t__stack 字段中。 當您在第二個任務上完成等待並轉而等待第三個時,編譯器會生成從前兩個值中建立 Tuple<int,int> 的代碼,並將此元祖存儲到相同的 <>__stack 字段中。 這些都說明根據您編寫的代碼的不一樣,最終可能會獲得很是不一樣的分配模式。 請考慮改用如下方式編寫 SumAsync:

  1.  
  2.           public static async Task<int> SumAsync(
  3.   Task<int> a, Task<int> b, Task<int> c)
  4. {
  5.   int ra = await a;
  6.   int rb = await b;
  7.   int rc = await c;
  8.   return Sum(ra, rb, rc);
  9. }
  10.         

這樣改變以後,編譯器如今會向狀態機類發出另外三個字段以存儲 ra、rb 和 rc,而且不會發生分散。 所以,您不得不進行權衡:選擇分配較少的較大狀態機,仍是選擇分配較多的較小狀態機。 在分散狀況下,分配的內存總量會更大,由於每一個分配的對象都會有本身的內存開銷,可是最終的性能測試可能會顯示它會好得多。 一般,如前所述,除非您已經發現分配是致使麻煩的緣由,不然您不該該考慮這些微優化操做,但不管如何,它有利於瞭解這些分配來自何處。

固然,毋庸置疑以前的示例中有一個更大的成本,您應該有所瞭解並積極思考。 直到三個等待都已完成以後代碼纔可以調用 Sum,而且在等待之間不會進行任何工做。 產生的每個等待都須要大量的工做,所以須要處理的等待越少越好。 而後您應當當即使用 Task.WhenAll 等待全部任務而將全部這三個等待合併到一個:

  1.  
  2.           public static async Task<int> SumAsync(
  3.   Task<int> a, Task<int> b, Task<int> c)
  4. {
  5.   int [] results = await Task.WhenAll(a, b, c);
  6.   return Sum(results[0], results[1], results[2]);
  7. }
  8.         

Task.WhenAll 方法在此返回的 Task<TResult[]> 在全部提供的任務完成以後才完成,這樣作的效率也遠遠高於單獨等待每個任務。 同時它還收集每一個任務的結果並存儲到數組。 若是您想要避免使用此數組,能夠強制綁定到適用於 Task 而不是 Task<TResult> 的非泛型 WhenAll 方法。 對於最終性能,您也能夠採用混合方法,首先檢查是否全部的任務都已成功完成,若是是,請獨立得到它們的結果 — 若是沒有,請等待沒有完成的任務的 WhenAll。 這能夠避免調用 WhenAll 時涉及沒必要要的分配,例如分配須要傳送到方法的參數數組。 而且,如前所述,咱們也但願這個庫函數能抑制環境封送。 圖 5 中顯示了此類解決方案。

圖 5 應用多項優化

  1.  
  2.           public static Task<int> SumAsync(
  3.   Task<int> a, Task<int> b, Task<int> c)
  4. {
  5.   return (a.Status == TaskStatus.RanToCompletion &&
  6.           b.Status == TaskStatus.RanToCompletion &&
  7.           c.Status == TaskStatus.RanToCompletion) ?
  8.           Task.FromResult(Sum(a.Result, b.Result, c.Result)) :
  9.     SumAsyncInternal(a, b, c);
  10. }
  11.  
  12. private static async Task<int> SumAsyncInternal(
  13.   Task<int> a, Task<int> b, Task<int> c)
  14. {
  15.   await Task.WhenAll((Task)a, b, c).ConfigureAwait(false);
  16.   return Sum(a.Result, b.Result, c.Result);
  17. }
  18.         

異步和性能

異步方法是一個功能強大的高效工具,使您可以更輕鬆編寫可伸縮和響應更快的庫和應用程序。 請牢記一點,異步不是對單個操做的性能優化。 採用同步操做並使其異步化必然會下降該操做的性能,由於它仍然須要完成同步操做的全部工做,只不過如今會有額外的限制和注意事項。 您關注異步的一個緣由是其整體性能:若是您採用異步方法編寫全部內容,整個系統的執行效果如何。這樣您能夠僅消耗執行須要的有價值的資源,重疊 I/O 並實現更好的系統利用率。 .NET Framework 提供的異步方法實現已經進行了優化,而且最終經常比使用現有模式和更多代碼精心編寫的異步實現可以提供一樣優秀甚至更好的性能。 從如今開始,不管您什麼時候準備在 .NET Framework 中開發異步代碼,異步方法都是您的首選工具。 而且,做爲一個開發人員,瞭解 Framework 代替您在這些異步方法中所做的一切對您很是有益,這樣能夠確保得到儘量好的最終結果。

相關文章
相關標籤/搜索