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)。这样做将产生可预测、可测试和易于维护的代码库。

参考#

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.