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

参考#

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。