基于任务的异步模式 (TAP)。
在大多数情况下模型十分简单:
对于 I/O 绑定代码,当你 await 一个操作,它将返回 async 方法中的一个 Task 或 Task<T>。
对于 CPU 绑定代码,当你 await 一个操作,它将在后台线程通过 Task.Run 方法启动。
它控制执行 await 的方法的调用方,且它最终允许 UI 具有响应性或服务具有灵活性。
除上方链接的 TAP 文章中介绍的 async 和 await 之外,还有其他处理异步代码的方法,但本文档将在下文中重点介绍语言级别的构造。
I/O 绑定示例:从 Web 服务下载数据
只需执行如下操作即可轻松实现:
private readonly HttpClient _httpClient = new HttpClient(); downloadButton.Clicked += async (o, e) => { // 当来自Web服务的请求发生时,此行将向UI提供控制权。 // UI线程现在可以自由执行其他工作 var stringData = await _httpClient.GetStringAsync(URL); DoSomethingWithData(stringData); };
代码表示目的(异步下载某些数据),而不会在与任务对象的交互中停滞。
CPU 绑定示例:为游戏执行计算
执行伤害计算的开销可能极大,而且在 UI 线程中执行计算有可能使游戏在计算执行过程中暂停!
这可确保在执行工作时 UI 能流畅运行。
private DamageResult CalculateDamageDone() { // ··· 省略的业务逻辑代码 // //执行昂贵的计算并返回该计算的结果。 } calculateButton.Clicked += async (o, e) => { // 此行将在计算 damagedone()执行其工作时向UI提供控制权。
// UI线程现在可以自由执行其他工作 var damageResult = await Task.Run(() => CalculateDamageDone()); DisplayDamage(damageResult); };
此代码清楚地表达了按钮的单击事件的目的,它无需手动管理后台线程,而是通过非阻止性的方式来实现。
内部原理
深入了解异步,以获取详细信息。
在 C# 方面,编译器将代码转换为状态机,它将跟踪类似以下内容:到达 await 时暂停执行以及后台作业完成时继续执行。
异步的承诺模型的实现。
- 异步代码可用于 I/O 绑定和 CPU 绑定代码,但在每个方案中有所不同。
- 异步代码使用
Task<T>和Task,它们是对后台所完成的工作进行建模的构造。 async关键字将方法转换为异步方法,这使你能在其正文中使用await关键字。- 应用
await关键字后,它将挂起调用方法,并将控制权返还给调用方,直到等待的任务完成。 - 仅允许在异步方法中使用
await。
确定所需执行的操作是 I/O 绑定或 CPU 绑定是关键,因为这会极大影响代码性能,并可能导致某些构造的误用。
以下是编写代码前应考虑的两个问题:
-
你的代码是否会“等待”某些内容,例如数据库中的数据?
如果答案为“是”,则你的工作是 I/O 绑定。
-
你的代码是否要执行开销巨大的计算?
如果答案为“是”,则你的工作是 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.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 使用延迟的执行,因此异步调用将不会像在 foreach() 循环中那样立刻发生,除非强制所生成的序列通过对 .ToList() 或 .ToArray() 的调用循环访问。
尽管异步编程相对简单,但应记住一些可避免意外行为的要点。
async方法需在其主体中具有await关键字,否则它们将永不暂停!
请注意这会导致效率低下,因为由 C# 编译器为异步方法生成的状态机将不会完成任何任务。
- 应将“Async”作为后缀添加到所编写的每个异步方法名称中。
由于它们未由代码显式调用,因此对其显式命名并不重要。
async void应仅用于事件处理程序。
其他任何对 async void 的使用都不遵循 TAP 模型,且可能存在一定使用难度,例如:
-
async void方法中引发的异常无法在该方法外部被捕获。 -
十分难以测试
async void方法。 -
如果调用方不希望
async void方法是异步方法,则这些方法可能会产生不好的副作用。 -
在 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#)
- Six Essential Tips for Async(关于异步的六个要点)是有关异步编程的绝佳资源