如果有 I/O-bound 需求(例如從網絡請求數據、訪問數據庫、讀取和寫入文件系統),則需要使用異步編程(Asynchronous programming)。或者有 CPU-bound 代碼(例如執行昂貴的計算),也是編寫異步代碼的好場景。
C# 具有語言級異步編程模型(language-level asynchronous programming model),該模型使得我們可以輕鬆編寫異步代碼,而無需處理回調或符合支持異步的庫,它遵循所謂的 Task-based Asynchronous Pattern(TAP)。
異步模型概述#
異步編程的核心是 Task
和 Task<T>
對象,它們是異步操作的模型。它們由 async
和 await
關鍵字支持,在大多數情況下,該模型相當簡單:
- 對於 I/O-bound 代碼,可以在異步方法中
await
返回Task
或Task<T>
的操作 - 對於 CPU-bound 代碼,可以使用 Task.Run 方法在後台線程上啟動一個操作,並通過 await 來等待它的完成
await
關鍵字是異步編程的關鍵所在。它將控制權交還給執行 await
的方法的調用者,最終實現了界面的響應性或服務的彈性。雖然有其他處理異步代碼的方法,但本文重點介紹語言級別的構造。
I/O-bound 示例:從 Web 服務下載數據#
當按下某個按鈕時,可能需要從 Web 服務下載一些數據,但不希望阻塞 UI 線程。可以使用 System.Net.Http.HttpClient 類來實現如下:
private readonly HttpClient _httpClient = new HttpClient();
downloadButton.Clicked += async (o, e) =>
{
// 這行代碼將在向 Web 服務發送請求時將控制權交還給 UI 線程。
//
// 此時,UI 線程可以自由地執行其他工作。
var stringData = await _httpClient.GetStringAsync(URL);
DoSomethingWithData(stringData);
};
這段代碼表達了異步下載數據的意圖,而不會陷入與 Task
對象交互的細節中。
CPU-bound 示例:為遊戲執行昂貴的計算#
假設我們正在編寫一款移動遊戲,在遊戲中按下按鈕可以對屏幕上的多個敵人造成傷害。進行傷害計算可能會非常耗時,如果在 UI 線程上執行計算,遊戲會因為計算而出現卡頓!
最好的處理方式是使用 [Task.Run](http://Task.Run)
啟動一個後台線程來執行計算,並使用 await
等待其結果。這樣,遊戲的用戶界面會保持流暢,因為計算是在後台進行的。
private DamageResult CalculateDamageDone()
{
// 代碼已省略:
//
// 進行昂貴的計算,並返回計算結果。
}
calculateButton.Clicked += async (o, e) =>
{
// 這行代碼在 CalculateDamageDone() 執行工作時將控制權交還給 UI。UI 線程可以自由地執行其他工作。
var damageResult = await Task.Run(() => CalculateDamageDone());
DisplayDamage(damageResult);
};
這段代碼清晰地表達了按鈕的點擊事件的意圖,它不需要手動管理後台線程,並且以非阻塞(non-blocking)的方式實現。
內部原理#
在 C# 方面,編譯器(compiler)將代碼轉換為一個狀態機(state machine),用於跟踪當遇到 await 時的執行暫停,並在後台任務完成後恢復執行。
對於研究理論的人來說,這是 異步性的 Promise 模型(Promise Model of asynchrony) 的實現。
需要理解的關鍵要點#
- 異步代碼可以用於 I/O-bound 和 CPU-bound 的代碼,但在每種情況下使用的方式不同。
- 異步代碼使用
Task<T>
和Task
這兩個結構來模擬後台工作的執行。 async
關鍵字將方法轉換為異步方法,從而允許在方法體內使用await
關鍵字。- 當應用
await
關鍵字時,它會暫停調用方法,並將控制權交還給調用者,直到等待的任務完成。 - await 只能在異步方法內使用。
識別 CPU-bound 和 I/O-bound 工作#
前兩個示例展示了如何在 I/O-bound 和 CPU-bound 的工作中使用 async
和 await
。重要的是,能夠識別出所需執行的任務是 I/O-bound 還是 CPU-bound,因為它會極大地影響代碼的性能,並且有可能導致誤用某些結構。
在編寫任何代碼之前,應該先考慮兩個問題:
- 您的代碼是否會等待(waiting)某些內容,例如來自數據庫的數據?
如果 “是”,那麼該任務屬於 I/O-bound。 - 您的代碼是否會執行一個耗時的計算(performing an expensive computation)?
如果 “是”,那麼該任務屬於 CPU-bound。
如果工作是 I/O-bound,那麼在代碼中使用 async
和 await
,而無需使用 Task.Run
。更不應該使用任務並行庫(Task Parallel Library)。
如果工作是 CPU-bound,並且關注代碼的響應性能,那麼可以使用 async
和 await
,但需要通過 Task.Run
在另一個線程上執行工作。如果該工作適合並發和並行處理,還可以考慮使用任務並行庫(Task Parallel Library)。
此外,應該始終對代碼的執行進行測量。例如,有可能會發現,在多線程情況下,與上下文切換的開銷相比,CPU-bound 的工作並不太昂貴。每種選擇都有其利弊,應該根據實際情況做出正確的選擇。
更多示例#
下面的示例展示了在 C# 中編寫異步代碼的各種方法。它們涵蓋了可能遇到的幾種不同場景。
從網絡提取數據#
該代碼段從 https://dotnetfoundation.org 主頁下載 HTML,並計算 HTML 中字符串 “.NET” 的出現次數。它使用 ASP.NET 定義了一個 Web API 控制器方法,該方法將執行此任務並返回次數。
private readonly HttpClient _httpClient = new HttpClient();
[HttpGet, Route("DotNetCount")]
public async Task<int> GetDotNetCount()
{
// 將 GetDotNetCount() 掛起(Suspend),以允許調用者(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 OnSeeTheDotNetsButtonClick(object sender, RoutedEventArgs e)
{
// 在此捕獲任務句柄(task handle),以便稍後 await 後台任務。
var getDotNetFoundationHtmlTask = _httpClient.GetStringAsync("https://dotnetfoundation.org");
// 可以在這裡進行其他與 UI 線程相關的工作,例如啟用進度條。
// 這一點很重要,在 "await" 調用之前完成,以便用戶在該方法的執行暫停之前能夠看到進度條。
NetworkProgressBar.IsEnabled = true;
NetworkProgressBar.Visibility = Visibility.Visible;
// "await" 操作符將掛起 OnSeeTheDotNetsButtonClick() 方法,將控制權返回給調用者。這就是使應用程序能夠響應並不阻塞 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,允許我們編寫異步代碼,對多個後台任務進行非阻塞等待。
以下示例展示了如何獲取一組用戶 ID 的用戶數據:
public async Task<User> GetUserAsync(int userId)
{
// 代碼已省略:
//
// 給定一個用戶ID {userId},檢索與數據庫中的 {userId} 對應的用戶對象。
}
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} 對應的用戶對象。
}
public static async Task<User[]> GetUsersAsync(IEnumerable<int> userIds)
{
var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
return await Task.WhenAll(getUserTasks);
}
儘管使用 LINQ 可以減少代碼量,但在將 LINQ 與異步代碼混合使用時需要注意。因為 LINQ 使用延遲執行(deferred (lazy) execution),除非通過調用 .ToList()
或 .ToArray()
來強制生成的序列進行迭代,否則異步調用不會立即發生,就像在 foreach
循環中那樣。上面的示例使用 Enumerable.ToArray 來迫使查詢立即執行並將結果存儲在數組中。這樣可以使代碼 id => GetUserAsync(id)
運行並啟動該任務。
重要信息和建議#
在異步編程中,有一些細節需要牢記,這些細節可以防止出現意外行為。
-
異步方法(
async
methods)需要在其主體中使用await
關鍵字,否則它們將永遠不會產生結果!這一點非常重要。如果在異步方法的主體中不使用
await
,C# 編譯器會生成一個警告,但代碼會被編譯和運行,就像是普通方法一樣。這非常低效,因為由 C# 編譯器為異步方法生成的狀態機沒有實現任何功能。 -
在編寫的每個異步方法名後面添加 "Async" 作為後綴。
這是在 .NET 中使用的約定,可以更容易地區分同步和異步方法。某些方法(例如事件處理程序或 Web 控制器方法)不一定適用於此約定。因為它們不是由您的代碼顯式調用的,所以對它們的命名進行明確並不那麼重要。 -
**async void
應僅用於事件處理程序(event handlers)。**async void
是允許異步事件處理程序工作的唯一方式,因為事件沒有返回類型(因此無法使用Task
和Task<T>
)。對於其他任何使用async void
的情況都不符合 TAP 模型,並且可能很難使用,例如:- 在
async void
方法中拋出的異常無法在該方法之外捕獲。 - 很難對
async void
方法進行測試。 - 如果調用者不期望它們是異步的,
async void
方法可能會引發不良的副作用。
- 在
-
在使用 LINQ 表達式中的異步 lambda 表達式時要謹慎。
LINQ 中的 Lambda 表達式使用延遲執行(deferred execution),這意味著代碼可能在我們不希望執行的時候執行。如果沒有正確編寫,引入阻塞任務可能很容易導致死鎖。此外,像這樣嵌套異步代碼也可能使得對代碼執行的理解更加困難。Async 和 LINQ 都是強大的工具,但在結合使用兩者時應盡可能小心。
-
以非阻塞的方式編寫等待任務完成的代碼。
通過阻塞當前線程來等待任務完成可能導致死鎖和阻塞上下文線程,並且可能需要更複雜的錯誤處理。以下表格提供了關於如何以非阻塞的方式處理等待任務的指南:
若要執行此操作... 使用以下方式... 而不是… 獲取後台任務的結果 await Task.Wait 或 Task.Result 等待任何任務完成 await Task.WhenAny Task.WaitAny 等待所有任務完成 await Task.WhenAll Task.WaitAll 等待一段時間 await Task.Delay Thread.Sleep -
在可能的情況下考慮使用 ValueTask。
從異步方法返回
Task
對象可能會在某些路徑上引入性能瓶頸。Task
是一個引用類型,因此使用它意味著分配一個對象。在聲明為async
修飾符的方法返回緩存結果或同步完成的情況下,額外的分配可能會成為代碼性能關鍵部分的顯著時間開銷。如果這些分配發生在緊密循環中,那麼代價會變得更高。關於更多信息,請參見 通用的異步返回類型(generalized async return types)。ValueTask
結構體是 .NET 提供的一種用於優化性能的替代方案。ValueTask
可以避免在某些情況下對Task
的分配,並且在異步操作直接返回結果時可以提高性能。在性能關鍵的場景下,考慮使用ValueTask
來減少不必要的內存分配和提高性能。 -
考慮使用
ConfigureAwait(false)
。一個常見的問題是,“什麼時候應該使用 Task.ConfigureAwait(Boolean) 方法?” 該方法允許
Task
實例配置其 awaiter。這是一個重要的考慮因素,如果設置不正確,可能會產生性能問題甚至導致死鎖。關於ConfigureAwait
的更多信息,請參見 ConfigureAwait FAQ。在異步編程中,使用
ConfigureAwait(false)
可以告知運行時不要將等待操作返回到原始上下文,從而提高性能並減少上下文切換的開銷。但是,在某些情況下,例如需要訪問 UI 線程的操作或需要確保代碼在特定上下文中執行的情況下,應當慎重使用 ConfigureAwait (false)。根據具體情況進行權衡和決策,以確保代碼的正確性和性能優化。 -
編寫無狀態的代碼。
不要依賴全局對象的狀態或特定方法的執行。相反,只依賴於方法的返回值。為什麼呢?
- 代碼更容易理解。
- 代碼更容易進行測試。
- 混合使用異步和同步代碼更簡單。
- 通常可以完全避免競爭條件。
- 依賴返回值使得協調異步代碼變得簡單。
- (額外好處)與依賴注入非常搭配。
通過減少對狀態的依賴,我們可以提高代碼的可維護性和可測試性。同時,避免使用全局狀態可以減少競爭條件和並發問題的出現。依賴方法的返回值,可以更清晰地了解代碼的行為,也使得代碼的編寫和調試更簡單。總之,採用無狀態的編程風格可以提升代碼質量和開發效率。
建議的目標是在代碼中實現完全或接近完全的引用透明性(Referential Transparency)。這樣做將產生可預測、可測試和易於維護的代碼庫。
參考#
- Asynchronous programming - C# | Microsoft Learn
- Task-based Asynchronous Pattern (TAP): Introduction and overview | Microsoft Learn
- https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/async#return-types
- https://devblogs.microsoft.com/dotnet/configureawait-faq
- https://en.wikipedia.org/wiki/Referential_transparency_(computer_science)