在.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、初識異步類型
如果移除async和await上下文關鍵字,將HttpClient替換爲WebClient,將GetStringAsync改成DownloadString,代碼仍能編譯並工作。但是在獲取頁面內容時,UI將無法響應。而運行異步版本時(理想情況下通過較慢的網速進行連接),UI仍然能夠響應,在獲取網站頁面時,仍然能夠移動窗體。大多數開發者都知道在開發Windows Form時,有兩條關於線程的金科玉律
- 不要在UI線程上執行任何耗時的操作
- 不要在除了UI線程之外的其他線程上訪問UI控件
2、分解第一個示例
將上述的示例轉變爲以下語句:
task的類型是Task
二、思考異步編程
如果讓一個開發者描述異步執行過程,他很可能會談起多線程。儘管這是異步編程的典型用途,但它卻並不一定需要異步執行。要充分了解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、異步方法
按下圖來思考異步方法是非常有用的。圖中共有三個代碼塊(方法)和兩個邊界(方法返回類型)
以下面的代碼爲例:
- 調用方法爲PrintPageLength
- 異步方法爲GetPageLengthAsync
- 異步操作爲HttpClient.GetStringAsync
- 調用方法和異步方法之間的邊界爲Task
- 異步方法和異步操作之間的邊界爲Task
三、語法和語義
1、聲明異步方法
異步方法的聲明語法與其他方法完全一樣,只是要包含async上下文關鍵字。async上下文關鍵字有一個不爲人知的祕密:對語言設計者來說,方法簽名中有沒有該關鍵字都無所謂。就像在方法內使用具有適當返回類型的yield return或yield break,會使編譯器進入某種“迭代器塊模式”(iterator block mode)一樣,編譯器也會發現方法內包含await,並進入“異步模式”(async mode)。
2、異步方法的返回類型
調用者和異步方法之間是通過返回值來通信的。異步函數的返回類型只能爲:void;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線程。從開發者的角度來看,感覺像是方法在異步操作完成時就暫停了。就方法中使用的所有局部變量而言,編譯器應確保其變量值在後續操作開始前後均保持不變:
思考一下,從一個異步方法“返回”意味着什麼。同樣,這裏也存在着兩種可能。
- 這是你需要等待的第一個await表達式,因此原始調用者還位於棧中的某個位置。(記住,在到達需要等待的操作之前,方法都是同步執行的。)
- 已經等待了其他操作,因此處於由某個操作調用的後續操作中。調用棧與第一次進入該方法時相比,已經發生了翻天覆地的變化。
在第一種情況下,最終往往會將Task或Task
4.3、使用可等待模式的成員
5、從異步方法返回
在到達return語句之前,幾乎必然會返回調用者,我們需以某種方式向這個調用者傳播信息。一個Task
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
6.2、在拋出異常時進行包裝
異步方法在調用時永遠不會直接拋出異常。異常方法會返回Task或Task
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。
四、異步匿名函數
創建異步匿名函數,與創建其他匿名方法或Lambda表達式類似,不同的是要在前面加上async修飾符。與異步方法一樣,在創建委託時,委託簽名的返回類型必須爲void、Task或Task
五、實現細節:編譯器轉換(略..)
六、高效地使用async/await
1、基於任務的異步模式
C# 5異步函數特性的一大好處是,它爲異步提供了一致的方案。但如果在命名異步方法以及觸發異常等方面做法存在着差異,則很容易破壞這種一致性。微軟因此發佈了基於任務的異步模式(Task-based Asynchronous Pattern,TAP),即提出了每個人都應遵守的約定。異步方法的名稱應以Async爲後綴,如果存在命名衝突,建議使用TaskAsync後綴。如果方法很明顯是異步的,則可去掉後綴,如Task.Delay和Task.WhenAll等。一般來說,如果方法的整個業務是異步的,而不是爲了達到某種業務上的目標,那麼去掉後綴應該就是安全的。
TAP方法一般返回的是Task或TaskEmployee 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
基於IO的操作會將工作移交給硬盤或其他計算機,這非常適合異步,而且沒有明顯的缺點。CPU密集型的任務就不那麼適合了:
- 如果任務需等待其他系統返回的結果,而隨後的結果處理又十分耗時,這種情況就更加棘手了。如果最終要佔用調用者上下文的大部分CPU資源,就應該在文檔中清晰地進指明這種行爲。
- 另一種方法是避免使用調用者的上下文,而應使用Task.ConfigureAwait方法。該方法目前只包含一個continueOnCapturedContext參數。該方法返回一個可等待模式的實現。當參數爲true時,可等待的行爲正常,因此如果UI線程調用異步方法,await表達式後面的後續操作可仍然在UI線程上執行。這樣要訪問UI元素就變得非常方便。如果沒有任何特殊需求,可將參數指定爲false,這時後續操作的執行通常發生在原始操作完成的上下文中。通常來說我們應該在每個await表達式處調用該方法,而保持一致是個好習慣。如果想爲調用者提供方法執行上下文的靈活性,可將其作爲異步方法參數。注意,ConfigureAwait只會影響執行上下文的同步部分。
2、組合異步操作
2.1、 在單個調用中收集結果
調用ToList()來具體化LINQ查詢。這保證了每個任務將只啓動一次。否則每次迭代tasks時,將會再次獲取字符串。
2.2、在全部完成時收集結果
TaskCompletionSource
如果原始任務正常完成,則將返回值複製到Task CompletionSource
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