非同步程式設計案例
如果您的程式代碼實作 I/O 系結案例來支援網路數據要求、數據庫存取或文件系統讀取/寫入,異步程式設計是最佳方法。 您也可以針對 CPU 密集型場景撰寫非同步程式,例如昂貴的計算。
C# 具有語言層級的非同步程式設計模型,讓您可以輕鬆撰寫非同步程式碼,而不需要處理回呼或依賴支援非同步的函式庫。 此模型遵循所謂的 工作架構異步模式 (TAP)。
探索異步程序設計模型
Task
和 Task<T>
物件代表異步程序設計的核心。 這些物件可藉由支援 async
和 await
關鍵詞,來建立異步作的模型。 在大部分情況下,I/O 系結和 CPU 系結案例的模型相當簡單。 在 async
方法內:
-
I/O 系結程式代碼 會啟動
async
方法內Task
或Task<T>
物件所代表的作業。 - CPU 系結程式代碼 使用 Task.Run 方法在背景線程上啟動作業。
在這兩種情況下,活躍中的 Task
代表著可能未完成的異步操作。
await
關鍵字就是顯現魔力的地方。 它會對包含 await
表達式之方法的呼叫端產生控制權,最後可讓UI回應或服務具有彈性。 雖然 使用 async
和 await
表示式以外的異步程序代碼,但本文著重於語言層級建構。
注意
本文中提供的一些範例會使用 System.Net.Http.HttpClient 類別,從 Web 服務下載數據。 在範例程式代碼中,s_httpClient
對像是類型為 Program
類別的靜態字段:
private static readonly HttpClient s_httpClient = new();
如需詳細資訊,請參閱本文結尾的 完整範例程式代碼。
檢視基礎概念
當您在 C# 程式代碼中實作異步程式設計時,編譯程式會將程式轉換成狀態機器。 此建構會追蹤程式代碼中的各種作業和狀態,例如在程式代碼到達 await
表達式時產生執行,並在背景作業完成時繼續執行。
就計算機科學理論而言,異步程式設計是 Promise 模型的異步實作。
在異步程序設計模型中,有幾個要瞭解的重要概念:
- 您可以針對 I/O 系結和 CPU 系結程式代碼使用異步程式代碼,但實作不同。
- 異步程式代碼會使用
Task<T>
和Task
對象作為建構,以建立背景中執行工作的模型。 -
async
關鍵詞會將方法宣告為異步方法,這可讓您在方法主體中使用await
關鍵詞。 - 當您套用
await
關鍵詞時,程式代碼會暫停呼叫方法,並將控制權傳回給呼叫端,直到工作完成為止。 - 您只能在異步方法中使用
await
表示式。
I/O 系結範例:從 Web 服務下載數據
在此範例中,當用戶選取按鈕時,應用程式會從 Web 服務下載數據。 您不想在下載程式期間封鎖應用程式的 UI 線程。 下列程式代碼會完成這項工作:
s_downloadButton.Clicked += async (o, e) =>
{
// This line will yield control to the UI as the request
// from the web service is happening.
//
// The UI thread is now free to perform other work.
var stringData = await s_httpClient.GetStringAsync(URL);
DoSomethingWithData(stringData);
};
程式碼表達了非同步下載資料的意圖,而不會被與 Task
物件的互動所干擾。
CPU 限制範例:進行遊戲計算
在下一個範例中,手機遊戲會對螢幕上的數個角色造成傷害,以回應按鈕事件。 執行損毀計算可能會很昂貴。 在UI線程上執行計算可能會導致計算期間顯示和UI互動問題。
處理工作的最佳方式是啟動背景線程,以使用 Task.Run
方法完成工作。 作業會使用 await
表示式來產生結果。 工作完成時,作業會繼續。 此方法可讓使用者介面(UI)在背景工作完成時順利運行。
static DamageResult CalculateDamageDone()
{
return new DamageResult()
{
// Code omitted:
//
// Does an expensive calculation and returns
// the result of that calculation.
};
}
s_calculateButton.Clicked += async (o, e) =>
{
// This line will yield control to the UI while CalculateDamageDone()
// performs its work. The UI thread is free to perform other work.
var damageResult = await Task.Run(() => CalculateDamageDone());
DisplayDamage(damageResult);
};
程序代碼清楚表示按鈕 Clicked
事件的意圖。 它不需要手動管理背景線程,而且會以非封鎖方式完成工作。
辨識 CPU 系結和 I/O 系結案例
上述範例示範如何使用 I/O 系結和 CPU 系結工作的 async
修飾詞和 await
表示式。 每個案例的範例都示範程序代碼如何根據作業系結的位置而有所不同。 若要準備實作,您必須瞭解如何識別作業何時為 I/O 系結或 CPU 系結。 您的實作選擇可能會大幅影響程式碼的效能,並可能導致誤用建構。
撰寫任何程序代碼之前,有兩個主要問題可解決:
問題 | 場景 | 實現 |
---|---|---|
程式代碼是否應該等候結果或動作,例如來自資料庫的數據? | I/O 系結 | 使用 async 修飾詞和 await 表示式 ,而不Task.Run 方法。 避免使用任務平行庫。 |
程式代碼是否應該執行昂貴的計算? | 受限於 CPU | 使用 async 修飾詞和 await 表示式,但使用 Task.Run 方法在另一個線程上執行工作。 此方法可解決 CPU 回應性的問題。 如果工作適用於並行與平行處理原則,也可以考慮使用工作平行程式庫。 |
一律測量程式代碼的執行。 與多線程運行時的上下文切換開銷相比,您可能會發現受CPU限制的工作成本並沒有那麼高。 每一個選擇都有取捨。 為您的情況挑選正確的取捨。
探索其他範例
本節中的範例示範數種方式,您可以在 C# 中撰寫異步程序代碼。 它們涵蓋您可能遇到的幾個案例。
從網路擷取資料
下列程式代碼會從指定的 URL 下載 HTML,並計算 HTML 中發生字串 「.NET」 的次數。 程序代碼會使用 ASP.NET 來定義 Web API 控制器方法,該方法會執行工作並傳回計數。
注意
如果您打算在生產程式碼中執行 HTML 剖析,請不要使用規則運算式。 請改用剖析程式庫。
[HttpGet, Route("DotNetCount")]
static public async Task<int> GetDotNetCount(string URL)
{
// Suspends GetDotNetCount() to allow the caller (the web server)
// to accept another request, rather than blocking on this one.
var html = await s_httpClient.GetStringAsync(URL);
return Regex.Matches(html, @"\.NET").Count;
}
您可以為通用 Windows App 撰寫類似的程式代碼,並在按下按鈕之後執行計數工作:
private readonly HttpClient _httpClient = new HttpClient();
private async void OnSeeTheDotNetsButtonClick(object sender, RoutedEventArgs e)
{
// Capture the task handle here so we can await the background task later.
var getDotNetFoundationHtmlTask = _httpClient.GetStringAsync("https://dotnetfoundation.org");
// Any other work on the UI thread can be done here, such as enabling a Progress Bar.
// It's important to do the extra work here before the "await" call,
// so the user sees the progress bar before execution of this method is yielded.
NetworkProgressBar.IsEnabled = true;
NetworkProgressBar.Visibility = Visibility.Visible;
// The await operator suspends OnSeeTheDotNetsButtonClick(), returning control to its caller.
// This action is what allows the app to be responsive and not block the UI thread.
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 方法
下列範例示範如何擷取一組 userId
物件的 User
對象數據。
private static async Task<User> GetUserAsync(int userId)
{
// Code omitted:
//
// Given a user Id {userId}, retrieves a User object corresponding
// to the entry in the database with {userId} as its Id.
return await Task.FromResult(new User() { id = userId });
}
private 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 更簡潔撰寫此程式代碼:
private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
{
var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
return await Task.WhenAll(getUserTasks);
}
雖然使用 LINQ 撰寫的程式代碼較少,但在混合 LINQ 與異步程式代碼時請小心。 LINQ 會使用延遲執行。 異步呼叫不會像在 foreach
迴圈中那樣立即發生,除非您透過對 .ToList()
或 .ToArray()
方法的呼叫強制產生的序列進行迭代。 這個範例會使用 Enumerable.ToArray 方法來積極執行查詢,並將結果儲存在陣列中。 此方法會強制 id => GetUserAsync(id)
指令執行並起始工作。
檢視非同步程式設計中需要考量的因素
在進行非同步程式設計時,有幾個需要注意的細節,以避免出現意外行為。
在 `async()` 方法主體中使用 `await`
當您使用 async
修飾詞時,應該在方法主體中包含一或多個 await
表達式。 如果編譯程式未遇到 await
表示式,則方法無法產生。 雖然編譯程式會產生警告,但程式代碼仍會編譯,而編譯程式會執行 方法。 C# 編譯器為異步方法生成的狀態機器沒有達成任何實質作用,因此整個過程效率非常低。
將 「Async」 後綴新增至異步方法名稱
.NET 樣式慣例是將 “Async” 後綴新增至所有異步方法名稱。 這種方法有助於更輕鬆地區分同步和異步方法。 您程式代碼未明確呼叫的某些方法(例如事件處理程式或 Web 控制器方法)不一定適用於此案例。 因為程式代碼不會明確呼叫這些專案,因此使用明確命名並不重要。
僅從事件處理程式傳回 'async void'
事件處理程式必須宣告 void
傳回型別,而且不能像其他方法一樣使用或傳回 Task
和 Task<T>
物件。 當您撰寫異步事件處理程式時,您必須在 void
傳回處理程式的 方法上使用 async
修飾詞。 傳回方法的其他 async void
實作不會遵循 TAP 模型,而且可能會提出挑戰:
-
async void
方法擲回的例外狀況無法在該方法外部捕捉 -
async void
方法難以測試 -
async void
方法如果呼叫端未預期它們是非同步的,可能會導致負面影響
在LINQ中使用異步 Lambda 時請小心
當您在 LINQ 運算式中實作異步 Lambda 時,請務必小心使用。 LINQ 中的 Lambda 運算式會使用延後執行,這表示程式代碼可以在非預期的時間執行。 如果程式代碼未正確撰寫,將封鎖工作引入此案例,就很容易造成死結。 此外,異步程式代碼的巢狀結構也使得難以推斷程式碼的執行。 Async 和 LINQ 功能強大,但這些技術應該盡可能小心且清楚地一起使用。
讓予任務以非封鎖方式
如果您的程式需要工作的結果,請撰寫程式代碼,以非封鎖方式實作 await
表達式。 阻塞當前線程以同步等待 Task
項目完成可能會導致死結與上下文線程的阻塞。 此程序設計方法可能需要更複雜的錯誤處理。 下表提供以非封鎖方式存取工作結果的指引:
工作案例 | 目前的程序代碼 | 將 取代為 'await' |
---|---|---|
擷取背景工作的結果 |
Task.Wait 或 Task.Result |
await |
當任何任務完成時,繼續 | Task.WaitAny |
await Task.WhenAny |
當 所有 任務完成時,繼續 | Task.WaitAll |
await Task.WhenAll |
在一段時間後繼續 | Thread.Sleep |
await Task.Delay |
請考慮使用 ValueTask 類型
當異步方法傳回 Task
物件時,可能會在特定路徑中引入效能瓶頸。 由於 Task
是參考型別,因此會從堆積配置 Task
物件。 如果使用 async
修飾詞宣告的方法傳回快取的結果或以同步方式完成,額外的配置可能會在程式代碼的效能關鍵區段中產生大量的時間成本。 當分配在緊密迴圈中發生時,此情況可能會變得昂貴。 如需詳細資訊,請參閱通用的非同步傳回型別。
瞭解何時設定 ConfigureAwait(false)
開發人員通常會詢問何時使用 Task.ConfigureAwait(Boolean) 布爾值。 此 API 可讓 Task
實例設定實作任何 await
表示式之狀態機器的內容。 如果未正確設定布爾值,效能可能會降低或發生死結。 如需詳細資訊,請參閱 ConfigureAwait FAQ。
撰寫較少狀態的程式碼
請避免撰寫相依於全域物件狀態或執行特定方法的程序代碼。 相反地,請僅依賴方法的回傳值。 撰寫較少依賴狀態的程式碼有許多好處:
- 更容易理解代碼
- 更容易測試程序代碼
- 更容易混合同步和異步程式碼
- 能夠在程序代碼中避免競爭狀況
- 簡單協調依賴於返回值的非同步程式碼
- (附加功能)在程式代碼中搭配依賴注入運作良好
建議的目標是在程式碼中達到完全或幾乎完全的 Referential Transparency (參考透明度)。 此方法會產生可預測的、可測試且可維護的程式代碼基底。
檢閱完整的範例
下列程式代碼代表完整的範例,可在 Program.cs 範例檔案中使用。
using System.Text.RegularExpressions;
using System.Windows;
using Microsoft.AspNetCore.Mvc;
class Button
{
public Func<object, object, Task>? Clicked
{
get;
internal set;
}
}
class DamageResult
{
public int Damage
{
get { return 0; }
}
}
class User
{
public bool isEnabled
{
get;
set;
}
public int id
{
get;
set;
}
}
public class Program
{
private static readonly Button s_downloadButton = new();
private static readonly Button s_calculateButton = new();
private static readonly HttpClient s_httpClient = new();
private static readonly IEnumerable<string> s_urlList = new string[]
{
"https://learn.microsoft.com",
"https://learn.microsoft.com/aspnet/core",
"https://learn.microsoft.com/azure",
"https://learn.microsoft.com/azure/devops",
"https://learn.microsoft.com/dotnet",
"https://learn.microsoft.com/dotnet/desktop/wpf/get-started/create-app-visual-studio",
"https://learn.microsoft.com/education",
"https://learn.microsoft.com/shows/net-core-101/what-is-net",
"https://learn.microsoft.com/enterprise-mobility-security",
"https://learn.microsoft.com/gaming",
"https://learn.microsoft.com/graph",
"https://learn.microsoft.com/microsoft-365",
"https://learn.microsoft.com/office",
"https://learn.microsoft.com/powershell",
"https://learn.microsoft.com/sql",
"https://learn.microsoft.com/surface",
"https://dotnetfoundation.org",
"https://learn.microsoft.com/visualstudio",
"https://learn.microsoft.com/windows",
"https://learn.microsoft.com/maui"
};
private static void Calculate()
{
// <PerformGameCalculation>
static DamageResult CalculateDamageDone()
{
return new DamageResult()
{
// Code omitted:
//
// Does an expensive calculation and returns
// the result of that calculation.
};
}
s_calculateButton.Clicked += async (o, e) =>
{
// This line will yield control to the UI while CalculateDamageDone()
// performs its work. The UI thread is free to perform other work.
var damageResult = await Task.Run(() => CalculateDamageDone());
DisplayDamage(damageResult);
};
// </PerformGameCalculation>
}
private static void DisplayDamage(DamageResult damage)
{
Console.WriteLine(damage.Damage);
}
private static void Download(string URL)
{
// <UnblockingDownload>
s_downloadButton.Clicked += async (o, e) =>
{
// This line will yield control to the UI as the request
// from the web service is happening.
//
// The UI thread is now free to perform other work.
var stringData = await s_httpClient.GetStringAsync(URL);
DoSomethingWithData(stringData);
};
// </UnblockingDownload>
}
private static void DoSomethingWithData(object stringData)
{
Console.WriteLine($"Displaying data: {stringData}");
}
// <GetUsersForDataset>
private static async Task<User> GetUserAsync(int userId)
{
// Code omitted:
//
// Given a user Id {userId}, retrieves a User object corresponding
// to the entry in the database with {userId} as its Id.
return await Task.FromResult(new User() { id = userId });
}
private 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);
}
// </GetUsersForDataset>
// <GetUsersForDatasetByLINQ>
private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
{
var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
return await Task.WhenAll(getUserTasks);
}
// </GetUsersForDatasetByLINQ>
// <ExtractDataFromNetwork>
[HttpGet, Route("DotNetCount")]
static public async Task<int> GetDotNetCount(string URL)
{
// Suspends GetDotNetCount() to allow the caller (the web server)
// to accept another request, rather than blocking on this one.
var html = await s_httpClient.GetStringAsync(URL);
return Regex.Matches(html, @"\.NET").Count;
}
// </ExtractDataFromNetwork>
static async Task Main()
{
Console.WriteLine("Application started.");
Console.WriteLine("Counting '.NET' phrase in websites...");
int total = 0;
foreach (string url in s_urlList)
{
var result = await GetDotNetCount(url);
Console.WriteLine($"{url}: {result}");
total += result;
}
Console.WriteLine("Total: " + total);
Console.WriteLine("Retrieving User objects with list of IDs...");
IEnumerable<int> ids = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
var users = await GetUsersAsync(ids);
foreach (User? user in users)
{
Console.WriteLine($"{user.id}: isEnabled={user.isEnabled}");
}
Console.WriteLine("Application ending.");
}
}
// Example output:
//
// Application started.
// Counting '.NET' phrase in websites...
// https://learn.microsoft.com: 0
// https://learn.microsoft.com/aspnet/core: 57
// https://learn.microsoft.com/azure: 1
// https://learn.microsoft.com/azure/devops: 2
// https://learn.microsoft.com/dotnet: 83
// https://learn.microsoft.com/dotnet/desktop/wpf/get-started/create-app-visual-studio: 31
// https://learn.microsoft.com/education: 0
// https://learn.microsoft.com/shows/net-core-101/what-is-net: 42
// https://learn.microsoft.com/enterprise-mobility-security: 0
// https://learn.microsoft.com/gaming: 0
// https://learn.microsoft.com/graph: 0
// https://learn.microsoft.com/microsoft-365: 0
// https://learn.microsoft.com/office: 0
// https://learn.microsoft.com/powershell: 0
// https://learn.microsoft.com/sql: 0
// https://learn.microsoft.com/surface: 0
// https://dotnetfoundation.org: 16
// https://learn.microsoft.com/visualstudio: 0
// https://learn.microsoft.com/windows: 0
// https://learn.microsoft.com/maui: 6
// Total: 238
// Retrieving User objects with list of IDs...
// 1: isEnabled= False
// 2: isEnabled= False
// 3: isEnabled= False
// 4: isEnabled= False
// 5: isEnabled= False
// 6: isEnabled= False
// 7: isEnabled= False
// 8: isEnabled= False
// 9: isEnabled= False
// 0: isEnabled= False
// Application ending.