《深入理解C#》整理10-使用async/await進行異步編程

在.NET Framework中,有三種不同的模型來簡化異步編程。①.NET 1.x中的BeginFoo/EndFoo方法, 使用IAsyncResult和AsyncCallback來傳播結果。②.NET 2.0中基於事件的異步模式,使用BackgroundWorker和WebClient實現。③.NET 4引入並由.NET 4.5擴展的任務並行庫(TPL)。

儘管TPL經過了精心設計,但用它編寫健壯可讀的異步代碼仍然十分困難。雖然支持並行是一個壯舉,但對於異步編程的某些方面來說,最好是從語言層面進行修補,而不是純粹的庫。C# 5的這個主要特性基於TPL,因此可以在適用於異步的地方編寫同步形式的代碼。

一、異步函數簡介

C# 5引入了異步函數(asynchrnous function)的概念。通常是指用async修飾符聲明的,可包含await表達式的方法或匿名函數。如果await表達式等待的值還不可用,那麼異步函數將立即返回;當該值可用時,異步函數將(在適當的線程上)回到離開的地方繼續執行。“在這條語句完成之前不要執行下一條語句”的流程依然不變,只是不再阻塞。

1、初識異步類型

image-20201101095609832

如果移除async和await上下文關鍵字,將HttpClient替換爲WebClient,將GetStringAsync改成DownloadString,代碼仍能編譯並工作。但是在獲取頁面內容時,UI將無法響應。而運行異步版本時(理想情況下通過較慢的網速進行連接),UI仍然能夠響應,在獲取網站頁面時,仍然能夠移動窗體。大多數開發者都知道在開發Windows Form時,有兩條關於線程的金科玉律

  • 不要在UI線程上執行任何耗時的操作
  • 不要在除了UI線程之外的其他線程上訪問UI控件

2、分解第一個示例

將上述的示例轉變爲以下語句:

image-20201101100213507

task的類型是Task,而await task表達式的類型是string。也就是說,await表達式執行的是“拆包”(unwrap)操作。await的主要目的是在等待耗時操作完成時避免阻塞。巧妙之處在於,方法在執行到await表達式時就返回了。在此之前,它與其他事件處理程序一樣,都是在UI線程同步執行的。await後,代碼將檢查其結果是否存在。如果不存在(幾乎總是如此),會安排一個在Web操作完成時將要執行的後續操作(continuation)。在本例中,後續操作將執行剩下的代碼,跳到await表達式的末尾,並如你所願地回到UI線程,以便在UI上進行操作。

二、思考異步編程

如果讓一個開發者描述異步執行過程,他很可能會談起多線程。儘管這是異步編程的典型用途,但它卻並不一定需要異步執行。要充分了解C# 5的異步特性是如何工作的,最好摒棄任何線程思想,然後迴歸基礎。

1、異步執行的基礎

在同步方法中,執行流從一條語句到下一條語句,按順序執行。但異步執行模型不是這樣。相反,它充斥了後續操作。在開始做一件事情的時候,要告知其操作完成後應進行哪些操作。它與回調函數理念相同,這裏我們將其稱作“異步編程上下文”。在.NET中,後續操作很自然地由委託加以表示,且通常爲接收異步操作結果的action。但問題是,即使可以使用Lambda表達式,爲這一系列複雜的步驟創建委託,仍然是一件困難的事。實際上,C#編譯器會對所有await都構建一個後續操作。

前面對異步編程的描述是理想化的。實際上基於任務的異步模式要稍有不同。它並不會將後續操作傳遞給異步操作,而是在異步操作開始時返回一個token,我們可以用這個token在稍後提供後續操作。它表示正在進行的操作,在返回調用代碼前可能已經完成,也可能正在處理。token用於表達這樣的想法:在這個操作完成之前,不能進行下一步處理。token的形式通常爲Task或Task,但這並不是必須的。

在C# 5中,異步方法的執行流通常遵守下列流程。(1) 執行某些操作。(2) 開始異步操作,並記住返回的token。(3) 可能會執行其他操作。(在異步操作完成前,往往不能進行任何操作,此時忽略該步驟。)(4) 等待異步操作完成(通過token)。(5) 執行其他操作。(6) 完成。

如果你能接受在異步操作完成前進行阻塞,那麼可以使用token。對於Task,可以調用Wait()。但這時,我們佔用了一個有價值的資源(線程),卻沒有進行任何有用的工作。”但如果不想阻塞線程,又該怎麼做呢?很簡單,我們可以立即返回,然後異步地繼續執行其他操作。如果想讓調用者知道什麼時候異步方法能夠完成,就要傳一個token回去,它們可以選擇阻塞,或(更有可能)使用一個後續操作。

