[C#.NET 拾遺補漏]15:異步編程基礎

現代應用程序廣泛使用文件和網絡 I/O。I/O 相關 API 傳統上默認是阻塞的,導致用戶體驗和硬件利用率不佳,此類問題的學習和編碼的難度也較大。而今基於 Task 的異步 API 和語言級異步編程模式顛覆了傳統模式,使得異步編程非常簡單,幾乎沒有新的概念需要學習。

異步代碼有如下特點:

  • 在等待 I/O 請求返回的過程中,通過讓出線程來處理更多的服務器請求。

  • 通過在等待 I/O 請求時讓出線程進行 UI 交互,並將長期運行的工作過渡到其他 CPU,使用戶界面的響應性更強。

  • 許多較新的 .NET API 都是異步的。

  • 在 .NET 中編寫異步代碼很容易。

使用 .NET 基於 Task 的異步模型可以直接編寫 I/O 和 CPU 受限的異步代碼。該模型圍繞着TaskTask<T>類型以及 C# 的asyncawait關鍵字展開。本文將講解如何使用 .NET 異步編程及一些相關基礎知識。

Task 和 Task<T>


Task 是 Promise 模型的實現。簡單說,它給出“承諾”:會在稍後完成工作。而 .NET 的 Task 是爲了簡化使用“承諾”而設計的 API。

Task 表示不返回值的操作, Task<T> 表示返回T類型的值的操作。

重要的是要把 Task 理解爲發起異步工作的抽象,而不是對線程的抽象。默認情況下,Task 在當前線程上執行,並酌情將工作委託給操作系統。可以選擇通過Task.RunAPI 明確要求任務在單獨的線程上運行。

Task 提供了一個 API 協議,用於監視、等待和訪問任務的結果值。比如,通過await關鍵字等待任務執行完成,爲使用 Task 提供了更高層次的抽象。

使用 await 允許你在任務運行期間執行其它有用的工作,將控制權交給其調用者,直到任務完成。你不再需要依賴回調或事件來在任務完成後繼續執行後續工作。

I/O 受限異步操作


下面示例代碼演示了一個典型的異步 I/O 調用操作:

public Task<string> GetHtmlAsync()
{
// 此處是同步執行
var client = new HttpClient();
return client.GetStringAsync("https://www.dotnetfoundation.org");
}

這個例子調用了一個異步方法,並返回了一個活動的 Task,它很可能還沒有完成。

下面第二個代碼示例增加了asyncawait關鍵字對任務進行操作:

public async Task<string> GetFirstCharactersCountAsync(string url, int count)
{
// 此處是同步執行
var client = new HttpClient();

// 此處 await 掛起代碼的執行,把控制權交出去(線程可以去做別的事情)
var page = await client.GetStringAsync("https://www.dotnetfoundation.org");

// 任務完成後恢復了控制權,繼續執行後續代碼
// 此處回到了同步執行

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

01所花費的時間是await交出控制權之前所花的時間。從12花費的時間是GetStringAsync方法花費在 I/O 上的時間,沒有 CPU 成本。最後,從23花費的時間是上下文重新獲取控制權後繼續執行的時間。

CPU 受限異步操作


CPU 受限的異步代碼與 I/O 受限的異步代碼有些不同。因爲工作是在 CPU 上完成的,所以沒有辦法繞開專門的線程來進行計算。使用 async 和 await 只是爲你提供了一種乾淨的方式來與後臺線程進行交互。請注意,這並不能爲共享數據提供加鎖保護,如果你正在使用共享數據,仍然需要使用適當的同步策略。

下面是一個 CPU 受限的異步調用:

public async Task<int> CalculateResult(InputData data)
{
// 在線程池排隊獲取線程來處理任務
var expensiveResultTask = Task.Run(() => DoExpensiveCalculation(data));

// 此時此處,你可以並行地處理其它工作

var result = await expensiveResultTask;

return result;
}

CalculateResult方法在它被調用的線程(一般可以定義爲主線程)上執行。當它調用Task.Run時,會在線程池上排隊執行 CPU 受限操作 DoExpensiveCalculation,並接收一個Task<int>句柄。DoExpensiveCalculation會在下一個可用的線程上並行運行,很可能是在另一個 CPU 核上。和 I/O 受限異步調用一樣,一旦遇到awaitCalculateResult的控制權就會被交給它的調用者,這樣在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 接口提供異步行爲的傳統模式。在這種模式中,需要BeginEnd方法同步操作(例如,BeginWriteEndWrite來實現異步寫操作)。這種模式也不再推薦用於新的開發。

下面簡單舉例對三種模式進行比較。

假設有一個 Read 方法,該方法從指定的偏移量開始將指定數量的數據讀入提供的緩衝區:

public class MyClass
{
public int Read(byte [] buffer, int offset, int count);
}

若用 TAP 異步模式來改寫,該方法將是簡單的一個 ReadAsync 方法:

public class MyClass
{
public Task<int> 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,應該沒少折騰。

參考:
https://docs.microsoft.com/en-us/dotnet/standard/async
https://docs.microsoft.com/en-us/dotnet/standard/asynchronous-programming-patterns/


本文分享自微信公衆號 - 一線碼農聊技術(dotnetfly)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章