如果有 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)