2、異步方法

按下圖來思考異步方法是非常有用的。圖中共有三個代碼塊(方法)和兩個邊界(方法返回類型)

image-20201101102935962

以下面的代碼爲例:

  • 調用方法爲PrintPageLength
  • 異步方法爲GetPageLengthAsync
  • 異步操作爲HttpClient.GetStringAsync
  • 調用方法和異步方法之間的邊界爲Task
  • 異步方法和異步操作之間的邊界爲Task

image-20201101103101525

三、語法和語義

1、聲明異步方法

異步方法的聲明語法與其他方法完全一樣,只是要包含async上下文關鍵字。async上下文關鍵字有一個不爲人知的祕密:對語言設計者來說,方法簽名中有沒有該關鍵字都無所謂。就像在方法內使用具有適當返回類型的yield return或yield break,會使編譯器進入某種“迭代器塊模式”(iterator block mode)一樣,編譯器也會發現方法內包含await,並進入“異步模式”(async mode)。

2、異步方法的返回類型

調用者和異步方法之間是通過返回值來通信的。異步函數的返回類型只能爲:void;Task;Task(某些類型的TResult,其自身即可爲類型參數)。.NET 4中的Task和Task類型都表示一個可能還未完成的操作。Task繼承自Task。二者的區別是,Task表示一個返回值爲T類型的操作,而Task則不需要產生返回值。儘管如此,返回Task仍然很有用,因爲調用者可以在返回的任務上,根據任務執行的情況(成功或失敗),附加自己的後續操作。在某種意義上,你可以認爲Task就是Task類型,如果這麼寫合法的話。

還有一個關於異步方法簽名的約束:所有參數都不能使用out或ref修飾符。這麼做是有道理的,因爲這些修飾符是用於將通信信息返回給調用代碼的;而且在控制返回給調用者時,某些異步方法可能還沒有開始執行,因此引用參數可能還沒有賦值。當然,更奇怪的是:將局部變量作爲實參傳遞給ref形參,異步方法可以在調用方法已經結束的情況下設置該變量。這並沒有多大意義,所以編譯器乾脆禁止這麼做。

3、可等待模式

異步方法幾乎包含所有常規C#方法所包含的內容,只是多了一個await表達式。我們可以使用任意控制流:循環、異常、using語句等。await表達式非常簡單,只是在其他表達式前面加了一個await。一般來說,我們只能等待(await)一個異步操作。換句話說,是包含以下含義的操作:

  • 告知是否已經完成;
  • 如未完成可附加後續操作;
  • 獲取結果,該結果可能爲返回值,但至少可以指明成功或失敗。

在異步方法中,對於一個await表達式,編譯器生成的代碼會先調用GetAwaiter(),然後適時地使用awaiter的成員來等待結果。C#編譯器要求awaiter必須實現INotifyCompletion。這主要是由於效率的原因。一些編譯器的預發佈版本根本就沒有這個接口。編譯器僅通過簽名來檢查所有其他成員。重要的是,GetAwaiter()方法本身並不一定是一個標準的實例方法。它可以是await表達式中對象的擴展方法。

整個表達式本身也同樣擁有一個有趣的類型:如果GetResult()返回void,那麼整個await表達式就沒有類型,而只是一個獨立的語句。否則,其類型與GetResult()的返回類型相同。

4、await表達式的流

4.1、展開復雜的表達式

await表達式的結果可以用作方法實參,或作爲其他表達式的一部分,也可以將await指定的部分從整體中分開。通常來說,你只需要在某個值的上下文中檢查await的行爲即可。即使該值源自一個方法調用,但由於我們談論的是異步,所以可以忽略這個方法調用。

4.2、可見的行爲

執行過程到達await表達式後,存在着兩種可能:等待中的異步操作已經完成,或還未完成。如果操作已經完成,那麼執行流程就非常簡單,只需繼續執行即可。如果操作失敗,並且由一個代表該失敗的異常所捕獲,則會拋出該異常。否則,將得到該操作所返回的結果。所有這一切,都無需任何線程上下文切換或附加任何後續操作。

