C# 异步编程场景
C# 异步编程场景
如果代码要实现 I/O 绑定方案以支持网络数据请求、数据库访问或文件系统读取/写入,则异步编程是最佳方法。 还可以为 CPU 绑定场景编写异步代码,例如耗时的计算。
异步编程模型
Task
和 Task<T>
对象共同表示异步编程的核心。 这些对象通过支持 async
和 await
关键字来进行异步操作建模。 在大多数情况下,对于 I/O 绑定和 CPU 绑定方案,模型相当简单。 在 async
方法中:
- I/O 绑定代码在Task方法中启动由
Task<T>
或async
对象表示的操作。 - CPU 绑定代码 使用
Task.Run
方法在后台线程上启动操作。
在这两种情况下,活动 Task
表示可能尚未完成的异步操作。
await
关键字有这奇妙的作用。 它向包含 await
表达式的方法的调用方生成控制权,并最终允许 UI 响应或服务具有弹性。虽然存在其他方法来处理异步代码,而不仅仅是使用async
和await
表达式。
基本概念
在 C# 代码中实现异步编程时,编译器会将程序转换为状态机
。此构造跟踪代码中的各种操作和状态,例如在代码到达 await
表达式时暂停执行,并在后台任务完成时恢复执行。
就计算机科学理论而言,异步编程是 异步编程模型的实现。
在异步编程模型中,有几个关键概念需要了解:
- 可以对 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 密集型情况
CPU:
- 代码是否应该等待结果或操作,例如数据库中的数据
- 使用
async
修饰符和await
表达式。避免使用任务并行库。
I/O:
- 代码是否应该运行昂贵的计算。
- 使用
async
修饰符和await
表达式,并使用Task.Run
方法在另一个线程上生成工作。此方法解决了CPU
响应能力方面的问题。如果该工作同时适用于并发和并行,还应考虑使用任务并行库。
从网络提取数据
以下代码从给定 URL 下载 HTML,并计算字符串“.NET”在 HTML 中发生的次数。
[HttpGet, Route("DotNetCount")]
static public async Task<int> GetDotNetCountAsync(string URL)
{
// Suspends GetDotNetCountAsync() 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;
}
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 方法
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 使用延迟(或延迟)执行,这意味着在枚举序列之前,不会发生异步调用。
前面的示例正确且安全,因为它使用 Enumerable.ToArray 该方法立即评估 LINQ 查询并将任务存储在数组中。 此方法可确保 id => GetUserAsync(id) 调用立即执行,并且所有任务同时启动,就像循环方法一 foreach 样。 始终使用 Enumerable.ToArray LINQ Enumerable.ToList 创建任务,以确保立即执行和并发任务执行。 下面是一个示例,演示如何使用ToList()Task.WhenAny在任务完成时处理任务。
private static async Task ProcessTasksAsTheyCompleteAsync(IEnumerable<int> userIds)
{
var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToList();
while (getUserTasks.Count > 0)
{
Task<User> completedTask = await Task.WhenAny(getUserTasks);
getUserTasks.Remove(completedTask);
User user = await completedTask;
Console.WriteLine($"Processed user {user.id}");
}
}
在此示例中, ToList()
创建一个支持 Remove()
该作的列表,使你可以动态删除已完成的任务。 当想要在结果可用时处理结果,而不是等待所有任务完成时,此模式特别有用。
尽管使用 LINQ 编写的代码较少,但在将 LINQ 与异步代码混合时,请谨慎作。 LINQ 使用延迟(或惰性)执行。 异步调用不会像在foreach
循环中那样立即发生,除非您通过调用.ToList()
或.ToArray()
方法强制生成的序列进行迭代。
可以根据您的需求在 Enumerable.ToArray
和 Enumerable.ToList
之间进行选择:
- 在计划将所有任务一起处理时使用
ToArray()
,例如Task.WhenAll
。 对于集合大小固定的方案,数组是有效的。 - 在需要动态管理任务时使用
ToList()
,例如Task.WhenAny
,在任务完成时,可以从集合中删除已完成的任务。
审查异步编程的注意事项
在async()方法主体中使用await
使用 async
修饰符时,应在方法正文中包含一个或多个 await
表达式。 如果编译器未遇到 await
表达式,该方法将无法生成。 尽管编译器生成警告,但代码仍会编译,编译器会运行该方法。 由 C# 编译器为异步方法生成的状态机无法完成任何工作,因此整个过程效率很低。
将“Async”后缀添加到异步方法名称
.NET 样式约定是将所有异步方法名称添加“Async”
后缀。 此方法有助于更轻松地区分同步和异步方法。
仅从事件处理程序返回“async void”
事件处理程序必须声明 void
返回类型,不能像其他方法一样使用或返回 Task
对象 Task<T>
。 编写异步事件处理程序时,需要对处理程序的返回方法使用 async
修饰符 void
。 返回方法的其他实现 async void
不遵循 TAP 模型,并且可能会带来挑战:
async void
方法中引发的异常无法在该方法外部被捕获async void
方法难以测试async void
方法在调用方未期望其为异步时可能会导致负面效果
在 LINQ 中谨慎使用异步 lambda
在 LINQ 表达式中实现异步 lambda 时,请务必小心。 LINQ 中的 Lambda 表达式使用延迟执行,这意味着代码可以在意外时间执行。 如果代码编写不正确,在这种情况下引入阻止任务很容易导致死锁。 此外,异步代码的嵌套也使得难以推理代码的执行。 Async 和 LINQ 非常强大,但这些技术应尽可能谨慎且清晰地一起使用。
以非阻止方式暂停任务
如果程序需要任务的结果,请编写以非阻止方式实现 await
表达式的代码。 通过阻止当前线程来同步等待 Task
项完成的方法可能导致死锁和已阻止的上下文线程。 此编程方法可能需要更复杂的错误处理。 下表提供了有关如何以非阻止方式访问任务结果的指导:
对异步操作的同步访问
在某些情况下,当await
关键字在整个调用堆栈中不可用时,您可能需要阻止异步操作。这种情况发生在旧代码库或将异步方法集成到无法更改的同步 API 中时。
警告
对异步操作的同步阻塞可能会导致死锁,因此应尽可能避免。 首选解决方案是在整个调用堆栈中使用 async/await
。
如果必须在Task
上同步阻止,以下是按优先程度从高到低的可用方法:
- 使用 GetAwaiter().GetResult()
- 将 Task.Run 用于复杂方案
- 使用 Wait() 和 Result
使用 GetAwaiter()。GetResult()
当必须同步阻止时,模式 GetAwaiter().GetResult() 通常是首选方法:
// When you cannot use await
Task<string> task = GetDataAsync();
string result = task.GetAwaiter().GetResult();
此方法:
- 保留原始异常而不将其包装在一个 AggregateException中。
- 阻止当前线程,直到任务完成。
- 如果未仔细使用,仍存在死锁风险。
将 Task.Run 用于复杂方案
对于需要隔离异步工作流程的复杂场景:
// Offload to thread pool to avoid context deadlocks
string result = Task.Run(async () => await GetDataAsync()).GetAwaiter().GetResult();
此模式:
- 在线程池线程上执行异步方法。
- 帮助避免某些死锁情境。
- 通过调度工作到线程池来增加开销。
使用 Wait() 和 Result
可以通过采用阻塞方法来调用 Wait()
和 Result
。但是,不建议使用此方法,因为它将异常包装在AggregateException中。
Task<string> task = GetDataAsync();
task.Wait();
string result = task.Result;
与Wait()和Result相关的问题:
- 异常被包装在AggregateException中,使错误处理更加复杂。
- 更高的死锁风险。
- 代码中意图不太清晰。
其他注意事项
- 死锁防护:在 UI 应用程序中或使用同步上下文时尤其小心。
- 性能影响:阻塞线程可减少可伸缩性。
- 异常处理:仔细测试错误方案,因为异常行为在模式之间有所不同。
完整实例
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()
{
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);
};
}
private static void DisplayDamage(DamageResult damage)
{
Console.WriteLine(damage.Damage);
}
private static void Download(string URL)
{
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);
};
}
private static void DoSomethingWithData(object stringData)
{
Console.WriteLine($"Displaying data: {stringData}");
}
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);
}
private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
{
var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
return await Task.WhenAll(getUserTasks);
}
private static async Task ProcessTasksAsTheyCompleteAsync(IEnumerable<int> userIds)
{
var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToList();
while (getUserTasks.Count > 0)
{
Task<User> completedTask = await Task.WhenAny(getUserTasks);
getUserTasks.Remove(completedTask);
User user = await completedTask;
Console.WriteLine($"Processed user {user.id}");
}
}
[HttpGet, Route("DotNetCount")]
static public async Task<int> GetDotNetCountAsync(string URL)
{
// Suspends GetDotNetCountAsync() 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;
}
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 GetDotNetCountAsync(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("Processing tasks as they complete...");
await ProcessTasksAsTheyCompleteAsync(ids);
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.