0x00 異步編程模式的歷史
.NET Framework 提供了執行異步操作的三種模式:
-
異步編程模型 (APM) 模式(即 IAsyncResult 模式),在該模式下,異步操作需要使用
Begin
和End
方法(例如,異步寫入操作需要使用BeginWrite
和EndWrite
方法) 不建議新的開發使用此模式。 有關詳細信息,請參閱異步編程模型 (APM)。 -
基於事件的異步模式 (EAP),這種模式需要
Async
后綴,也需要一個或多個事件、事件處理程序委托類型和EventArg
派生類型。 EAP 是在 .NET Framework 2.0 中引入的。 不建議新的開發使用這種模式。 有關詳細信息,請參閱基於事件的異步模式 (EAP)。 -
基於任務的異步模式 (TAP),該模式使用單一方法表示異步操作的開始和完成。 TAP 是在 .NET Framework 4 中引入的,並且它是在 .NET Framework 中進行異步編程的推薦使用方法。 C# 中的 async 和 await 關鍵詞以及 Visual Basic 語言中的 Async 和 Await 運算符為 TAP 添加了語言支持。 有關詳細信息,請參閱基於任務的異步模式 (TAP)。
現在主要使用TAP來編程。
0x01 Task和 Task<T>
任務是用於實現稱之為並發 Promise 模型的構造。 簡單地說,它們“承諾”,會在稍后完成工作,讓你使用干凈的 API 與 promise 協作。
Task
表示不返回值的單個操作。Task<T>
表示返回T
類型的值的單個操作。
請務必將任務理解為工作的異步抽象,而非在線程之上的抽象。 默認情況下,任務在當前線程上執行,且在適當時會將工作委托給操作系統。 可選擇性地通過 Task.Run
API 顯式請求任務在獨立線程上運行。
任務會公開一個 API 協議來監視、等候和訪問任務的結果值(如 Task<T>
)。 含有 await
關鍵字的語言集成可提供高級別抽象來使用任務。
任務運行時,使用 await
在任務完成前將控制讓步於其調用方,可讓應用程序和服務執行有用工作。 任務完成后代碼無需依靠回調或事件便可繼續執行。 語言和任務 API 集成會為你完成此操作。 如果正在使用 Task<T>
,任務完成時,await
關鍵字還將“打開”返回的值。下面進一步詳細介紹了此工作原理。
0x02 針對 I/O 的操作的Task
以下部分介紹了使用典型異步 I/O 調用時會出現的各種情況。 我們先看兩個例子。
第一個示例調用異步方法,並返回活動任務,很可能尚未完成。
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 的異步調用的方方面面:
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
。