更有趣的場景發生在異步操作仍在執行時。在這種情況下,方法異步地等待操作完成,然後繼續執行適當的上下文。這種“異步等待”意味着方法將不再執行,它把後續操作附加在了異步操作上,然後返回。異步操作確保該方法在正確的線程中恢復。其中正確的線程通常指線程池線程(具體使用哪個線程都無妨)或UI線程。從開發者的角度來看,感覺像是方法在異步操作完成時就暫停了。就方法中使用的所有局部變量而言,編譯器應確保其變量值在後續操作開始前後均保持不變:

image-20201101145247115

思考一下,從一個異步方法“返回”意味着什麼。同樣,這裏也存在着兩種可能。

  • 這是你需要等待的第一個await表達式,因此原始調用者還位於棧中的某個位置。(記住,在到達需要等待的操作之前,方法都是同步執行的。)
  • 已經等待了其他操作,因此處於由某個操作調用的後續操作中。調用棧與第一次進入該方法時相比,已經發生了翻天覆地的變化。

在第一種情況下,最終往往會將Task或Task返回給調用者。顯然,這時還不能得到方法的真實結果,因爲即使沒有返回值,也無法得知方法的完成是否存在異常。因此,需要返回的任務必須是未完成的。在後一種情況下,“某些操作”的回調取決於你的上下文。

4.3、使用可等待模式的成員

image-20201101145938114

5、從異步方法返回

在到達return語句之前,幾乎必然會返回調用者,我們需以某種方式向這個調用者傳播信息。一個Task(即計算機科學中的future),是對未來生成的值或拋出的異常所做出的承諾(promise)。和普通的執行流一樣,如果return語句出現在有finally塊的try塊中(包括using語句),那麼用來計算返回值的表達式將立即被求值,但直到所有對象清理完畢後,纔會作爲任務結果。這意味着如果finally塊拋出一個異常,則整個代碼都會失敗。在異步世界裏,你很少需要顯式處理某個任務,而是await一個任務來進行消費,並作爲異步方法機制的一部分,自動生成一個結果任務

6、異常

程序並不會總是執行得一帆風順,.NET表示失敗的慣用方式是使用異常。與向調用者返回值類似,異常處理需要語言的額外支持。在想要拋出異常時,異步方法的原始調用者可能已經不在棧上了;而當await的異步操作失敗時,其原始調用者可能沒有執行在同一條線程上,因此需要某種方式來封送(marshaling)失敗。

6.1、在等待時拆包異常

awaiter的GetResult方法可獲取返回值(如果存在的話);同樣地,如果存在異常,它還負責將異常從異步操作傳遞迴方法中。在異步世界裏,單個Task可表示多個操作,並導致多個失敗。Task有多種方式可以表示異常:

  • 當異步操作失敗時,任務的Status變爲Faulted(並且IsFaulted返回true)
  • Exception屬性返回一個AggregateException,該AggregateException包含所有(可能有多個)造成任務失敗的異常;如果任務沒有錯誤,則返回null
  • 如果任務的最終狀態爲錯誤,則Wait()方法將拋出一個AggregateException
  • Task的Result屬性(同樣等待完成)也將拋出AggregateException

任務還支持取消操作,可通過CancellationTokenSource和CancellationToken來實現這一點。如果任務取消了,Wait()方法和Result屬性都將拋出包含OperationCanceled Exception的AggregateException(實際上是一個TaskCanceledException,它繼承自OperationCanceledException),但狀態將變爲Canceled,而不是Faulted。

在等待任務時,任務出錯或取消都將拋出異常,但並不是AggregateException。大多情況下爲方便起見,拋出的是AggregateException中的第一個異常。要解決這個問題並不需要太多的工作。我們可以使用可等待模式的知識,編寫一個Task的擴展方法,從而創建一個可從任務中拋出原始AggregateException的特殊可等待模式成員。

image-20201101152444295

image-20201101152504321

6.2、在拋出異常時進行包裝

異步方法在調用時永遠不會直接拋出異常。異常方法會返回Task或Task,方法內拋出的任何異常(包括從其他同步或異步操作中傳播過來的異常)都將簡單地傳遞給任務,就像前面介紹的那樣。如果調用者直接等待任務,則可得到一個包含真正異常的AggregateException;但如果調用者使用await,異常則會從任務中解包。返回void的異步方法可向原始的SynchronizationContext報告異常,如何處理將取決於上下文

image-20201101153137926

image-20201101153153102

image-20201101153246339

6.3、處理取消

