曾一直贊揚異步編程模型 (APM) 的優點,強調異步執行 I/O 密集型操作是生產高響應和可伸縮應用程序及組件的關鍵。這些目標是可以達成的,因為 APM 可讓您使用極少量的線程來執行大量的工作,而無需阻止任何線程。遺憾的是,使用 APM 構建典型的應用程序或組件有些難度,因此許多程序員不願意去做。
有幾個因素使得 APM 難以實現。首先,您需要避免狀態數據駐留於線程的堆棧上,因為線程堆棧上的數據不能遷移到其他線程。避免基於堆棧的數據意味著必須避免方法參數和局部變量。多年以來,開發人員已喜歡上參數和局部變量,因為它們使編程變得簡單多了。
其次,您需要將代碼拆分為幾個回調方法,有時稱為“續”。例如,如果在一個方法中開始異步讀取或寫入操作,之後必須實現要調用的另一個方法(可能通過不同的線程),以處理 I/O 操作的結果。但程序員就是不習慣考慮以這種方法進行數據處理。在方法和線程之間遷移狀態意味著狀態必須進行打包,導致實現過程復雜化。
再次,眾多有用的編程構造 — 如 try/catch/finally 語句、lock 語句、using 語句,甚至是循環(for、while 和 foreach 語句)— 不能在多個方法之間進行拆分。避免這些構造也增加了實現過程的復雜性。
最後,嘗試提供多種功能,如協調多個重疊操作的結果、取消、超時,以及將 UI 控件修改封送到 Windows® 窗體或 Windows Presentation Foundation (WPF) 應用程序中的 GUI 線程,這都為使用 APM 增加了更多的復雜性。
在本期的專欄中,我將演示 C# 編程語言的一些最新添加內容,它們大大簡化了異步編程模型的使用。之後我會介紹我自己的一個類,稱為 AsyncEnumerator,它建立在這些 C# 語言功能的基礎上,用來解決我剛提到的問題。我的 AsyncEnumerator 類能夠讓您在代碼中使用 APM 變得簡單而有趣。通過此類,您的代碼會變得可伸縮且高響應,因此沒有理由不使用異步編程。請注意,AsyncEnumerator 類是 Power Threading 庫的一部分,並且依賴於同樣是此庫一部分的其他代碼;讀者可從 Wintellect.com 下載該庫。
匿名方法和 lambda 表達式
SynchronousPattern 方法顯示了如何同步打開並讀取文件。該方法簡單明了;它會構造一個 FileStream 對象,分配 Byte[],調用 Read,然後處理返回的數據。C# using 語句可確保完成數據處理後會關閉該 FileStream 對象。
ApmPatternWithMultipleMethods 方法顯示了如何使用公共語言運行時 (CLR) 的 APM,來執行與 SynchronousPattern 方法相同的操作。您會立即看到實現過程要復雜得多。請注意,ApmPatternWithMultipleMethods 方法會啟動異步 I/O 操作,操作完成時會調用 ReadCompleted 方法。同時請注意,兩個方法之間的數據傳遞是通過將共享數據封裝到 ApmData 類的實例來完成的,為此我必須專門進行定義,以便啟用這兩個方法之間的數據傳遞。還應注意,不能使用 C# using 語句,因為 FileStream 是在一個方法中打開,然後在另一個方法中關閉的。為彌補這個問題,我編寫了代碼,用於在 ReadCompleted 方法返回之前顯式調用 FileStream 的 Close 方法。
ApmPatternWithAnonymousMethod 方法展示了如何使用 C# 2.0 稱為匿名方法的新功能重新編寫此代碼,通過此功能您可以將代碼作為參數傳遞到方法。它能有效地讓您將一個方法的代碼嵌入到另一個方法的代碼中。(我在所著書籍“CLR via C#”(CLR 編程之 C# 篇)(Microsoft Press, 2006) 中詳細說明了匿名方法。)請注意,ApmPatternWithAnonymousMethod 方法要簡短得多,也更易於理解 — 在習慣使用匿名方法後就可以體會到這一點。
首先,請注意該代碼較簡單,因為它完全包含在一個方法內。在此代碼中,我將調用 BeginRead 方法啟動異步 I/O 操作。所有 BeginXxx 方法會將其第二個至最後一個參數視為一個引用方法的委托,即 AsyncCallback,該方法在操作完成時由線程池線程進行調用。通常,使用 APM 時,您必須編寫單獨的方法,為該方法命名,並通過 BeginXxx 方法的最後一個參數將額外數據傳遞到該方法。但是,匿名方法功能允許只編寫單獨的內嵌方法,這樣啟動請求和處理結果的所有代碼便會和環境協調。實際上,該代碼看上去與 SynchronousPattern 方法有些類似。
其次,請注意 ApmData 類不再是必需的;您不需要定義該類、構造其實例以及使用它的任何字段!這是如何實現的?其實,匿名方法的作用不僅僅限於將一個方法的代碼嵌入另一個方法的代碼中。當 C# 編譯器檢測到外部方法中聲明的任何參數或局部變量也用於內部方法時,該編譯器實際上會自動定義一個類,並且兩個方法之間共享的每個變量會成為此編譯器定義的類中的字段。然後,在 ApmPatternWithAnonymousMethod 方法內,編譯器會生成代碼以構造此類的實例,且引用變量的任何代碼都會編譯成訪問編譯器所定義類的字段的代碼。編譯器還使得內部方法成為新類上的實例方法,允許其代碼輕松地訪問字段,現在兩個方法可以共享數據。
這是匿名方法的出色功能,它可讓您像使用方法參數和局部變量一樣編寫代碼,但實際上編譯器會重新編寫您的代碼,從堆棧中取出這些變量,並將它們作為字段嵌入對象。對象可在方法之間輕松傳遞,並且可以從一個線程輕松遷移到另一個線程,這對於使用 APM 而言是十分完美的。由於編譯器會自動執行所有的工作,您可以很輕松地將最後一個參數的空值傳遞到 BeginRead 方法,因為現在沒有要在方法和線程之間顯式傳遞的數據。但是,我仍然無法使用 C# using 語句,因為此處有兩個不同的方法,盡管看上去似乎只有一個方法。
以下內容顯示了執行圖 1 中摘錄的代碼後的輸出。
Primary ThreadId=1
ThreadId=1: 4D-5A-90-00-03 (SynchronousPattern)
ThreadId=3: 4D-5A-90-00-03 (ApmPatternWithMultipleMethods)
ThreadId=3: 4D-5A-90-00-03 (ApmPatternWithAnonymousMethod)
ThreadId=3: 4D-5A-90-00-03 (ApmPatternWithLambdaExpression)
ThreadId=3: 4D-5A-90-00-03 (ApmPatternWithIterator)
我讓 Main 方法顯示應用程序主線程的托管線程 ID。然後我讓 ProcessData 方法顯示執行該方法的線程的托管線程 ID。如您所見,輸出顯示了所有異步模式讓主線程之外的其他線程執行結果,而同步模式則讓應用程序的主線程執行所有工作。
還應指出,C# 3.0 引入了一個新功能,稱為 lambda 表達式。在執行同一操作時,lambda 表達式功能的語法比 C# 匿名方法功能更簡潔。實際上,這對於 C# 團隊來說是一個麻煩,因為現在它必須記錄和支持產生相同結果的兩個不同語法。為了使用 C# 3.0 lambda 表達式功能,ApmPatternWithAnonymousMethod 方法經修改後成為圖 1 中所示的 ApmPatternWithLambdaExpression 方法。在此處可以看到語法略為簡化,因為編譯器能夠自動推斷出結果參數的類型為 IAsyncResult,而且“=>”要鍵入的內容比“delegate”少。
foreach 語句
C# 2.0 為 C# 編程語言引入了另一種功能:迭代器。迭代器功能的最初目的是讓開發人員能夠輕松地編寫代碼,遍歷集合的內部數據結構。要了解迭代器,必須首先好好看一下 C# foreach 語句。編寫如下代碼時,編譯器會將它轉化為如圖 2 所示的內容:
foreach (String s in collectionObject)
DoSomething(s);
可以看到,foreach 語句提供了遍歷集合類中所有項目的簡便方法。但是,有很多不同種類的集合類 — 數組、列表、詞典、樹、鏈接列表等等 — 每個均使用其內部數據結構來表示項目集合。使用 foreach 語句就是代表要遍歷集合中的所有項目,而並不關注集合類內部用來維護其各個項目的數據結構。
foreach 語句使得編譯器生成對集合類的 GetEnumerator 方法的調用。此方法創建了一個實現 IEnumerator 接口的對象。此對象知道如何遍歷集合的內部數據結構。while 循環的每次迭代都會調用 IEnumerator 對象的 MoveNext 方法。此方法告訴對象前進到集合中的下一個項目,如果成功,則返回 true,如果所有項目均已枚舉,則返回 false。在內部,MoveNext 可能只會遞增索引,也可能會前進到鏈接列表的下一個節點,或者它可能會向上向下遍歷樹。整個 MoveNext 方法的要點是,從執行 foreach 循環的代碼中抽象出集合的內部數據結構。
如果 MoveNext 返回 true,則調用 Current 屬性的 get 訪問器方法會返回 MoveNext 方法抵達項的值,以便 foreach 語句(try 塊內部的代碼)的主體能處理該項目。當 MoveNext 確定集合的所有項目均已得到處理時,會返回 false。
此時,while 循環退出,並進入 finally 塊。在 finally 塊中,通過檢查來確定 IEnumerator 對象是否實現 IDisposable 接口,如果是,則調用其 Dispose 方法。對記錄而言,IEnumerator 接口由 IDisposable 派生而來,因此需要所有 IEnumerator 對象來實現 Dispose 方法。一些枚舉器對象在迭代過程中需要附加資源。對象可能會返回文本文件的文本行。foreach 循環退出後(可能會在遍歷集合中的所有項目之前發生),finally 塊會調用 Dispose,允許 IEnumerator 對象釋放這些附加資源 — 例如,關閉文本文件。