如果需要 I/O 綁定(例如從網絡請求數據或訪問數據庫),則需要利用異步編程。 還可以使用 CPU 綁定代碼(例如執行成本高昂的計算),對編寫異步代碼而言,這是一個不錯的方案。C# 擁有語言級別的異步編程模型,它使你能輕松編寫異步代碼,而無需應付回叫或符合支持異步的庫。 它遵循基於任務的異步模式 (TAP)。
異步編程的核心是 Task
和 Task<T>
對象,這兩個對象對異步操作建模。 它們受關鍵字 async
和 await
的支持。 在大多數情況下模型十分簡單:
對於 I/O 綁定代碼,當你 await
一個操作,它將返回 async
方法中的一個 Task
或 Task<T>
。
對於 CPU 綁定代碼,當你 await
一個操作,它將在后台線程通過 Task.Run
方法啟動。
await
關鍵字有這奇妙的作用。 它控制執行 await
的方法的調用方,且它最終允許 UI 具有響應性或服務具有靈活性。
除上方鏈接的 TAP 文章中介紹的 async
和 await
之外,還有其他處理異步代碼的方法,但本文檔將在下文中重點介紹語言級別的構造。
I/O 綁定示例:從 Web 服務下載數據
你可能需要在按下按鈕時從 Web 服務下載某些數據,但不希望阻止 UI 線程。 只需執行如下操作即可輕松實現:
private readonly HttpClient _httpClient = new HttpClient(); downloadButton.Clicked += async (o, e) => { // 當來自Web服務的請求發生時,此行將向UI提供控制權。 // UI線程現在可以自由執行其他工作 var stringData = await _httpClient.GetStringAsync(URL); DoSomethingWithData(stringData); };
就是這么簡單! 代碼表示目的(異步下載某些數據),而不會在與任務對象的交互中停滯。
CPU 綁定示例:為游戲執行計算
假設你正在編寫一個移動游戲,在該游戲中,按下某個按鈕將會對屏幕中的許多敵人造成傷害。執行傷害計算的開銷可能極大,而且在 UI 線程中執行計算有可能使游戲在計算執行過程中暫停!
此問題的最佳解決方法是啟動一個后台線程,它使用 Task.Run
執行工作,並 await
其結果。 這可確保在執行工作時 UI 能流暢運行。
private DamageResult CalculateDamageDone() { // ··· 省略的業務邏輯代碼 // //執行昂貴的計算並返回該計算的結果。 } calculateButton.Clicked += async (o, e) => { // 此行將在計算 damagedone()執行其工作時向UI提供控制權。
// UI線程現在可以自由執行其他工作 var damageResult = await Task.Run(() => CalculateDamageDone()); DisplayDamage(damageResult); };
就是這么簡單! 此代碼清楚地表達了按鈕的單擊事件的目的,它無需手動管理后台線程,而是通過非阻止性的方式來實現。
內部原理
異步操作涉及許多移動部分。 若要了解 Task
和 Task<T>
的內部原理,請參閱深入了解異步,以獲取詳細信息。
在 C# 方面,編譯器將代碼轉換為狀態機,它將跟蹤類似以下內容:到達 await
時暫停執行以及后台作業完成時繼續執行。
從理論上講,這是異步的承諾模型的實現。
- 異步代碼可用於 I/O 綁定和 CPU 綁定代碼,但在每個方案中有所不同。
- 異步代碼使用
Task<T>
和Task
,它們是對后台所完成的工作進行建模的構造。 async
關鍵字將方法轉換為異步方法,這使你能在其正文中使用await
關鍵字。- 應用
await
關鍵字后,它將掛起調用方法,並將控制權返還給調用方,直到等待的任務完成。 - 僅允許在異步方法中使用
await
。
前兩個示例演示如何將 async
和 await
用於 I/O 綁定和 CPU 綁定工作。 確定所需執行的操作是 I/O 綁定或 CPU 綁定是關鍵,因為這會極大影響代碼性能,並可能導致某些構造的誤用。
以下是編寫代碼前應考慮的兩個問題:
-
你的代碼是否會“等待”某些內容,例如數據庫中的數據?
如果答案為“是”,則你的工作是 I/O 綁定。
-
你的代碼是否要執行開銷巨大的計算?
如果答案為“是”,則你的工作是 CPU 綁定。
如果你的工作為 I/O 綁定,請使用 async
和 await
(而不使用 Task.Run
)。 不應使用任務並行庫 。 相關原因在深入了解異步的文章中說明。
如果你的工作為 CPU 綁定,並且你重視響應能力,請使用 async
和 await
,並在另一個線程上使用 Task.Run
生成工作。 如果該工作同時適用於並發和並行,則應考慮使用任務並行庫。
此外,應始終對代碼的執行進行測量。 例如,你可能會遇到這樣的情況:多線程處理時,上下文切換的開銷高於 CPU 綁定工作的開銷。 每種選擇都有折衷,應根據自身情況選擇正確的折衷方案。
如果打算在生產代碼中進行 HTML 分析,則不要使用正則表達式。 改為使用分析庫。
private readonly HttpClient _httpClient = new HttpClient(); [HttpGet] [Route("DotNetCount")] public async Task<int> GetDotNetCountAsync() { // 掛起 GetDotNetCountAsync()方法,以允許調用方(Web服務器)接受另一個請求,而不是阻止此請求。 var html = await _httpClient.GetStringAsync("https://dotnetfoundation.org"); return Regex.Matches(html, @"\.NET").Count; }
以下是為通用 Windows 應用編寫的相同方案,當按下按鈕時,它將執行相同的任務:
private readonly HttpClient _httpClient = new HttpClient(); private async void SeeTheDotNets_Click(object sender, RoutedEventArgs e) { // 在這里捕獲任務句柄,以便稍后等待后台任務 var getDotNetFoundationHtmlTask = _httpClient.GetStringAsync("https://www.dotnetfoundation.org"); // 用戶界面線程上的任何其他工作都可以在這里完成,例如啟用進度條。 // 在“等待”調用之前,這一點很重要,這樣用戶就可以在生成此方法的執行之前看到進度條。 NetworkProgressBar.IsEnabled = true; NetworkProgressBar.Visibility = Visibility.Visible; // await 操作符掛起 SeeTheDotNets_Click 事件,將控制權返回給調用方。 // 這使得應用程序能夠響應而不阻塞UI線程。 var html = await getDotNetFoundationHtmlTask; int count = Regex.Matches(html, @"\.NET").Count; DotNetCountLabel.Text = $"Number of .NETs on dotnetfoundation.org: {count}"; NetworkProgressBar.IsEnabled = false; NetworkProgressBar.Visibility = Visibility.Collapsed; }
等待多個任務完成
你可能發現自己處於需要並行檢索多個數據部分的情況。 Task
API 包含兩種方法(即 Task.WhenAll
和 Task.WhenAny
),這些方法允許你編寫在多個后台作業中執行非阻止等待的異步代碼。
此示例演示如何為一組 User
捕捉 userId
數據。
public async Task<User> GetUserAsync(int userId) { // ··· 省略的業務邏輯代碼 // 給定用戶Id {userId},檢索與數據庫中條目對應的用戶對象,其中 {userId}作為其ID } public static async Task<IEnumerable<User>> GetUsersAsync(IEnumerable<int> userIds) { var getUserTasks = new List<Task<User>>(); foreach (int userId in userIds) { getUserTasks.Add(GetUserAsync(userId)); } return await Task.WhenAll(getUserTasks); }
以下是使用 LINQ 進行更簡潔編寫的另一種方法:
public async Task<User> GetUserAsync(int userId) { // ··· 省略的業務邏輯代碼 // 給定用戶Id {userId},檢索與數據庫中條目對應的用戶對象,其中 {userId}作為其ID } public static async Task<User[]> GetUsersAsync(IEnumerable<int> userIds) { var getUserTasks = userIds.Select(id => GetUserAsync(id)); return await Task.WhenAll(getUserTasks); }
盡管它的代碼較少,但在混合 LINQ 和異步代碼時需要謹慎操作。 因為 LINQ 使用延遲的執行,因此異步調用將不會像在 foreach()
循環中那樣立刻發生,除非強制所生成的序列通過對 .ToList()
或 .ToArray()
的調用循環訪問。
盡管異步編程相對簡單,但應記住一些可避免意外行為的要點。
async
方法需在其主體中具有await
關鍵字,否則它們將永不暫停!
這一點需牢記在心。 如果 await
未用在 async
方法的主體中,C# 編譯器將生成一個警告,但此代碼將會以類似普通方法的方式進行編譯和運行。 請注意這會導致效率低下,因為由 C# 編譯器為異步方法生成的狀態機將不會完成任何任務。
- 應將“Async”作為后綴添加到所編寫的每個異步方法名稱中。
這是 .NET 中的慣例,以便更輕松區分同步和異步方法。 請注意,未由代碼顯式調用的某些方法(如事件處理程序或 Web 控制器方法)並不一定適用。 由於它們未由代碼顯式調用,因此對其顯式命名並不重要。
async void
應僅用於事件處理程序。
async void
是允許異步事件處理程序工作的唯一方法,因為事件不具有返回類型(因此無法利用 Task
和 Task<T>
)。 其他任何對 async void
的使用都不遵循 TAP 模型,且可能存在一定使用難度,例如:
-
async void
方法中引發的異常無法在該方法外部被捕獲。 -
十分難以測試
async void
方法。 -
如果調用方不希望
async void
方法是異步方法,則這些方法可能會產生不好的副作用。 -
在 LINQ 表達式中使用異步 lambda 時請謹慎
LINQ 中的 Lambda 表達式使用延遲執行,這意味着代碼可能在你並不希望結束的時候停止執行。如果編寫不正確,將阻塞任務引入其中時可能很容易導致死鎖。 此外,此類異步代碼嵌套可能會對推斷代碼的執行帶來更多困難。 Async 和 LINQ 的功能都十分強大,但在結合使用兩者時應盡可能小心。
- 采用非阻止方式編寫等待任務的代碼
將阻止當前線程作為等待任務完成的方法可能導致死鎖和已阻止的上下文線程,且可能需要更復雜的錯誤處理。 下表提供了關於如何以非阻止方式處理等待任務的指南:
使用以下方式... | 而不是… | 若要執行此操作 |
---|---|---|
await |
Task.Wait 或 Task.Result |
檢索后台任務的結果 |
await Task.WhenAny |
Task.WaitAny |
等待任何任務完成 |
await Task.WhenAll |
Task.WaitAll |
等待所有任務完成 |
await Task.Delay |
Thread.Sleep |
等待一段時間 |
- 編寫狀態欠缺的代碼
請勿依賴全局對象的狀態或某些方法的執行。 請僅依賴方法的返回值。 為什么?
- 這樣更容易推斷代碼。
- 這樣更容易測試代碼。
- 混合異步和同步代碼更簡單。
- 通常可完全避免爭用條件。
- 通過依賴返回值,協調異步代碼可變得簡單。
- (好處)它非常適用於依賴關系注入。
建議的目標是實現代碼中完整或接近完整的引用透明度。 這么做能獲得高度可預測、可測試和可維護的基本代碼。
其他資源
- 深入了解異步提供了關於任務如何工作的詳細信息。
- 使用 Async 和 Await 的異步編程 (C#)
- 由 Lucian Wischik 所著的 Six Essential Tips for Async(關於異步的六個要點)是有關異步編程的絕佳資源