任務並行庫(TPL)利用CancellationTokenSource和CancellationToken兩種類型向.NET 4中引入了一套統一的取消模型。該模型的理念是,創建一個CancellationToken Source,然後向其請求一個CancellationToken,並傳遞給異步操作。可在source上只執行取消操作,但該操作會反映到token上。(這意味着你可以向多個操作傳遞相同的token,而不用擔心它們之間會相互干擾。)取消token有很多種方式,最常用的是調用ThrowIfCancellation Requested,如果取消了token,並且沒有其他操作,則會拋出OperationCanceledException。如果在同步調用(如Task.Wait)中執行了取消操作,則可拋出同樣的異常。

C# 5規範中並沒有說明取消操作如何與異步方法交互。根據規範,如果異步方法體拋出任何異常,該方法返回的任務則將處於錯誤狀態。“錯誤”的確切含義因實現而異,但實際上,如果異步方法拋出OperationCanceledException(或其派生類,如TaskCanceled Exception),則返回的任務最終狀態爲Canceled。

image-20201101155601557

image-20201101160123869

四、異步匿名函數

創建異步匿名函數,與創建其他匿名方法或Lambda表達式類似,不同的是要在前面加上async修飾符。與異步方法一樣,在創建委託時,委託簽名的返回類型必須爲void、Task或Task。委託調用會開啓一個異步操作。與異步方法一樣,開啓異步操作的並不是await,也不是非要對異步匿名函數的結果使用await

image-20201101172455552

五、實現細節:編譯器轉換(略..)

六、高效地使用async/await

1、基於任務的異步模式

C# 5異步函數特性的一大好處是,它爲異步提供了一致的方案。但如果在命名異步方法以及觸發異常等方面做法存在着差異,則很容易破壞這種一致性。微軟因此發佈了基於任務的異步模式(Task-based Asynchronous Pattern,TAP),即提出了每個人都應遵守的約定。異步方法的名稱應以Async爲後綴,如果存在命名衝突,建議使用TaskAsync後綴。如果方法很明顯是異步的,則可去掉後綴,如Task.Delay和Task.WhenAll等。一般來說,如果方法的整個業務是異步的,而不是爲了達到某種業務上的目標,那麼去掉後綴應該就是安全的。

TAP方法一般返回的是Task或Task,但也有例外,如可等待模式的入口Task.Yield,不過這實屬鳳毛麟角。重要的是,從TAP方法中返回的任務應該是“熱”的。也就是說,它表示的操作應該已經開始執行了,而無須調用者的手動開啓。創建異步方法時,通常應考慮提供4個重載。4個重載均具有相同的基本參數,但要提供不同的選項,以用於進度報告和取消操作。比如Employee LoadEmployeeById(string Id),根據TAP的約定,需要提供下列重載的一個或全部:

  • Task LoadEmployeeById(string Id);
  • Task LoadEmployeeById(string Id,CancellationToken callationToken);
  • Task LoadEmployeeById(string Id,IProgress progress);
  • Task LoadEmployeeById(string Id,CancellationToken callationToken,IProgress progress);

這裏的IProgress是一個IProgress,這裏的T可以是任何適用於進度報告的類型。取消操作通常來說更容易支持,因爲存在着很多框架方法的支持。如果異步方法主要是執行其他異步操作(可能還包括依賴關係),就很容易支持取消操作,只需接收一個取消token並向下遊傳遞即可。異步操作應同步地進行錯誤檢查,如不合法的實參等。

基於IO的操作會將工作移交給硬盤或其他計算機,這非常適合異步,而且沒有明顯的缺點。CPU密集型的任務就不那麼適合了:

  • 如果任務需等待其他系統返回的結果,而隨後的結果處理又十分耗時,這種情況就更加棘手了。如果最終要佔用調用者上下文的大部分CPU資源,就應該在文檔中清晰地進指明這種行爲。
  • 另一種方法是避免使用調用者的上下文,而應使用Task.ConfigureAwait方法。該方法目前只包含一個continueOnCapturedContext參數。該方法返回一個可等待模式的實現。當參數爲true時,可等待的行爲正常,因此如果UI線程調用異步方法,await表達式後面的後續操作可仍然在UI線程上執行。這樣要訪問UI元素就變得非常方便。如果沒有任何特殊需求,可將參數指定爲false,這時後續操作的執行通常發生在原始操作完成的上下文中。通常來說我們應該在每個await表達式處調用該方法,而保持一致是個好習慣。如果想爲調用者提供方法執行上下文的靈活性,可將其作爲異步方法參數。注意,ConfigureAwait只會影響執行上下文的同步部分。

