myesn

myEsn2E9

hi
github

C#: 異步編程場景(Asynchronous programming scenarios)

如果有 I/O-bound 需求(例如從網絡請求數據、訪問數據庫、讀取和寫入文件系統),則需要使用異步編程(Asynchronous programming)。或者有 CPU-bound 代碼(例如執行昂貴的計算),也是編寫異步代碼的好場景。

C# 具有語言級異步編程模型(language-level asynchronous programming model),該模型使得我們可以輕鬆編寫異步代碼,而無需處理回調或符合支持異步的庫,它遵循所謂的 Task-based Asynchronous Pattern(TAP)

異步模型概述#

異步編程的核心是 TaskTask<T> 對象,它們是異步操作的模型。它們由 asyncawait 關鍵字支持,在大多數情況下,該模型相當簡單:

  • 對於 I/O-bound 代碼,可以在異步方法中 await 返回 TaskTask<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 的工作中使用 asyncawait。重要的是,能夠識別出所需執行的任務是 I/O-bound 還是 CPU-bound,因為它會極大地影響代碼的性能,並且有可能導致誤用某些結構。

在編寫任何代碼之前,應該先考慮兩個問題:

  1. 您的代碼是否會等待(waiting)某些內容,例如來自數據庫的數據
    如果 “是”,那麼該任務屬於 I/O-bound
  2. 您的代碼是否會執行一個耗時的計算(performing an expensive computation)
    如果 “是”,那麼該任務屬於 CPU-bound

如果工作是 I/O-bound,那麼在代碼中使用 asyncawait ,而無需使用 Task.Run 。更不應該使用任務並行庫(Task Parallel Library)

如果工作是 CPU-bound,並且關注代碼的響應性能,那麼可以使用 asyncawait,但需要通過 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.WhenAllTask.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 是允許異步事件處理程序工作的唯一方式,因為事件沒有返回類型(因此無法使用 TaskTask<T>)。對於其他任何使用 async void 的情況都不符合 TAP 模型,並且可能很難使用,例如:

    • async void 方法中拋出的異常無法在該方法之外捕獲。
    • 很難對 async void 方法進行測試。
    • 如果調用者不期望它們是異步的,async void 方法可能會引發不良的副作用。
  • 在使用 LINQ 表達式中的異步 lambda 表達式時要謹慎。

    LINQ 中的 Lambda 表達式使用延遲執行(deferred execution),這意味著代碼可能在我們不希望執行的時候執行。如果沒有正確編寫,引入阻塞任務可能很容易導致死鎖。此外,像這樣嵌套異步代碼也可能使得對代碼執行的理解更加困難。Async 和 LINQ 都是強大的工具,但在結合使用兩者時應盡可能小心。

  • 以非阻塞的方式編寫等待任務完成的代碼。

    通過阻塞當前線程來等待任務完成可能導致死鎖和阻塞上下文線程,並且可能需要更複雜的錯誤處理。以下表格提供了關於如何以非阻塞的方式處理等待任務的指南:

    若要執行此操作...使用以下方式...而不是…
    獲取後台任務的結果awaitTask.Wait 或 Task.Result
    等待任何任務完成await Task.WhenAnyTask.WaitAny
    等待所有任務完成await Task.WhenAllTask.WaitAll
    等待一段時間await Task.DelayThread.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)。這樣做將產生可預測、可測試和易於維護的代碼庫。

參考#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。