目錄
匿名方法和 lambda 表達式
foreach 語句
迭代器
異步編程
我曾一直贊揚異步編程模型 (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 表達式
圖 1 中,SynchronousPattern 方法顯示了如何同步打開並讀取文件。該方法簡單明了;它會構造一 個 FileStream 對象,分配 Byte[],調用 Read,然後處理返回的數據。C# using 語句可確保完成數據 處理後會關閉該 FileStream 對象。
Figure1ApmPatterns 類的摘錄
private static void SynchronousPattern() {
using (FileStream fs =
new FileStream(typeof(ApmPatterns).Assembly.Location,
FileMode.Open, FileAccess.Read, FileShare.Read, 8192,
FileOptions.Asynchronous)) {
Byte[] data = new Byte[fs.Length];
Int32 bytesRead = fs.Read(data, 0, data.Length);
ProcessData(data);
}
}
#region APM Pattern with Multiple Methods
private sealed class ApmData {
public FileStream m_fs;
public Byte[] m_data;
}
private static void ApmPatternWithMultipleMethods() {
ApmData apmData = new ApmData();
apmData.m_fs =
new FileStream(typeof(ApmPatterns).Assembly.Location,
FileMode.Open, FileAccess.Read, FileShare.Read, 8192,
FileOptions.Asynchronous);
apmData.m_data = new Byte[apmData.m_fs.Length];
apmData.m_fs.BeginRead(apmData.m_data, 0, apmData.m_data.Length,
ReadCompleted, apmData);
// Method returns after issuing async I/O request
}
private static void ReadCompleted(IAsyncResult result) {
ApmData apmData = (ApmData) result.AsyncState;
Int32 bytesRead = apmData.m_fs.EndRead(result);
ProcessData(apmData.m_data);
apmData.m_fs.Close(); // No 'using'
}
#endregion
private static void ApmPatternWithAnonymousMethod() {
FileStream fs =
new FileStream(typeof(ApmPatterns).Assembly.Location,
FileMode.Open, FileAccess.Read, FileShare.Read, 8192,
FileOptions.Asynchronous);
Byte[] data = new Byte[fs.Length];
fs.BeginRead(data, 0, data.Length, delegate(IAsyncResult result) {
Int32 bytesRead = fs.EndRead(result);
ProcessData(data);
fs.Close(); // No 'using'
}, null);
// Method returns after issuing async I/O request
}
private static void ApmPatternWithLambdaExpression() {
FileStream fs =
new FileStream(typeof(ApmPatterns).Assembly.Location,
FileMode.Open, FileAccess.Read, FileShare.Read, 8192,
FileOptions.Asynchronous);
Byte[] data = new Byte[fs.Length];
fs.BeginRead(data, 0, data.Length, result => {
Int32 bytesRead = fs.EndRead(result);
ProcessData(data);
fs.Close(); // No 'using'
}, null);
// Method returns after issuing async I/O request
}
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 所示的內容:
Figure2C# 中的 foreach 循環
// Construct an object that knows how to traverse the
// internal data structure of the collection
IEnumerator<String> e = collectionObject.GetEnumerator();
try {
String s;
while (e.MoveNext()) { // Advance to the next item
s = e.Current; // Get the item to process
DoSomething(s);
}
}
finally {
// If the IEnumerable implements IDisposable,
// clean up the object
IDisposable d = e as IDisposable;
if (d != null) d.Dispose();
}
foreach (String s in collectionObject)
DoSomething(s);
可以看到,foreach 語句提供了遍歷集合類中所有項目的簡便方法。但是,有很多不同種類的集合類 — 數組、列表、詞典、樹、鏈接列表等等 — 每個均使用其內部數據結構來表示項目集合。使用 foreach 語句就是代表要遍歷集合中的所有項目,而並不關注集合類內部用來維護其各個項目的數據結構 。
foreach 語句使得編譯器生成對集合類的 GetEnumerator 方法的調用。此方法創建了一個實現 IEnumerator<T> 接口的對象。此對象知道如何遍歷集合的內部數據結構。while 循環的每次迭代 都會調用 IEnumerator<T> 對象的 MoveNext 方法。此方法告訴對象前進到集合中的下一個項目, 如果成功,則返回 true,如果所有項目均已枚舉,則返回 false。在內部,MoveNext 可能只會遞增索引 ,也可能會前進到鏈接列表的下一個節點,或者它可能會向上向下遍歷樹。整個 MoveNext 方法的要點是 ,從執行 foreach 循環的代碼中抽象出集合的內部數據結構。
如果 MoveNext 返回 true,則調用 Current 屬性的 get 訪問器方法會返回 MoveNext 方法抵達項的 值,以便 foreach 語句(try 塊內部的代碼)的主體能處理該項目。當 MoveNext 確定集合的所有項目 均已得到處理時,會返回 false。
此時,while 循環退出,並進入 finally 塊。在 finally 塊中,通過檢查來確定 IEnumerator<T> 對象是否實現 IDisposable 接口,如果是,則調用其 Dispose 方法。對記錄而 言,IEnumerator<T> 接口由 IDisposable 派生而來,因此需要所有 IEnumerator<T> 對象 來實現 Dispose 方法。一些枚舉器對象在迭代過程中需要附加資源。對象可能會返回文本文件的文本行 。foreach 循環退出後(可能會在遍歷集合中的所有項目之前發生),finally 塊會調用 Dispose,允許 IEnumerator<T> 對象釋放這些附加資源 — 例如,關閉文本文件。
迭代器
現在您已了解 foreach 語句產生的代碼,下面讓我們看一下對象如何與其 MoveNext 和 Current 成 員實現 IEnumerable<T> 接口。我們先討論數組,它們可能是所有集合數據結構中最簡單的結構。 System.Array 類是所有數組的基類,實現 IEnumerable<T> 接口。它的 GetEnumerator 方法會返 回一個對象,其類型實現 IEnumerator<T> 接口。因此,您可以輕松編寫代碼,以遍歷數組的所有 元素:
Int32[] numbers = new Int32[] { 1, 2, 3 };
foreach (Int32 num in numbers)
Console.WriteLine(num);
另外,如果 C# 編譯器檢測到與 foreach 一同使用的集合是一個數組,則編譯器會產生優化代碼,具 有從 0 到數組長度減一的簡單索引。但是在本次討論中,讓我們忽略編譯器對此進行的優化,假設編譯 器像對待其他任何集合類一樣對待數組。實際上,您可以強制編譯器像對待其他任何集合類一樣對待數組 ,只需將其轉換為 IEnumerable<T> 即可,如下所示:
foreach (Int32 num in (IEnumerable<Int32>) numbers)
Console.WriteLine(num);
我的 ApmPatterns 類包括一個 ArrayEnumerator<T> 類,該類知道如何以相反的順序返回數組 中的每一個項目(請參見圖 3)。此類可實現 IEnumerator<T> 接口,這要求該類同時實現非泛型 IEnumerable 和 IDisposable 接口。AsyncEnumerator<T> 類提供了公共的構造函數,可將傳遞的 數組保存在私有成員中。還有另一個私有成員 m_index,指示您當前要迭代至數組中的哪個項目;此字段 初始化為數組的長度(即數組中最後一個元素之後的元素)。您可以在圖 3 中看到。
Figure3迭代器
private static void Iterators() {
Int32[] numbers = new Int32[] { 1, 2, 3 };
// Use Array's built-in IEnumerable
foreach (Int32 num in numbers)
Console.WriteLine(num);
// Use my ArrayEnumerable class
foreach (Int32 num in new ArrayEnumerable<Int32>(numbers))
Console.WriteLine(num);
// Use my iterator
foreach (Int32 num in ArrayIterator(numbers))
Console.WriteLine(num);
}
private sealed class ArrayEnumerator<T>: IEnumerator<T> {
private T[] m_array;
private Int32 m_index;
public ArrayEnumerator(T[] array) {
m_array = array; m_index = array.Length;
}
public Boolean MoveNext() {
if (m_index == 0) return false;
m_index--; return true;
}
public T Current { get { return m_array[m_index]; } }
Object IEnumerator.Current { get { return Current; } }
public void Dispose() { }
public void Reset() { }
}
private sealed class ArrayEnumerable<T>: IEnumerable<T> {
private T[] m_array;
public ArrayEnumerable(T[] array) { m_array = array; }
public IEnumerator<T> GetEnumerator() {
return new ArrayEnumerator<T>(m_array);
}
IEnumerator IEnumerable.GetEnumerator() {
return GetEnumerator();
}
}
private static IEnumerable<T> ArrayIterator<T>(T[] array) {
try { yield return default(T); }
finally { }
for (Int32 index = array.Length - 1; index >= 0; index--)
yield return array[index];
}
private static IEnumerator<Int32>
ApmPatternWithIterator(AsyncEnumerator ae) {
using (FileStream fs =
new FileStream(typeof(ApmPatterns).Assembly.Location,
FileMode.Open, FileAccess.Read, FileShare.Read, 8192,
FileOptions.Asynchronous)) {
Byte[] data = new Byte[fs.Length];
fs.BeginRead(data, 0, data.Length, ae.End(), null);
yield return 1;
Int32 bytesRead = fs.EndRead(ae.DequeueAsyncResult());
ProcessData(data);
}
}
所有 IEnumerator<T> 對象實質上都是狀態機,可跟蹤集合中要枚舉的項目。foreach 循環調 用 MoveNext 時,MoveNext 方法會將狀態機推進到下一個項目。在我的 ArrayEnumerator 類中,如果所 有項目均已被枚舉(m_index 已經是 0,即最後一項),則 MoveNext 返回 false,否則,它會遞減 m_index 字段並返回 true。Current 屬性只會返回狀態機當前位置所反映的項目。
正如您所看到的,實現代碼枯燥乏味,因此我將一些接口方法留空以簡化編碼。我還省略了一些錯誤 檢查代碼。同時,數組是一個相當簡單的數據結構,因此為它編寫枚舉器簡直輕而易舉。其他數據結構( 如鏈接列表或樹)則會大大增加代碼的復雜性。
還應指出的一點是,您不能通過 foreach 語句直接使用我的 ArrayEnumerator<T> 類。換句話 說,這將無法編譯:
foreach (Int32 num in new ArrayEnumerator<Int32>(numbers))
Console.WriteLine(num);
foreach 語句等待 in 關鍵字後面出現一個集合(其類型實現 IEnumerable<T> 的對象),而 不是 IEnumerator<T>。因此,為了使用我的 ArrayEnumerator<T>,我還必須定義實現 IEnumerable<T> 的類,我的 ArrayEnumerator<T> 類也顯示在圖 3 中。現在,已經定義了 ArrayEnumerable<T> 類之後,我便能夠編寫可順利編譯和執行的以下代碼:
foreach (Int32 num in new ArrayEnumerable<Int32>(numbers))
Console.WriteLine(num);
哇!為了遍歷項目集合,需要編寫相當多的代碼。要是有一些技術使得代碼的編寫工作變簡單該有多 好。幸運的是,確實有 — 那就是 C# 2.0 迭代器功能。迭代器允許您編寫可返回有序值序列的單個成員 。通過簡單語法來表達應如何返回值,以及 C# 編譯器如何將代碼塊轉換為實現 IEnumerable<T> 、IEnumerable、IEnumerator<T>、IEnumerator 和 IDisposable 接口的完備類,便可實現此編寫 目的。
使用 C# 迭代器功能,我將 AsyncEnumerable<T> 類和 AsyncEnumerator<T> 類替換為 以下一個成員:
private static IEnumerable<T> ArrayIterator<T>(T[] array) {
for (Int32 index = array.Length-1; index >= 0; index--)
yield return array[index];
}
而且還可以使用 foreach 語句調用此成員,如下所示:
foreach (Int32 item in ArrayIterator(numbers))
Console.WriteLine(item);
還有一個額外的好處,由於這是泛型方法(相對於泛型類),C# 類型推斷也會介入,因此我不需要編 寫 ArrayIterator<Int32>,從而也令 foreach 代碼更加簡單。
請注意,由於我的迭代器成員會返回 IEnumerable<T>,因此編譯器會生成可實現 IEnumerable<T>、IEnumerable、IEnumerator<T>、IEnumerator 和 IDisposable 的代碼。 但是,您可以編寫返回 IEnumerator<T> 的迭代器成員,在這種情況下,編譯器生成只實現 IEnumerator<T>、IEnumerator 和 IDisposable 接口成員的類定義。
還要指出的一點是,您可以定義返回類型為非泛型 IEnumerable 或 IEnumerator 接口的迭代器成員 ,然後編譯器會定義 IEnumerable<Object>/IEnumerable(如果將 IEnumerable 指定為返回類型 )、IEnumerator<Object>/IEnumerator 和 IDisposable。
在迭代器中,yield return 語句會有效指示編譯器從方法返回何值(集合項目)。然而,此時迭代器 實際上並不會結束執行過程;而是掛起該執行。下次調用 MoveNext 時,該迭代器會在緊跟著 yield return 語句後的語句處重新開始其執行。除 yield return 之外,迭代器還包括 yield break 語句,在 執行時可使 MoveNext 方法返回 false,強制終止 foreach 循環。從迭代器成員退出也會導致 MoveNext 方法返回 false,強制終止 foreach 循環。
異步編程
對異步編程而言,迭代器帶來了諸多切實的好處。首先,在迭代器內,您可以編寫訪問參數和局部變 量的代碼。但是,編譯器實際上會將這些變量封裝到類字段(就像為匿名方法和 lambda 表達式所做的那 樣)。
其次,編譯器使您能夠編寫序列代碼,但也可以中途掛起方法,並在稍後繼續該執行(可能使用不同 的線程)。
再次,您可以在迭代器內使用各種編程構造,如 try/catch/finally 語句、lock 語句、using 語句 ,甚至是循環(for、while 和 foreach 語句)。編譯器會自動重新編寫代碼,以便這些構造能維護其語 義。
對記錄而言,有一些與迭代器相關的限制:
迭代器成員的簽名不能包含任何 out 或 ref 參數。
迭代器不能包含 return 語句(yield return 是可以的)。
迭代器不能包含任何不安全代碼。
finally 塊不能包含 yield return 或 yield break 語句。
yield return 語句不能出現在具有 catch 塊的 try 塊中(yield break 是可以的)。
yield return 語句不能出現在 catch 塊中(yield break 是可以的)。
記住這些限制是有好處的,但在多數編程情形中,這些限制根本不是問題,或者是可以解決的。
現在,有了迭代器的這些知識,您便可以構建執行一系列異步操作的迭代器。在圖 3 中,請參考 ApmPatternWithIterator 成員。此成員是一個迭代器,因為該成員的返回類型為 IEnumerator<T> 。請注意,此成員中的代碼與前面討論的 SynchronousPattern 方法非常相似。特別是,請注意迭代器是 如何包含 using 語句,以及是如何使用參數 (ae) 和局部變量(fs 和 data)的。
可以看出,此成員執行異步操作,因為它調用了 BeginRead 和 EndRead(而非只調用 Read)。但是 使用 APM 時,通常需要調用 BeginRead 並傳遞回調方法。在此情況下,我會傳遞調用 ae.End 的結果( 我將在下一專欄中對此進行說明)。通常,方法會在調用 BeginRead 後返回,但在迭代器中我調用的是 yield return,它只會掛起代碼。
通常,使用 APM 時,對 EndRead 的調用會采用另一種方式,但使用迭代器時,對 EndRead 的調用緊 跟著 yield return 語句。這意味著它會在迭代器執行恢復時執行。並且其他線程也可以執行此代碼。實 際上,如果您看了前面所示的輸出,就會發現 ApmPatternWithIterator 對 ProcessData 的調用是通過 其他線程 (ThreadId=3),而不是應用程序的主線程 (ThreadId=1)。
如果您確實認真學習了此代碼,就會發現此處發生的一切相當完美,使得異步編程幾乎像同步編程一 樣易於執行。但是現在您的線程絕對不會被阻止,它允許應用程序對用戶快速作出響應。由於沒有阻止任 何線程,不需要創建更多的線程,因此應用程序只需使用很少的資源。
現在即可讓多個迭代器同時執行,這會提高應用程序的可伸縮性。而且可以輕松地增加一些功能,包 括取消未完成操作、報告進度、設置計時器、協調多個重疊操作的結果,以及在需要更新 UI 時在 GUI 線程上執行所有這些操作。此外,通過一個編程模型即可實現所有這一切,這與更簡便的同步編程模型相 類似。
使所有這一切成為可能的就是 C# 迭代器和我為了驅動這些迭代器而開發的 AsyncEnumerator 類。希 望我已經講得夠誇張,能夠讓您迫切地想了解我的 AsyncEnumerator 類及其工作原理。遺憾的是,我已 經用光了本月專欄的空間,您只能等下一次的專欄了,下次我會詳細說明我的 AsyncEnumerator 類。