C#非同步程式設計筆記
0x00 非同步程式設計模式的歷史
.NET Framework 提供了執行非同步操作的三種模式:
-
非同步程式設計模型 (APM) 模式(即 IAsyncResult 模式),在該模式下,非同步操作需要使用
Begin
和End
方法(例如,非同步寫入操作需要使用BeginWrite
和EndWrite
方法) 不建議新的開發使用此模式。 -
基於事件的非同步模式 (EAP),這種模式需要
Async
字尾,也需要一個或多個事件、事件處理程式委託型別和EventArg
派生型別。 EAP 是在 .NET Framework 2.0 中引入的。 不建議新的開發使用這種模式。 -
基於任務的非同步模式 (TAP),該模式使用單一方法表示非同步操作的開始和完成。 TAP 是在 .NET Framework 4 中引入的,並且它是在 .NET Framework 中進行非同步程式設計的推薦使用方法。 C# 中的
現在主要使用TAP來程式設計。
0x01 Task和 Task<T>
任務是用於實現稱之為併發 Promise 模型的構造。 簡單地說,它們“承諾”,會在稍後完成工作,讓你使用乾淨的 API 與 promise 協作。
Task
表示不返回值的單個操作。Task<T>
表示返回T
型別的值的單個操作。
請務必將任務理解為工作的非同步抽象,而非線上程之上的抽象。 預設情況下,任務在當前執行緒上執行,且在適當時會將工作委託給作業系統。 可選擇性地通過 Task.Run
任務會公開一個 API 協議來監視、等候和訪問任務的結果值(如 Task<T>
)。 含有 await
關鍵字的語言整合可提供高級別抽象來使用任務。
任務執行時,使用 await
在任務完成前將控制讓步於其呼叫方,可讓應用程式和服務執行有用工作。 任務完成後程式碼無需依靠回撥或事件便可繼續執行。 語言和任務 API 整合會為你完成此操作。 如果正在使用 Task<T>
,任務完成時,await
關鍵字還將“開啟”返回的值。下面進一步詳細介紹了此工作原理。
0x02 針對 I/O 的操作的Task
以下部分介紹了使用典型非同步 I/O 呼叫時會出現的各種情況。 我們先看兩個例子。
第一個示例呼叫非同步方法,並返回活動任務,很可能尚未完成。
C#public Task<string> GetHtmlAsync()
{
// Execution is synchronous here
var client = new HttpClient();
return client.GetStringAsync("http://www.dotnetfoundation.org");
}
第二個示例還使用了 async
和 await
關鍵字對任務進行操作。
public async Task<string> GetFirstCharactersCountAsync(string url, int count)
{
// Execution is synchronous here
var client = new HttpClient();
// Execution of GetFirstCharactersCountAsync() is yielded to the caller here
// GetStringAsync returns a Task<string>, which is *awaited*
var page = await client.GetStringAsync("http://www.dotnetfoundation.org");
// Execution resumes when the client.GetStringAsync task completes,
// becoming synchronous again.
if (count > page.Length)
{
return page;
}
else
{
return page.Substring(0, count);
}
}
對 GetStringAsync()
的呼叫通過低級別 .NET 庫進行(可能是呼叫其他非同步方法),直到其到達 P/Invoke 互操作呼叫,進入本機網路庫。 本機庫隨後可能會調入系統 API 呼叫(例如 Linux 上套接字的 write()
)。 可能會使用 TaskCompletionSource 在本機/託管邊界建立一個任務物件。 將通過層向上傳遞任務物件,對其進行操作或直接返回,最後返回到初始呼叫方。
在上述的第二個示例中,Task<T>
物件將直接從 GetStringAsync
返回。 由於使用了 await
關鍵字,因此該方法會返回一個新建的任務物件。 控制會從 GetFirstCharactersCountAsync
方法中的該位置返回到呼叫方。 Task<T> 物件的方法和屬性確保呼叫方監視任務進度,當執行完 GetFirstCharactersCountAsync 中剩餘的程式碼時,任務便完成。
呼叫系統 API 後,請求位於核心空間,一路來到作業系統的網路子系統(例如 Linux 核心中的 /net
)。 此處作業系統將對網路請求進行非同步處理。 所用作業系統不同,細節可能有所不同(可能會將裝置驅動程式呼叫安排為傳送回執行時的訊號,或者會執行裝置驅動程式呼叫然後有一個訊號傳送回來),但最終都會通知執行時網路請求正在進行中。 此時,裝置驅動程式工作處於已計劃、正在進行或是已完成(請求已“通過網路”發出),但由於這些均為非同步進行,裝置驅動程式可立即著手處理其他事項!
例如,在 Windows 中作業系統執行緒呼叫網路裝置驅動程式並要求它通過表示操作的中斷請求資料包 (IRP) 執行網路操作。 裝置驅動程式接收 IRP,呼叫網路,將 IRP 標記為“待定”,並返回到作業系統。 由於現在作業系統執行緒瞭解到 IRP 為“待定”,因此無需再為此作業進行進一步操作,將其“返回”,這樣它就可用於完成其他工作。
請求完成且資料通過裝置驅動程式返回後,會經由中斷通知 CPU 新接收到的資料。 處理中斷的方式因作業系統不同而有所不同,但最終都會通過作業系統將資料傳遞到系統互操作呼叫(例如,Linux 中的中斷處理程式將安排 IRQ 的下半部分通過作業系統異步向上傳遞資料)。 請注意這仍是非同步進行的! 在下一個可用執行緒能執行非同步方法且“開啟”已完成任務的結果前,結果會排隊等候。
在整個過程中,關鍵點在於沒有執行緒專用於執行任務。 儘管需要在一些上下文中執行工作(即,作業系統確實必須將資料傳遞到裝置驅動程式並響應中斷),但沒有專用於等待資料從請求返回的執行緒。 這讓系統能處理更多的工作而不是等待某些 I/O 呼叫結束。
這對伺服器方案而言意味著什麼?
此模型可很好地處理典型的伺服器方案工作負荷。 由於沒有專用於阻止未完成任務的執行緒,伺服器執行緒池可服務更多的 Web 請求。相比伺服器將執行緒專用於接收到的每個請求,使用 async
和 await
能夠使伺服器多處理一個數量級的請求。
這對客戶端方案而言意味著什麼?
使用 async
和 await
對客戶端應用帶來的最大好處在於提高了響應能力。 儘管可以手動生成執行緒讓應用響應,但相比僅使用 async
和 await
,生成執行緒的操作更加昂貴。 特別是對於手機遊戲等應用而言,在涉及 I/O 時儘可能少地影響 UI 執行緒,這點至關重要。
更重要的是,由於繫結 I/O 的工作在 CPU 上幾乎沒有耗時,所以將整個 CPU 執行緒專用於執行幾乎沒有任何作用的工作將是一種資源浪費。
此外,使用 async
方法將工作排程到 UI 執行緒(例如,更新 UI)十分簡單,且無需額外的工作(例如呼叫執行緒安全的委託)。
0x03 針對 CPU 的操作的Task
繫結 CPU 的 async
程式碼與繫結 I/O 的 async
程式碼有些許不同。 由於工作在 CPU 上執行,無法解決執行緒專用於計算的問題。 async
和 await
的運用使得可以與後臺執行緒互動並讓非同步方法呼叫方可響應。 請注意這不會為共享資料提供任何保護。 如果正在使用共享資料,仍需要採用合適的同步策略。
這裡詳細介紹了繫結 CPU 的非同步呼叫的方方面面:
C#public async Task<int> CalculateResult(InputData data)
{
// This queues up the work on the threadpool.
var expensiveResultTask = Task.Run(() => DoExpensiveCalculation(data));
// Note that at this point, you can do some other work concurrently,
// as CalculateResult() is still executing!
// Execution of CalculateResult is yielded here!
var result = await expensiveResultTask;
return result;
}
CalculateResult()
在呼叫它的執行緒上執行。 呼叫 Task.Run
時,它會線上程池上對昂貴的繫結 CPU 的操作 DoExpensiveCalculation()
進行排隊,並收到一個 Task<int>
控制代碼。DoExpensiveCalculation()
最終在下一個可用執行緒上並行執行(很可能在另一個 CPU 核心上)。 當 DoExpensiveCalculation()
在另一執行緒處理任務時,由於呼叫 CalculateResult()
的執行緒仍在執行,這時可能會出現並行工作的情況。
一旦遇到 await
,CalculateResult()
執行會讓步於呼叫方,在 DoExpensiveCalculation()
執行運算的同時,允許其他任務在當前執行緒執行。DoExpensiveCalculation()
完成後,結果會在主執行緒上排隊等待執行。 最後,主執行緒將返回執行得到 DoExpensiveCalculation()
結果的 CalculateResult()
。
非同步為什麼在此處會起作用?
async
和 await
是在需要可響應性時管理繫結 CPU 的工作的最佳實踐。 存在多個可將非同步用於繫結 CPU 的工作的模式。 請務必注意,使用非同步成本有少許費用,不推薦緊湊迴圈使用它。 如何編寫此新功能的程式碼完全取決於你。
0x04 非同步方法的執行機制
非同步程式設計中最需弄清的是控制流是如何從方法移動到方法的。 下圖可引導你完成該過程。
關係圖中的數值對應於以下步驟。
-
事件處理程式呼叫並等待
AccessTheWebAsync
非同步方法。 -
AccessTheWebAsync
可建立 HttpClient 例項並呼叫 GetStringAsync 非同步方法以下載網站內容作為字串。 -
GetStringAsync
中發生了某種情況,該情況掛起了它的程序。 可能必須等待網站下載或一些其他阻止活動。 為避免阻止資源,GetStringAsync
會將控制權出讓給其呼叫方AccessTheWebAsync
。GetStringAsync
返回 Task<TResult>,其中TResult
為字串,並且AccessTheWebAsync
將任務分配給getStringTask
變數。 該任務表示呼叫GetStringAsync
的正在進行的程序,其中承諾當工作完成時產生實際字串值。 -
由於尚未等待
getStringTask
,因此,AccessTheWebAsync
可以繼續執行不依賴於GetStringAsync
得出的最終結果的其他工作。 該任務由對同步方法DoIndependentWork
的呼叫表示。 -
DoIndependentWork
是完成其工作並返回其呼叫方的同步方法。 -
AccessTheWebAsync
已用完工作,可以不受getStringTask
的結果影響。 接下來,AccessTheWebAsync
需要計算並返回該下載字串的長度,但該方法僅在具有字串時才能計算該值。因此,
AccessTheWebAsync
使用一個 await 運算子來掛起其進度,並把控制權交給呼叫AccessTheWebAsync
的方法。AccessTheWebAsync
將Task<int>
返回給呼叫方。 該任務表示對產生下載字串長度的整數結果的一個承諾。備註
如果
GetStringAsync
(因此getStringTask
)在AccessTheWebAsync
等待前完成,則控制會保留在AccessTheWebAsync
中。 如果非同步呼叫過程 (getStringTask
) 已完成,並且AccessTheWebSync
不必等待最終結果,則掛起然後返回到AccessTheWebAsync
將造成成本浪費。在呼叫方內部(此示例中的事件處理程式),處理模式將繼續。 在等待結果前,呼叫方可以開展不依賴於
AccessTheWebAsync
結果的其他工作,否則就需等待片刻。 事件處理程式等待AccessTheWebAsync
,而AccessTheWebAsync
等待GetStringAsync
。 -
GetStringAsync
完成並生成一個字串結果。 字串結果不是通過按你預期的方式呼叫GetStringAsync
所返回的。 (記住,該方法已返回步驟 3 中的一個任務)。相反,字串結果儲存在表示getStringTask
方法完成的任務中。 await 運算子從getStringTask
中檢索結果。 賦值語句將檢索到的結果賦給urlContents
。 -
當
AccessTheWebAsync
具有字串結果時,該方法可以計算字串長度。 然後,AccessTheWebAsync
工作也將完成,並且等待事件處理程式可繼續使用。 在此主題結尾處的完整示例中,可確認事件處理程式檢索並列印長度結果的值。如果你不熟悉非同步程式設計,請花 1 分鐘時間考慮同步行為和非同步行為之間的差異。 當其工作完成時(第 5 步)會返回一個同步方法,但當其工作掛起時(第 3 步和第 6 步),非同步方法會返回一個任務值。 在非同步方法最終完成其工作時,任務會標記為已完成,而結果(如果有)將儲存在任務中。
0x05 命名約定
按照約定,將“Async”追加到包含 async
修飾符的方法的名稱中。
如果某一約定中的事件、基類或介面協定建議其他名稱,則可以忽略此約定。 例如,你不應重新命名常用事件處理程式,例如 Button1_Click
。