我一直讚揚非同步程式設計模型 (APM) 的優點,強調非同步執行 I/O 密集型作業是生產高回應、可延展應用程式與元件的關鍵。這些目標可達成,因為 APM 讓您使用極少量執行緒執行大量工作,且不阻塞任何執行緒。遺憾的是,使用 APM 建構典型應用程式或元件有些難度,因此許多程式設計師不願意去做。
try/catch/finally、lock、using 陳述式,甚至迴圈(for、while、foreach)都無法跨方法拆分,避免這些建構也增加實作複雜度。本期專欄將示範 C# 程式語言最新增功能,它們大幅簡化 APM 使用。之後介紹我開發的 AsyncEnumerator 類別,建立在這些 C# 語言功能基礎上,解決上述問題。AsyncEnumerator 讓您在程式碼中使用 APM 變得簡單有趣,程式碼變得可延展且高回應,因此沒有理由不使用非同步程式設計。注意:AsyncEnumerator 是 Power Threading 函式庫一部分,依賴同屬此函式庫的其他程式碼;讀者可從 Wintellect.com 下載該函式庫。
圖 1 中,SynchronousPattern 方法顯示如何同步開啟並讀取檔案。該方法簡單明了:建構 FileStream、配置 Byte[]、呼叫 Read、處理回傳資料。C# using 陳述式確保資料處理完成後關閉 FileStream。
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);
// 方法在發出非同步 I/O 要求後返回
}
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(); // 無 '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(); // 無 'using'
}, null);
// 方法在發出非同步 I/O 要求後返回
}
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(); // 無 'using'
}, null);
// 方法在發出非同步 I/O 要求後返回
}
ApmPatternWithMultipleMethods 展示如何使用 CLR 的 APM 執行與 SynchronousPattern 相同操作,實作複雜得多。注意它啟動非同步 I/O 作業,完成時呼叫 ReadCompleted。兩方法間資料傳遞透過將共享資料封裝至 ApmData 類別實例完成,必須專門定義此類別。且無法使用 C# using 陳述式,因為 FileStream 在一個方法開啟、另一個方法關閉,只能在 ReadCompleted 返回前顯式呼叫 Close。
ApmPatternWithAnonymousMethod 展示如何使用 C# 2.0 匿名方法重寫。匿名方法讓您將程式碼作為參數傳遞給方法,有效地將一個方法程式碼內嵌至另一個方法。注意程式碼較簡單,因為完全包含在一個方法內。所有 BeginXxx 方法將倒數第二個參數視為參考方法的委派(AsyncCallback),在作業完成時由執行緒池執行緒呼叫。通常使用 APM 時,必須撰寫單獨方法、命名,並透過 BeginXxx 最後一個參數傳遞額外資料。但匿名方法功能允許只撰寫單獨內嵌方法,啟動要求與處理結果的所有程式碼便與環境協調,看起來與 SynchronousPattern 類似。
其次,ApmData 類別不再必要;不需定義該類別、建構實例及使用其欄位!如何實現?匿名方法不僅將方法程式碼內嵌。當 C# 編譯器偵測到外部方法宣告的參數或區域變數也用於內部方法時,實際上會自動定義一個類別,兩方法間共享的每個變數成為此編譯器定義類別的欄位。編譯器在 ApmPatternWithAnonymousMethod 內產生程式碼建構此類別實例,參考變數的程式碼編譯成存取編譯器定義類別欄位的程式碼。編譯器還使內部方法成為新類別上的實例方法,允許其程式碼輕鬆存取欄位,現在兩方法可共享資料。
這是匿名方法的出色功能,讓您像使用方法參數與區域變數一樣撰寫程式碼,但編譯器實際重寫程式碼,從堆疊取出這些變數,將它們作為欄位嵌入物件。物件可在方法間輕鬆傳遞,並可從一個執行緒輕鬆遷移至另一個執行緒,這對使用 APM 非常完美。因編譯器自動執行所有工作,您可輕鬆將最後一個參數的 null 傳遞給 BeginRead,因為現在沒有要在方法與執行緒間顯式傳遞的資料。但仍無法使用 C# using 陳述式,因為這裡有兩個不同方法,雖然看起來似乎只有一個。
C# 3.0 引入 Lambda 表達式,執行同一操作時語法比匿名方法更簡潔。為使用 C# 3.0 Lambda 表達式,ApmPatternWithAnonymousMethod 修改後成為圖 1 所示 ApmPatternWithLambdaExpression。語法略為簡化,因編譯器能自動推斷結果參數類型為 IAsyncResult,且 => 比 delegate 少鍵入內容。
C# 2.0 引入迭代器功能,最初目的是讓開發人員輕鬆撰寫程式碼遍歷集合內部資料結構。要了解迭代器,必須先看 C# foreach 陳述式。編寫 foreach 時,編譯器將其轉化為圖 2 所示內容。
// 建構知道如何遍歷集合內部資料結構的物件
IEnumerator<String> e = collectionObject.GetEnumerator();
try {
String s;
while (e.MoveNext()) { // 前進到下一個項目
s = e.Current; // 取得要處理的項目
DoSomething(s);
}
}
finally {
// 若 IEnumerable 實作 IDisposable,清理物件
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 主體處理。當 MoveNext 確定所有項目均已處理,傳回 false,while 迴圈退出並進入 finally 區塊。在 finally 中檢查 IEnumerator<T> 物件是否實作 IDisposable,是則呼叫 Dispose。記錄在案,IEnumerator<T> 介面由 IDisposable 衍生,故所有 IEnumerator<T> 物件需實作 Dispose。一些列舉器物件在迭代過程需附加資源,foreach 迴圈退出後(可能在遍歷所有項目前發生),finally 呼叫 Dispose 允許釋放附加資源(如關閉文字檔)。
了解 foreach 產生程式碼後,看物件如何與 MoveNext、Current 成員實作 IEnumerable<T> 介面。先討論陣列,它們可能是最簡單集合資料結構。System.Array 是所有陣列基類,實作 IEnumerable<T>。其 GetEnumerator 回傳實作 IEnumerator<T> 介面的物件,可輕鬆編寫程式碼遍歷陣列所有元素。
我的 ApmPatterns 類別包含 ArrayEnumerator<T> 類別,知道如何以相反順序回傳陣列每個項目(圖 3)。此類實作 IEnumerator<T> 介面,要求同時實作非泛型 IEnumerable 與 IDisposable。AsyncEnumerator<T> 提供公用建構函式將傳遞陣列保存於私有成員。另一私有成員 m_index 指示目前迭代至陣列哪個項目;此欄位初始化為陣列長度(即陣列最後一個元素之後的元素)。
private static void Iterators() {
Int32[] numbers = new Int32[] { 1, 2, 3 };
// 使用 Array 內建 IEnumerable
foreach (Int32 num in numbers)
Console.WriteLine(num);
// 使用我的 ArrayEnumerable 類別
foreach (Int32 num in new ArrayEnumerable<Int32>(numbers))
Console.WriteLine(num);
// 使用我的迭代器
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)) 無法編譯。foreach 等待 in 關鍵字後出現集合(實作 IEnumerable<T> 的物件),而非 IEnumerator<T>。故為使用 ArrayEnumerator<T>,必須定義實作 IEnumerable<T> 的類別,ArrayEnumerable<T> 即顯示於圖 3。定義後便能編寫可順利編譯執行的程式碼。
為了遍歷項目集合,需撰寫相當多程式碼。幸運的是有技術讓編碼工作變簡單——C# 2.0 迭代器功能。迭代器允許編寫可回傳有序值序列的單一成員。透過簡單語法表達應如何回傳值,以及編譯器如何將程式碼區塊轉換為實作 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 呼叫此成員。還有額外好處,因為是泛型方法(相對於泛型類別),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 迴圈。
對非同步程式設計,迭代器帶來諸多實質好處:
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 類別。