2、組合異步操作

2.1、 在單個調用中收集結果

image-20201101194119557

調用ToList()來具體化LINQ查詢。這保證了每個任務將只啓動一次。否則每次迭代tasks時,將會再次獲取字符串。

2.2、在全部完成時收集結果

image-20201101194310756

TaskCompletionSource類型可用於創建一個尚未含有結果的Task,並在之後提供結果(或異常)。它和AsyncTaskMethod Builder都建立在相同的基礎結構之上。後者爲異步方法提供返回的Task,並在方法體完成時,將帶結果的任務向外傳播。

如果原始任務正常完成,則將返回值複製到Task CompletionSource中。如果原始任務產生了錯誤,則可將異常複製到TaskCompletion Source中。取消原始任務後,TaskCompletionSource也會隨之被取消。在該方法運行時,它並不知道哪個TaskCompletionSource會對應哪個輸入任務,而只是將相同的後續操作附加到各任務上,然後由後續操作來尋找下一個TaskCompletionSource(通過對一個計數器進行原子地累加)並傳播結果。

3、對異步代碼編寫單元測試

3.1、安全地注入異步

假設要創建一些以特定順序完成的任務,並且(至少在部分測試中)確保可以在兩個任務的完成之間執行斷言。此外,我們不想引入其他線程,而希望擁有儘可能多的控制和可預見性。實質上,我們希望能夠控制時間。我們可以使用TimeMachine類來僞造時間,它可以用在特定時間以特殊方式完成的計劃任務,以編程方式來推進時間。將其與Windows Forms消息泵的手工版本SynchronizationContext組合,可得到一個非常合理的測試框架

如果測試的是更加專注於業務的異步方法,則要爲依賴的任務設置所有的結果,推進時間以完成所有任務,然後檢查返回任務的結果。需以正常方式提供僞造的產品代碼。此處異步帶來的唯一不同是,不再使用stub和mock來返回調用的直接結果,而是要求返回TimeMachine產生的任務。控制反轉的所有優點仍然適用,只是需要某種方式來創建合適的任務。

3.2、運行異步測試

上一節介紹的測試是完全同步運行的,測試本身並沒有使用async或await。如果所有測試中均使用了TimeMachine類,那麼這樣做是合理的,但在其他情況下,可能會需要編寫用async修飾的測試方法。與上一節使用TimeMachine的測試不同,你可能不想讓所有後續操作都運行在單獨的線程上,除非該線程像UI線程那樣。有時我們控制所有相關任務,並使用單線程上下文。而有時則需更加小心,只要測試代碼本身不是並行執行的,即可用多線程來觸發後續操作。

4、可等待模式的歸來

可等待模式的一個重要接口是INotifyCompletion,另一個擴展了上述接口,並且也位於System.Runtime.CompilerServices命名空間的接口是ICriticalNotifyCompletion。這兩個接口的核心都是上下文SynchronizationContext。它是一個能將調用封送到適當線程的同步上下文,而不管該線程是特定的線程池線程,還是單個的UI線程,或是其他線程。不過這並不是唯一相關的上下文,此外還存在有SecurityContext、LogicalCallContext、HostExecutionContext等大量上下文。它們的最上層結構是ExecutionContext。它是所有其他上下文的容器,也是本節將要關注的內容。ExecutionContext會跨過await,這一點非常重要。在任務完成時,你不會希望只是因爲忘了所模擬的用戶而再次回到異步方法中。爲傳遞上下文,需在附加後續操作時捕獲它,然後在執行後續操作時還原它。這分別由ExecutionContext.Capture和ExecutionContext.Run方法負責實現。

有兩段代碼可執行這種捕獲/還原操作,即awaiter和AsyncTaskMethodBuilder類(及其兄弟類)。任何使用awaiter的代碼都能直接訪問它,因此你不希望在使用編譯器生成代碼時,因信賴所有調用者而暴露可能的安全隱患,這表明編譯器生成代碼應該存在於awaiter代碼中。我們已經看到了答案:使用兩個具有細微差別的接口。如果要實現可等待模式,則必須由OnCompleted方法來傳遞執行上下文。如果實現的是ICriticalNotifyCompletion,則UnsafeOnCompleted方法不應傳遞執行上下文,而應標記上[SecurityCritical]特性,以阻止不信任的代碼調用。當然,方法的builder是可信的,用它們來傳遞上下文,可保證部分可信的調用者仍能有效地使用awaiter,但準攻擊者則無法規避上下文流。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章