系列文章目錄
1. C#與C++的發展曆程第一 - 由C#3.0起
2. C#與C++的發展曆程第二 - C#4.0再接再厲
3. C#與C++的發展曆程第三 - C#5.0異步程式設計的巅峰
C#5.0作為第五個C#的重要版本,将異步程式設計的易用度推向一個新的高峰。通過新增的async和await關鍵字,幾乎可以使用編寫同步代碼的方式來編寫異步代碼。
本文将重點介紹下新版C#的異步特性以及部分其他方面的改進。同時也将介紹WinRT程式一些異步程式設計的内容。
寫async異步程式設計這部分内容之前看了好多文章,反複整理自己的思路,盡力保證文章的正确性。盡管如此仍然可能存在錯誤,請廣大園友及時指出,感謝感謝。
異步程式設計不是一個新鮮的話題,最早期的C#版本也内建對異步程式設計的支援,當然在顔值上無法與目前基于TAP,使用async/await的異步程式設計相比。異步程式設計要解決的問題就是許多耗時的IO可能會阻塞線程導緻CPU空轉降低效率,或者一個長時間的背景任務會阻塞使用者界面。通過将耗時任務異步執行來使系統有更高的吞吐量,或保持界面的響應能力。如界面在加載一幅來自網絡的圖像時,還運作使用者進行其他操作。
按前文慣例先上一張圖通覽一下TAP模式下異步程式設計的方方面面,然後由異步程式設計的發展來讨論一下TAP異步模式。
圖1
APM
C# .NET最早出現的異步程式設計模式被稱為APM(Asynchronous Programming Model)。這種模式主要由一對Begin/End開頭的組成。BeginXXX方法用于啟動一個耗時操作(需要異步執行的代碼段),相應的調用EndXXX來結束BeginXXX方法開啟的異步操作。BeginXXX方法和EndXXX方法之間的資訊通過一個IAsyncResult對象來傳遞。這個對象是BeginXXX方法的傳回值。如果直接調用EndXXX方法,則将以阻塞的方式去等待異步操作完成。另一種更好的方法是在BeginXXX倒數第二個參數指定的回調函數中調用EndXXX方法,這個回調函數将在異步操作完成時被觸發,回調函數的第二個參數即EndXXX方法所需要的IAsyncResult對象。
.NET中一個典型的例子如System.Net命名空間中的HttpWebRequest類裡的BeginGetResponse和EndGetResponse這對方法:
由方法聲明即可看出,它們符合前述的模式。
APM使用簡單明了,雖然代碼量稍多,但也在合理範圍之内。APM兩個最大的缺點是不支援進度報告以及不能友善的“取消”。
EAP
在C# .NET第二個版本中,增加了一種新的異步程式設計模型EAP(Event-based Asynchronous Pattern),EAP模式的異步代碼中,典型特征是一個Async結尾的方法和Completed結尾的事件。XXXCompleted事件将在異步處理完成時被觸發,在事件的處理函數中可以操作異步方法的結果。往往在EAP代碼中還會存在名為CancelAsync的方法用來取消異步操作,以及一個ProgressChenged結尾的事件用來彙報操作進度。通過這種方式支援取消和進度彙報也是EAP比APM更有優勢的地方。通過後文TAP的介紹,你會發現EAP中取消機制沒有可延續性,并且不是很通用。
.NET2.0中新增的BackgroundWorker可以看作EAP模式的一個例子。另一個使用EAP的例子是被HttpClient所取代的WebClient類(新代碼應該使用HttpClient而不是WebClient)。WebClient類中通過DownloadStringAsync方法開啟一個異步任務,并有DownloadStringCompleted事件供設定回調函數,還能通過CancelAsync方法取消異步任務。
TAP & async/await
從.NET4.0開始新增了一個名為TPL的庫主要負責異步和并行操作的處理,目标就是使異步和并發操作有個統一的操作界面。TPL庫的核心是Task類,有了Task幾乎不用像之前版本的異步和并發那樣去和Thread等底層類打交道,作為使用者的我們隻需要處理好Task,Task背後有一個名為的TaskScheduler的類來處理Task在Thread上的執行。可以這樣說TaskScheduler和Task就是.NET4.0中異步和并發操作的基礎,也是我們寫代碼時不二的選擇。
對于Task可以将其了解為一個包裝委托對象(通常就是Action或Func對象)并執行的容器,從Task對象的建立就可以看出:
執行這個Task對象需要手動調用Start方法:
這樣task對象将在預設的TaskScheduler排程下去執行,TaskScheduler使用線程池中的線程,至于是建立還是使用已有線程這個對使用者是完全透明的。還也可以通過重載函數的參數傳入自定義的TaskScheduler。
關于TaskScheduler的排程,推薦園子裡這篇文章,前半部分介紹了一些線程執行機制,很值得一度。
當我們用new建立一個Task對象時,建立的對象是Created狀态,調用Start方法後将變為WaitingToRun狀态。至于什麼時候開始執行(進入Running狀态,由TaskScheduler控制,)。Task的建立執行還有一種“快捷方式”,即Run方法:
這種方式建立的Task會直接進入WaitingToRun狀态。
Task的其他狀态還有RanToCompletion,Canceled以及Faulted。在到大RanToCompletion狀态時就可以獲得Task<T>類型任務的結果。如果Task在狀态為Canceled的情況下結束,會抛出 OperationCanceledException。如果以Faulted狀态結束,會抛出導緻任務失敗的異常。
Task同時服務于并發程式設計和異步程式設計(在Jeffrey Richter的CLR via C#中分别稱這兩種模式為計算限制的異步操作和IO限制的異步操作,仔細想想這稱呼也很貼切),這裡主要讨論下Task和異步程式設計的相關的機制。其中最關鍵的一點就是Task是一個awaitable對象,這是其可以用于異步程式設計的基礎。除了Task,還有很多類型也是awaitable的,如ConfigureAwait方法傳回的ConfiguredTaskAwaitable、WinRT平台中的IAsyncInfo(這個後文有詳細說明)等。要成為一個awaitable類型需要符合哪些條件呢?其實就一點,其中有一個GetAwaiter()方法,該方法傳回一個awaiter。那什麼是awaiter對象呢?滿足如下3點條件即可:
實作INotifyCompletion或ICriticalNotifyCompletion接口
有bool類型的IsCompleted屬性
有一個GetResult()來傳回結果,或是傳回void
awaitable和awaiter的關系正如IEnumerable和IEnumerator的關系一樣。推而廣之,下面要介紹的async/await的幕後實作方式和處理yield文法糖的實作方式差不多。
Task類型的GetAwaiter()傳回的awaiter是TaskAwaiter類型。這個TaskAwaiter很簡單基本上就是剛剛滿足上面介紹的awaiter的基本要求。類似于EAP,當異步操作執行完畢後,将通過OnCompleted參數設定的回調繼續向下執行,并可以由GetResult擷取執行結果。
簡要了解過Task,再來看一下本節的重點 - async異步方法。async/await模式的異步也出來很久了,相關文章一大片,這裡介紹下重點介紹下一些不容易了解和值得重點關注的點。我相信我曾經碰到的困惑也是很多人的遇到的困惑,寫出來和大家共同探讨。
文法糖
對async/await有了解的朋友都知道這兩個關鍵字最終會被編譯為.NET中和異步相關的狀态機的代碼。這一部分來具體看一下這些代碼,了解它們後我們可以更準确的去使用async/await同時也能了解這種模式下異常和取消是怎樣完成的。
先來展示下用于分析反編譯代碼的例子,一個控制台項目的代碼,這是能想到的展示異步方法最簡單的例子了,而且和實際項目中常用的代碼結構也差不太多:
注意:控制台版本的示例代碼中在Main函數中使用了task.Result來擷取異步結果,需要注意這是一種阻塞模式,在除控制台之外的UI環境不要使用類似Result屬性這樣會阻塞的方法,它們會導緻UI線程死鎖。而對于沒有SynchronizationContext的控制台應用确是再合适不過了。對于沒有傳回值的Task,可以使用Wait()方法等待其完成。
這裡使用ILSpy去檢視反編譯後的代碼,而且注意要将ILSpy選項中的Decompile async methods (async/await)禁用(如下圖),否則ILSpy會很智能将IL反編譯為有async/await關鍵字的C#代碼。另外我也嘗試過Telerik JustDecompile等工具,但是能完整展示反編譯出的狀态機的隻有ILSpy。
圖2
另外注意,應該選擇Release版本的代碼去檢視,這是在一個Stackoverflow回答中看到的,說是有啥不同,具體也沒仔細看,這裡知道選擇Release版exe/dll反編譯就好了。下面以Service類為例來看一下反編譯後的代碼:
圖3
通過圖上的注釋可以看到代碼主要由兩大部分構成,Service類原有的代碼和一個由編譯器生成的狀态機,下面分别具體了解下它們都做了什麼。依然是以圖檔加注釋為主,重要的部分會在圖後給出文字說明。
圖4
通過上圖中的注釋可以大緻了解GetUserName方法編譯後的樣子。我們詳細介紹下其中幾個點,首先是AsyncTaskMethodBuilder<T>,我感覺很有必要列出其代碼一看:
為了篇幅關系,這裡删除了部分複雜的實作,取而代之的是介紹方法作用的注釋性文字,對于簡單的方法或是重要的方法保留了代碼。
狀态機的幾種狀态如下:
-1:表示還未開始執行
-2:執行結束,可能是正常完成,也可能遇到異常處理異常後結束
0~:下一個狀态。如0表示初始的-1之後的下一個狀态,1表示0後的下一狀态,以此類推。
上面的類中還出現了一個很重要的類型AsyncMethodBuilderCore,簡單的了解一下這個類型也很有必要。
總結來說AsyncTaskMethodBuilder<T>和AsyncMethodBuilderCore控制着狀态機的執行(主要是在正确的Context下調用MoveNext方法),并在執行狀态機的過程中負責正确的設定ExecutionContext和SynchronizationContext。
介紹了這麼多基礎構造,你可能更關心原來的調用Repository的方法的代碼去哪了,它們在狀态機的代碼中。下面就來看一下狀态機:
圖5
通過注釋應該可以了解這個狀态機的細節了。
簡單的說一下這個struct優化。一開始狀态機被作為struct對象放置在棧上,對于await的工作已經完成不需要等待的情況,将快速結束狀态機,這樣狀态機直接出棧效率高。如果await的工作需要等待則控制異步方法執行的AsyncTaskMethodBuilder再将狀态機移動到堆中。因為這種情況下會發生Context切換(在SynchronizationContext不為空的情況下),如果狀态機還在棧上則會導緻很大的切換負擔。
其實搞成一個狀态機的目的主要還是考慮到可能存在多個await的情況。對于隻有1個await的情況其實狀态機的必要性不大,幾個if也就夠了,下面擴充下上面的例子看看有2個以上await(1個和2個await的狀态機都是使用if/else解決問題,從3個起開始不同)時編譯器産生的代碼,首先是擴充後的C#代碼(以WPF應用為例):
依然以Service類為例來分析await編譯後的樣子:
Service中的GetUserAvatar方法中的3個await将把函數體分割為4個異步區間,如下:
圖6
編譯生成的代碼最主要的不同是生成的狀态機變了,依舊是通過截圖和注釋來說一下這個新的狀态機的執行情況(友善對比,注釋将隻标出與之前狀态機不同的部分):
圖7
通過上面的分析,async/await關鍵字背後的秘密已經清清楚楚。下面來說一下線程的問題。
線程!
關于async/await模式線程的問題,剛開始學習async/await那陣,看到很多文章,各種各樣的說法,一度讓我很迷惑。
一種觀點是很多國外同行的文章裡說的:async/await本身不建立線程。StackoverFlow上很多回答也明确說async/await這兩個新增的關鍵字隻是文法糖,編譯後的代碼不建立線程,這曾經一度給我造成了很大的困惑:“不建立線程的話要異步還有啥用!”。
後來看到一種觀點是園友jesse2013博文中的一句話:
await 不會開啟新的線程,目前線程會一直往下走直到遇到真正的Async方法(比如說HttpClient.GetStringAsync),這個方法的内部會用Task.Run或者Task.Factory.StartNew 去開啟線程。也就是如果方法不是.NET為我們提供的Async方法,我們需要自己建立Task,才會真正的去建立線程。
這個這個觀點應該是正确的,可後來看了很多代碼後感覺還不完全是這樣,畢竟一個被調用的async方法就會産生一個新的Task,而這個新的Task可能去“開啟一個新線程”。改造下上面的代碼測試這個問題:
在控制台應用中執行這段代碼會發現輸出的兩個線程Id是不相同的。
提示:控制台引用程式沒有SynchronizationContext,在不恢複SynchronizationContext的情況下能更好的看出線程的變化。
到底情況是怎樣的呢,這裡試着分析下我的想法:
這裡先闡釋清“建立新線程”這個概念。我認為在這種情況下大家說的“建立新線程”可以被認為是與調用方法使用不同的線程,這個線程可能是線程池已有的,也可能是建立并被加入到線程池的線程。明确這給之後,繼續說線程問題。
首先肯定一點async/await關鍵字不會建立新線程是對的。如上文代碼中所示async/await被編譯為一個狀态機的确不參與Task的建立,實際建立Task的是被調用的異步方法。也就是說每調用一次異步方法(每一個await)都會産生一個新的Task,這個Task會自動執行。前面說過Task由TaskScheduler安排執行,一般都會在一個與調用線程不同的線程上執行。
為了把這個問題解釋清楚,假設調用異步方法的線程為A,異步方法啟動後在B線程執行。當B線程開始執行後,A線程将交出控制權。異步方法執行結束後,後續代碼(await後面的代碼)将在B線程上使用A線程的ExecutionContext(和SynchronizationContext,預設情況)繼續執行。
注意這個A線程到B線程控制權的轉換正是async異步模式的精髓之一。在WPF等這樣的用戶端環境這樣做不會阻塞UI線程,使界面不失去響應。在MVC這樣的Web環境可以及時釋放HTTP線程,使Web伺服器可以接收更多請求。畢竟B線程這種線程池中的線程成本更低。這樣就是為什麼既然也要花等待異步操作完成的時間,還要另外使用異步方法的原因 - 及時釋放調用線程,讓低成本的線程去處理耗時的任務。
最後當需要在發起執行的線程(這裡是A線程)上繼續進行處理時隻要獲得當時A線程的ExecutionContext和SynchronizationContext就可以了,并在這些Context完成剩餘操作即可。
如果後續還有其他await,則會出現C線程,D線程等。如B調用了C的話,B的各種Context會被傳遞給C。當從異步方法傳回後,執行的線程變了但是Context沒變。這樣異步方法給我們的感覺就像是同步一般。這也就是async/await方法的精妙之處。
那個Task的ConfigureAwait方法又是做什麼用的呢,了解了上文就很好了解這個方法了。在異步方法傳回時,會發生線程切換,預設情況下(ConfigureAwait(true)時)ExecutionContext和SynchronizationContext都會被傳遞。如果ConfigureAwait(false)則隻有ExecutionContext會被傳遞,SynchronizationContext不會被傳遞。在WPF等用戶端程式UI部分,應該使用預設設定讓SynchronizationContext保持傳遞,這樣異步代碼的後續代碼才能正常操作UI。除此之外的其他情況,如上面的Service類中,都該使用ConfigureAwait(false)以放棄SynchronizationContext的傳遞來提高性能。
下面以圖應該會對上面這段文字有更深的了解:
吐槽一下,本來是想用vs生成的時序圖進行示範呢。結果發現vs2015取消這個功能了。手頭也沒有其他版本的vs。就用代碼截圖來掩飾這個線程變化過程吧。
首先是控制台程式的線程變化情況:
圖8
因為控制台應用沒有SynchronizationContext,是以可以清楚的看到線程的變化。
下面看看在WPF中類似流程執行的樣子:
圖9
可以看到在預設情況下每個await後的異步代碼傳回到都回到UI線程,即所有await的後繼代碼都使用UI線程的SynchronizationContext來執行。除了調用方法外,其它所有的方法沒有必要傳回UI線程,是以我們應該把除調用開始處(即Button_Click方法)外的所有異步調用都配置為ConfigureAwait(false)。
通過上面的圖,可以了解到有SynchronizationContext和沒有SynchronizationContext環境的不同,是否恢複SynchronizationContext的影響。對于ASP.NET環境雖然也有SynchronizationContext,但實測線程切換的表現比較詭異,實在無法具體分析,但按照WPF的方式來配置異步肯定是對的。
其它資料:據CLR via C#作者大神Jeffrey Richter在書中所說,.NET這種以狀态機實作異步的思想來自于其為.NET 4.0寫的Power Threading庫中的AsyncEnumerator類。可以将其作為一個參考來學習async異步方法的機制。
async異步程式設計中的取消和進度報告
由文章開始處的圖1可知,Task天生支援取消,通過一個接收CancellationToken的重載建立的Task可以被通知取消。
自然我們異步方法的取消也離不開CancellationToken,方法就是給異步方法添加接收CancellationToken的重載,如前文示例代碼Service中的方法可以添加一個這樣的重載支援取消:
async異步程式設計最大的一個特點就是傳播性,即如果有一個異步方法,則所有調用這個方法的方法都應該是異步方法,而不能有任何同步方法(控制台應用Main函數中那種把異步轉同步的方式除外)。而通過CancellationToken實作的取消模式可以很好的适配這種傳播性,所需要做的就是把所有異步方法都添加支援CancellationToken的重載。之前的例子改造成支援取消後如下(展示一部分):
注意ct.ThrowIfCancellationRequested()調用,這是可以及時取消後續未完成代碼的關鍵。當執行這個語句時,如果ct被标記取消,則這個語句抛出OperationCanceledException異常,後續代碼停止執行。
和取消機制一樣,新版的.NET也為進度通知提供了内置類型的支援。IProgress<T>和Progress<T>就是為此而生。類型中的泛型參數T表示Progress的ProgressChanged事件訂閱的處理函數的第二個參數的類型。擴充之前的例子,把它改成支援進度報告的方法:
可以看到在async異步模式下取消和進度都很容易使用。
以上介紹了擁有async/await支援的TAP異步程式設計。在編寫新的異步代碼時應該優先選用TAP模型,而且新版的.NET庫幾乎給所有同步接口增加了這種可以通過async/await使用的異步接口。但往往項目中會存在一些使用APM或EAP模式的代碼,通過下面介紹的一些方法可以使用async/await的方式調用這些代碼。
将BeginXXX/EndXXX的APM模式代碼轉為async異步方法隻需要利用TaskFactory類的FromAsync方法即可,我們以介紹APM時提到的HttpWebRequest為例:
TaskFactory的FromAsync方法中使用TaskCompletionSource<T>來構造Task對象。
封裝EAP模式的代碼要比APM麻煩一些,我們需要手動構造TaskCompletionSource對象(代碼來自,手打的)。
可以看到TaskCompletionSource提供了一種手動指定Task結果來構造Task的方式。
上面寫了那麼多,真沒有資訊保證全部都是正确的。最後推薦3篇文章,相信它們對了解async異步方法會有很大幫助,本文的很多知識點也是來自這幾篇文章:
Understanding C# async / await (1) Compilation
Understanding C# async / await (2) Awaitable-Awaiter Pattern
Understanding C# async / await (3) Runtime Context
WinRT是完全不同于.NET的一種架構,目地就是把Windows的底層包裝成API讓各種語言都可以簡單的調用。WinRT中對異步的實作也和.NET完全不同,這一小節先看一下WinRT中異步機制的實作方法,再來看一下怎樣使用C#和.NET與WinRT中的異步API進行互動。
前文提到async異步程式設計中兩個比較重要的對象是awaitable和awaiter。在WinRT中充當awaitable的是IAsyncInfo接口的對象,具體使用中有如下4個實作IAsyncInfo接口的類型:
IAsyncAction
IAsyncActionWithProgress<TProgress>
IAsyncOperation<TResult>
IAsyncOperationWithProgress<TResult, TProgress>
由泛型參數可以看出Action和Operation結尾的兩個類型不同之處在于IAsyncAction的GetResults方法傳回void,而IAsyncOperation<TResult>的GetResults方法傳回一個對象。WithProgress結尾的類型在類似類型的基礎上增加了進度報告功能(它們内部定義了Progress事件用來執行進度變更時的處理函數)。
Task和IAsyncInfo分别是對.NET和WinRT中異步任務的包裝。它們的原理相同但具體實作有所不同。IAsyncInfo表示的任務的狀态(可以通過Status屬性查詢)有如下幾種(和Task對照,整理自MSDN):
Task狀态
(TaskStatus類型)
IAsyncInfo狀态
(AsyncStatus類型)
RanToCompletion
Completed
Faulted
Error
Canceled
所有其他值和已請求的取消
所有其他值和未請求的取消
Started
另外擷取異常的方式也不一樣,通過Task中的Exception屬性可以直接得到.NET異常,而IAsynInfo中錯誤是通過ErrorCode屬性公開的一個HResult類型的錯誤碼。當時用下文價紹的方法将IAsynInfo轉為Task時,HResult會被映射為.NET Exception。
之前我們說這些IAsyncXXX類型是awaitable的,但為什麼這些類型中沒有GetAwaiter方法呢。真相是GetAwaiter被作為定義在.NET的程式集System.Runtime.WindowsRuntime.dll中的擴充方法,因為基本上來說async/awati還是C#使用的關鍵字,而C#主要以.NET為主。
這些擴充方法聲明形如(有多個重載,下面是其中2個):
我們又見到了熟悉的TaskAwaiter。這個方法的實作其實也很簡單(以第一個重載為例):
可以看到就是通過task.GetAwaiter得到的TaskAwaiter對象。
這一系列擴充方法的背後又有一個更重要的擴充方法 - AsTask()。
AsTask方法有更多的重載,其實作原理和前文介紹将EAP包裝為async異步模式的代碼差不多,都是通過TaskCompletionSource來手工構造Task。下面展示的是一個最複雜的重載的實作:
通過參數可以看到,這個轉換Task的過程支援調用方法傳入的取消和進度報告。如果我們需要調用的WinRT異步方法的過程中支援取消和進度報告,就不能直接await那個異步方法(相當于調用了預設無參的AsTask的傳回task上的GetAwaiter方法),而是應該await顯示調用的AsTask(可以傳入CancellationToken及IProgress參數的重載,上面那個)傳回的task對象。這個可以見本小節末尾處的例子。
回頭看一下上面給出的AsTask的實作。裡面一個最終要的對象就是TaskToAsyncOperationWithProgressAdapter<TResult, TProgress>,其可以由IAsyncOperationWithProgress<TResult, TProgress>直接轉型而來。它也是IAsyncOperationWithProgress<TResult, TProgress>和Task之間的一個橋梁。這個類的工作主要由其父類TaskToAsyncInfoAdapter<TCompletedHandler, TProgressHandler, TResult, TProgressInfo>來完成。這個父類的實作就比較複雜了,但道理都是相同的。有興趣的同學自行檢視其實作吧。
了解了原理最後來看一下代碼示例,WinRT中所有的IO相關的類中隻提供異步方法,示例是以也選擇了這個使用最廣泛的功能(示例代碼來源是某開源庫,具體是啥忘了,有輕微改動):
有了async/await和上文介紹的擴充方法的支援,C#調用WinRT的異步接口和使用.NET中的異步接口一樣的簡單。
如果是需要傳遞取消和進度報告怎麼辦呢?
代碼的簡潔程度讓你感到震撼吧。而且得到Task對象後,不但可以友善的配置取消和進度報告,還能通過ConfigureAwait來配置SynchronizationContext的恢複。
不知道參數ct和progress怎麼來的同學可以看上一小節的取消和異步部分。
除了由IAsyncInfo到Task的轉換外,還可以由Task/Task<T>轉為IAsyncAction/IAsyncOperation<T>。這個轉換的主要作用是把C#寫的代碼封裝為WinRT供其它語言調用。實作這個操作的AsAsyncAction/AsAsyncOperation<T>方法也是定義于上面提到的System.Runtime.WindowsRuntime.dll程式集中。以本文第一小節的Service類為例,将其GetUserName方法改造成傳回IAsyncOperation<string>的方法,如下:
這兩個擴充方法是用簡單友善,但有一點不足的就是不能支援Task中的取消和進度報告。要解決這個問題可以使用IAsyncInfo的Run方法來獲得IAsynInfo對象。Run方法支援多種不同類型的委托對象作為參數,比較複雜的一種可以支援取消和進度報告作為委托對象(一般是lambda表達式)的參數,比如把上面的例子改成支援取消和進度報告後如下:
内幕這樣就輕松的實作了将C#編寫的代碼作為WinRT元件的過程。從如下AsAsyncOperation和AsyncInfo.Run的反編譯代碼來看,很難知道這個方法的實作細節,畢竟它們都是和WinRT Native代碼相關的部分。
微軟對C++進行了擴充,一方面是為C++實作類似C#中基于Task的線程管理方式,另一方面讓C++(準确說是C++/CX)可以實作與WinRT規範的的異步接口互操作。
這些擴充主要定義于ppltask.h中,concurrency命名空間下。
concurrency::task
先來看一下和.NET Task基本等價的task類型。這也是微軟C++擴充中并發異步線程管理的核心類型之一。微軟圍繞concurrency::task的設計的一些方法與C#中的Task相關方法真的非常下。下面的表格對比了C#的Task與C++中的concurrency::task。有C# Task基礎的話,對于concurrency::task很容易就能上手。
C# Task
C++ concurrency::task
構造 方式1
constructor
構造 方式2
Task.Factory.StartNew()
用于異步 - create_task()
構造 方式3
用于并行 - make_task()
傳回task_handle,和task_group等同用。
阻塞 - 等待完成
task.Wait()
task::wait()
阻塞 - 等待擷取結果
GetAwaiter().GetResult()
task::get()
任務狀态類型
TaskStatus
concurrency::task_status
并行 - 等待全部
Task.WhenAll()
concurrency::when_all
并行 - 等待部分
Task.WhenAny()
concurrency::when_any
異步 - 任務延續
Task.ContinueWith()
task::then()
接着讨論一下本節的重點内容,微軟給C++帶來的異步支援。
普通異步
看過之前介紹C#異步的部分,可以知道支援異步的系統無非就由以下以下幾部分組成:任務建立、任務延續、任務等待、取消、進度報告等。依次來看一下ppltask.h中支援這些部分的方法。
create_task方法可以将函數對象(廣義上的函數對象包含如lambda表達式,在C++11中也多用lambda表達式作為函數對象)包裝成task類對象。如上文所述,定義在ppltask.h中,位于concurrency命名空間下的task類和異步方法關系最密切。下面的代碼示例了concurrency::task的建立。
在C++11中一般都使用auto直接表示一些複雜的類型,讓編譯器去推斷。例子中寫出完整的類型可以讓讀者更好的了解方法的傳回類型。
而類似于.NET Task中的ContinueWith方法的task::then方法,基本使用如下:
在C++中由于沒有類似C#中async/await關鍵字的支援,是以後續任務不能像C#中那樣直接跟在await ...語句後,必須通過task::then方法來設定。
then方法也可以實作鍊式調用,如:
關于後續代碼執行上下文的問題,如果create_task方法接受的函數對象傳回的是task<T>或task<void>則後續代碼會在相同的線程上下文運作,如果傳回的是T或void則後續任務會在任意上下文運作。可以使用concurrency::task_continuation_context來更改這個設定。具體用法是将task_continuation_context傳給task::then其中那些接受task_continuation_context類型參數的重載。如果參數值為concurrency::task_continuation_context::use_arbitrary,則表示指定延續在背景線程上運作,如果參數值為concurrency::task_continuation_context::use_current,則表示指定延續在調用了task::then的線程上運作。如:
對于取消和異步的支援,将在下一小段進行介紹,那裡的實作方式同樣可以應用到這一部分中。
使用create_task的方式建立task的方法隻用于C++内部對task的管理。如果是希望将異步作為WinRT元件釋出需要使用下面介紹的create_async。
如果是純C++中處理多線程任務,除了使用Windows中所提供的task,還可以考慮C++11标準庫中的thread,後者跨平台更好。後文會有一部分介紹C++11的thread。如果是對C#的TPL模型很熟悉,轉到C++使用ppltask.h中的task會發現模型一緻性很高。
支援WinRT的異步
1. 提供WinRT标準的異步方法
通過create_async方法可以将函數轉為異步函數,即這個方法是傳回IAsyncInfo對象的。通過這個方法可以将代碼包裝成WinRT中标準的異步方法供其它語言調用。被包裝的代碼一般是可調用對象,在C++11中一般都使用Lambda表達式。傳回的IAsyncInfo的具體類型(上文介紹的四種之一)是有傳入的參數決定的。
create_async的聲明:
可以看到為了确定這個模闆方法的傳回類型使用了C++11的decltype和位置傳回類型等新特性。
通常情況下,傳入create_async的函數對象的方法體是一般的代碼。還以把create_task方法的調用傳入create_async接收的lambda表達式的方法體中,create_task傳回的concurrency::task也可以配置一系列的then(),最終這些配置都将反應給最外部的create_async的包裝。
下面的代碼就是包裝了最簡單的過程代碼:
也可以像上面說的包裝一段create_task的代碼(把C++内部的任務暴露給WinRT接口):
通過create_async的重載也可以輕松的支援取消和進度報告。
擴充的C++使用的異步模式與C# TPL使用的标記式取消模型一緻,但在使用上還是稍有不同,在介紹這種模式之前,先來說說取消延續的問題,如下面的代碼:
這個例子中可以看到,我們可以在task内部方法中通過cancel_current_task()調用來取消目前的任務。如果t1被手動取消,對于t1的兩個後繼任務t2和t3,t2會被取消,t3不會被取消。這是由于t2是基于值延續的延續,而t3是基于任務的延續。
接下來的示例展示了C++中 的标記式取消:
通過使用cancellation_token,取消也可以傳遞到基于任務的延續。
上面示範的例子cancellation_token是在create_async方法内部定義的,更常見的情況在create_async的工作方法參數中顯示聲明cancellation_token并傳入到工作方法内,這樣IAsyncXXX上面的Cancel方法被調用,取消标志也會被自動設定,進而觸發鍊式的标記性取消。
說起來很抽象,可以參考下面的代碼:
這樣當DoSomething傳回值(IAsyncAction對象)的Cancel方法被調用後,ct被标記為取消,任務t會在合适的時間被取消執行。
C++的cancellation_token有一個更進階的功能:其上可以設定回調函數,當cts觸發取消時,token被标記為取消時,會執行這個回調函數的代碼。
說完取消,再來看一下進度報告。下面的例子基本是示範進度報告最簡單的例子。
我們将一個concurrency::progress_reporter<T>對象當作參數傳入create_async接收的工作函數。然後就可以使用reporter的report方法來報告進度。傳回的IAsyncOperationWithProgress類型可以使這個進度報告與WinRT中調用這個方法的代碼協同工作。
2. 調用WinRT标準的異步方法
說了建立異步方法,再來看看使用C++調用WinRT的異步方法。由于C++中沒有async/await那樣的異步模式,是以最值得關心的就是如何,是以當一個任務完成後需要手動傳入剩餘的代碼來繼續後續任務的執行,這裡需要用到task的then方法,首先我們需要把IAsyncInfo轉為task。(其實上面的代碼已經示範了這個用法)
不同于C#中通過AsTask方法将IAsyncInfo等類型轉為Task對象。C++中是使用create_task的方法(就是上面介紹的那個,不同的重載)來完成這個工作:
接着調用task的then方法設定後續執行:
捕獲異常方面,不涉及WinRT的部分遵循C++的異常捕獲原則,WinRT互動部分,需要保證抛出的異常可以被WinRT識别處理。
除了使用ppltask.h中的擴充,還可以使用WRL中的AsyncBase模闆類來實作C++對WiinRT異步的支援。但後者的代碼過于晦澀,就不再介紹了。
說回來和WinRT互動就好用的語言還是C#,C++可以用于實作純算法部分,即位于WinRT下方的部分,隻需要在必要的時候通過WinRT公開讓C#可調用的接口。這樣代碼的編寫效率和執行效率都很高。另外C#的應用商店程式支援本地編譯也是大勢所趨,在WinRT之上使用C#或C++/CX差別不大。
C++在沉寂多年之後,終于在新版标準中迎來爆發,其中标準内置的線程支援就是一個完全全新的特性。在之前版本的C++中沒有标準的線程庫,實作跨平台的線程操作一般都要借助于第三方的庫。現在有了C++11,相同的操作線程的代碼可以在不同的編譯器上編譯執行進而可以實作跨平台的線程操作。
C++新标準中的線程,異步等看起來和C#的機制非常的像,不知道微軟和C++标準委員會誰“借鑒”的誰。
下面按線程,并發中同步支援,異步這樣的順序來逐個了解下C++新标準中增加的這些特性。介紹方式以C#的等價機制做對比,篇幅原因很多都是一個綱領作用,介紹一筆帶過,根據需要大家自行查找相應的功能的具體使用方法。
線程
C++11标準庫中引入了std::thread作為抽象線程的類型。其很多操作和.NET中的Thread類似。
C++ 11
C#
std::thread
Thread
建立
插入一個線程
t.join() t表示std::thread對象,下同
t.Join() t表示Thread對象,下同
分離線程
t.detach()
無
擷取線程id
t.get_id()
Thread.CurrentThread.ManagedThreadId
線程休眠
std::this_thread::sleep_for()
Thread.Sleep()
一段簡單的綜合示例代碼:
多線程 - 互斥
C++11中内建了互斥機制,可以讓多個線程安全的通路同一個變量。幾種機制總結如下(可能并非完全一直,但效果上很類似)
原子類型
atomic_type
std::atomic<T>
Interlocked
記憶體栅欄
memory_order_type
MemoryBarrier
線程本地存儲
thread_local
ThreadStatic
LocalDataStoreSlot
ThreadLocal<T>
互斥
std::mutex
std::timed_mutex
std::recursive_mutex
std::recursive_timed_mutex
Mutex
鎖
lock_guard<T>
lock
通知
condition_variable
condition_variable_any
(notify_one/notify_all)
ManualResetEvent
AutoResetEvent
初始化
call_once
上面介紹的線程或多線程支援都是一些很底層的接口。針對異步操作C++11還提供了一些進階接口,其中具有代表性的對象就是std::future和std::async。
std::future和C#中的TaskAwaiter比較相似,而std::async作用正如C#中使用async關鍵字标記的異步方法。在C++11中通過std::async将一個可調用對象包裝廠一個異步方法,這個方法将傳回一個std::future對象,通過std::future可以得到異步方法的結果。
看一下這段代碼(來自qicosmos老師的博文)就能明白上面所說:
關于C++11異步方面的特性,強烈推薦qicosmos老師的博文以及他編寫的圖書《深入應用C++11:代碼優化與工程級應用》。
新版本的C#提供了友善擷取方法調用者資訊的功能,對于需要調試以及輸出一些日志的情況很有用。這樣我們不需要像之前那樣在每個需要記錄日志的地方寫死下調用的方法名,提高了代碼的可讀性。
提供這個新功能的是幾個應用于參數的Attribute:
CallerFilePathAttribute 獲得調用方法所在的源檔案位址
CallerLineNumberAttribute 被調用代碼的行号
CallerMemberNameAttribute 調用方法的名稱
使用其簡單隻需要聲明一個參數,然後把這些Attribute加在參數前面,在函數中取到的參數值就是我們想要的結果。一個簡單的例子如下:
輸出如下:
Main C:\Users\...\ConsoleApplication1\Program.cs 31
還算是簡單友善,尤其對于輸出日志來說。
C#5.0還對Lambda捕獲閉包外變量進行了一些小優化,這個在之前文章介紹Lambda時有介紹,這裡不再贅述。
在C中就有宏來完成類似的功能。由于C++可以相容C,是以在C++11之前,一般都用這種C相容的方式來獲得被調用方法的資訊。新版的C++對此進行了标準化,增加了一個名為__func__的宏來完成這個功能。
需要注意的是和C#中類似功能獲得調用方法名稱不同,這個__func__宏得到的是被調用方法,即__func__所在方法的名稱。個人感覺C++中__func__更實用。仍然是一個簡單的例子:
調用Caller()将輸出"Called"。
C++中實作這個宏的方式就是在編譯過程中在每個方法體的最前面插入如下代碼:
了解這個之後你會感覺這個宏沒有那麼神秘了。
除了新被标準化的__func__在大部分C++編譯器中仍然可以使用__LINE__和__FILE__擷取目前行号和所在檔案。
下篇文章将介紹C#6帶來的新特性,C#6中沒有什麼重量級的改進(據說編譯器好像有很大改動,那個不了解就不說了,不是一般使用者能仔細研究的。編譯前端和編譯後端發展這麼多年複雜程度接近作業系統了),大都是一些文法糖,而且糖的數量還不少。歡迎繼續關注。
本文斷斷續續寫了很久,中間還出去玩了2周。有什麼錯誤請指正。