「C#.NET 拾遺補漏」15:非同步程式設計基礎
現代應用程式廣泛使用檔案和網路 I/O。I/O 相關 API 傳統上預設是阻塞的,導致使用者體驗和硬體利用率不佳,此類問題的學習和編碼的難度也較大。而今基於 Task 的非同步 API 和語言級非同步程式設計模式顛覆了傳統模式,使得非同步程式設計非常簡單,幾乎沒有新的概念需要學習。
非同步程式碼有如下特點:
在等待 I/O 請求返回的過程中,通過讓出執行緒來處理更多的伺服器請求。通過在等待 I/O 請求時讓出執行緒進行 UI 互動,並將長期執行的工作過渡到其他 CPU,使使用者介面的響應性更強。許多較新的 .NET API 都是非同步的。在 .NET 中編寫非同步程式碼很容易。
使用 .NET 基於 Task 的非同步模型可以直接編寫 I/O 和 CPU 受限的非同步程式碼。該模型圍繞著Task和Task型別以及 C# 的async和await關鍵字展開。本文將講解如何使用 .NET 非同步程式設計及一些相關基礎知識。
Task 和 Task
Task 是 Promise 模型的實現。簡單說,它給出“承諾”:會在稍後完成工作。而 .NET 的 Task 是為了簡化使用“承諾”而設計的 API。
Task 表示不返回值的操作, Task 表示返回T型別的值的操作。
重要的是要把 Task 理解為發起非同步工作的抽象,而不是對執行緒的抽象。預設情況下,Task 在當前執行緒上執行,並酌情將工作委託給作業系統。可以選擇通過TaskAPI 明確要求任務在單獨的執行緒上執行。
Task 提供了一個 API 協議,用於監視、等待和訪問任務的結果值。比如,通過await關鍵字等待任務執行完成,為使用 Task 提供了更高層次的抽象。
使用 await 允許你在任務執行期間執行其它有用的工作,將控制權交給其呼叫者,直到任務完成。你不再需要依賴回撥或事件來在任務完成後繼續執行後續工作。
I/O 受限非同步操作
下面示例程式碼演示了一個典型的非同步 I/O 呼叫操作:
public Task GetHtmlAsync()
{
// 此處是同步執行
var client=new HttpClient();
return client.GetStringAsync("dotnetfoundation");
}
這個例子呼叫了一個非同步方法,並返回了一個活動的 Task,它很可能還沒有完成。
下面第二個程式碼示例增加了async和await關鍵字對任務進行操作:
public async Task GetFirstCharactersCountAsync(string url, int count)
{
// 此處是同步執行
var client=new HttpClient();
// 此處 await 掛起程式碼的執行,把控制權交出去(執行緒可以去做別的事情)
var page=await client.GetStringAsync("dotnetfoundation");
// 任務完成後恢復了控制權,繼續執行後續程式碼
// 此處回到了同步執行
if (count > page.Length)
{
return page;
}
else
{
return page.Substring(0, count);
}
}
使用 await 關鍵字告訴當前上下文趕緊生成快照並交出控制權,非同步任務執行完成後會帶著返回值去執行緒池排隊等待可用執行緒,等到可用執行緒後,恢復上下文,執行緒繼續執行後續程式碼。
GetStringAsync() 方法的內部通過底層 .NET 庫呼叫資源(也許會呼叫其他非同步方法),一直到 P/Invoke 互操作呼叫本地(Native)網路庫。本地庫隨後可能會呼叫到一個系統 API(如 Linux 上 Socket 的write()API)。Task 物件將通過層層傳遞,最終返回給初始呼叫者。
在整個過程中,關鍵的一點是,沒有一個執行緒是專門用來處理任務的。雖然工作是在某種上下文中執行的(作業系統確實要把資料傳遞給裝置驅動程式並中斷響應),但沒有執行緒專門用來等待請求的資料回返回。這使得系統可以處理更大的工作量,而不是乾等著某個 I/O 呼叫完成。
雖然上面的工作看似很多,但與實際 I/O 工作所需的時間相比,簡直微不足道。用一條不太精確的時間線來表示,大概是這樣的:
0-1--------------------2-3
從0到1所花費的時間是await交出控制權之前所花的時間。從1到2花費的時間是GetStringAsync方法花費在 I/O 上的時間,沒有 CPU 成本。最後,從2到3花費的時間是上下文重新獲取控制權後繼續執行的時間。
CPU 受限非同步操作
CPU 受限的非同步程式碼與 I/O 受限的非同步程式碼有些不同。因為工作是在 CPU 上完成的,所以沒有辦法繞開專門的執行緒來進行計算。使用 async 和 await 只是為你提供了一種乾淨的方式來與後臺執行緒進行互動。請注意,這並不能為共享資料提供加鎖保護,如果你正在使用共享資料,仍然需要使用適當的同步策略。
下面是一個 CPU 受限的非同步呼叫:
public async Task CalculateResult(InputData data)
{
// 線上程池排隊獲取執行緒來處理任務
var expensiveResultTask=Task(()=> DoExpensiveCalculation(data));
// 此時此處,你可以並行地處理其它工作
var result=await expensiveResultTask;
return result;
}
CalculateResult方法在它被呼叫的執行緒(一般可以定義為主執行緒)上執行。當它呼叫Task時,會線上程池上排隊執行 CPU 受限操作 DoExpensiveCalculation,並接收一個Task控制代碼。DoExpensiveCalculation會在下一個可用的執行緒上並行執行,很可能是在另一個 CPU 核上。和 I/O 受限非同步呼叫一樣,一旦遇到await,CalculateResult的控制權就會被交給它的呼叫者,這樣在DoExpensiveCalculation返回結果的時候,結果就會被安排在主執行緒上排隊執行。
對於開發者,CUP 受限和 I/O 受限的在呼叫方式上沒什麼區別。區別在於所呼叫資源性質的不同,不必關心底層對不同資源的呼叫的具體邏輯。編寫程式碼需要考慮的是,對於 CUP 受限的非同步任務,根據實際情況考慮是否需要使其和其它任務並行執行,以加快程式的整體執行時間。
非同步程式設計模式
最後簡單回顧一下 .NET 歷史上提供的三種執行非同步操作的模式。
基於任務的非同步模式(Task-based Asynchronous Pattern,TAP),它使用單一的方法來表示非同步操作的啟動和完成。TAP 是在 .NET Framework 4 中引入的。它是 .NET 中非同步程式設計的推薦方法。C# 中的 async 和 await 關鍵字為 TAP 添加了語言支援。基於事件的非同步模式(Event-based Asynchronous Pattern,EAP),這是基於事件的傳統模式,用於提供非同步行為。它需要一個具有 Async 字尾的方法和一個或多個事件。EAP 是在 .NET Framework 2.0 中引入的。它不再被推薦用於新的開發。非同步程式設計模式(Asynchronous Programming Model,APM)模式,也稱為 IAsyncResult 模式,這是使用 IAsyncResult 介面提供非同步行為的傳統模式。在這種模式中,需要Begin和End方法同步操作(例如,BeginWrite和EndWrite來實現非同步寫操作)。這種模式也不再推薦用於新的開發。
下面簡單舉例對三種模式進行比較。
假設有一個 Read 方法,該方法從指定的偏移量開始將指定數量的資料讀入提供的緩衝區:
public class MyClass
{
public int Read(byte [] buffer, int offset, int count);
}
若用 TAP 非同步模式來改寫,該方法將是簡單的一個 ReadAsync 方法:
public class MyClass
{
public Task ReadAsync(byte [] buffer, int offset, int count);
}
若使用 EAP 非同步模式,需要額外多定義一些型別和成員:
public class MyClass
{
public void ReadAsync(byte [] buffer, int offset, int count);
public event ReadCompletedEventHandler ReadCompleted;
}
public delegate void ReadCompletedEventHandler(
object sender, ReadCompletedEventArgs e);
public class ReadCompletedEventArgs : AsyncCompletedEventArgs
{
public MyReturnType Result { get; }
}
若使用 AMP 非同步模式,則需要定義兩個方法,一個用於開始執行非同步操作,一個用於接收非同步操作結果:
public class MyClass
{
public IAsyncResult BeginRead(
byte [] buffer, int offset, int count,
AsyncCallback callback, object state);
public int EndRead(IAsyncResult asyncResult);
}
後兩種非同步模式已經過時不推薦使用了,這裡也不再繼續探討。歲數大點的 .NET 程式設計師可能比較熟悉後兩種非同步模式,畢竟那時候沒有 async/await,應該沒少折騰。