异步编程场景

如果代码实现 I/O 绑定方案以支持网络数据请求、数据库访问或文件系统读取/写入,则异步编程是最佳方法。 还可以为 CPU 绑定场景编写异步代码,例如耗时的计算。

C# 具有语言级异步编程模型,可让你轻松编写异步代码,而无需杂乱回调或符合支持异步的库。 该模型遵循所谓的基于任务的异步模式(TAP)。

探索异步编程模型

TaskTask<T> 对象共同表示异步编程的核心。 这些对象通过支持 asyncawait 关键字来进行异步操作建模。 在大多数情况下,对于 I/O 绑定和 CPU 绑定方案,模型相当简单。 在 async 方法中:

  • I/O 绑定代码async方法中启动由TaskTask<T>对象表示的操作。
  • CPU 绑定代码 使用 Task.Run 方法在后台线程上启动操作。

在这两种情况下,活动 Task 表示可能尚未完成的异步操作。

await 关键字有这奇妙的作用。 它向包含 await 表达式的方法的调用方生成控制权,并最终允许 UI 响应或服务具有弹性。 虽然存在其他方法来处理异步代码,而不仅仅是使用asyncawait表达式,但本文重点介绍语言级构造。

注意

本文中介绍的一些示例使用 System.Net.Http.HttpClient 类从 Web 服务下载数据。 在示例代码中,对象 s_httpClient 是类型 Program 类的静态字段:

private static readonly HttpClient s_httpClient = new();

有关详细信息,请参阅本文末尾 的完整示例代码

查看基础概念

在 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 密集型情况

前面的示例演示如何对 I/O 绑定和 CPU 绑定工作使用 async 修饰符和 await 表达式。 每个场景的示例展示了代码如何根据操作绑定的位置而有所不同。 要为你的实施做好准备,你需要了解如何确定一个操作是 I/O 绑定还是 CPU 绑定。 实现选择可能会极大地影响代码的性能,并可能导致错误使用构造。

在编写任何代码之前,需要解决两个主要问题:

问题 情景 执行
代码是否应等待结果或作,例如数据库中的数据? I/O 绑定 使用 async 修饰符和 await 表达式,而不使用 Task.Run 方法。

避免使用任务并行库。
代码是否应该运行昂贵的计算? CPU-bound 使用async修饰符和await表达式,并使用Task.Run方法在另一个线程上生成工作。 此方法解决了 CPU 响应能力方面的问题。

如果该工作同时适用于并发和并行,还应考虑使用任务并行库

始终测量代码的执行。 你可能会发现,与进行多线程处理时上下文切换的开销相比,受 CPU 限制的工作的成本不足。 每个选择都有利弊。 选择适合你的情况的正确折中方案。

浏览其他示例

本节中的示例演示了在 C# 中编写异步代码的几种方法。 它们涵盖了可能会遇到的几种情况。

从网络提取数据

以下代码从给定 URL 下载 HTML,并计算字符串“.NET”在 HTML 中发生的次数。 该代码使用 ASP.NET 定义 Web API 控制器方法,该方法执行任务并返回计数。

注意

如果打算在生产代码中进行 HTML 分析,则不要使用正则表达式。 改为使用分析库。

[HttpGet, Route("DotNetCount")]
static public async Task<int> GetDotNetCount(string URL)
{
    // Suspends GetDotNetCount() 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;
}

可以为通用 Windows 应用编写类似的代码,并在按下按钮后执行计数任务:

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 提供的方法使你能够编写异步代码,以在多个后台作业上执行非阻止等待:

以下示例演示如何为一组userId对象获取User对象数据。

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 与异步代码混合时,请谨慎作。 LINQ 使用延迟(或惰性)执行。 异步调用将不会像在 foreach 循环中那样立刻发生,除非强制所生成的序列通过对 .ToList().ToArray() 的调用循环访问。 此示例使用 Enumerable.ToArray 该方法急切地执行查询,并将结果存储在数组中。 此方法强制 id => GetUserAsync(id) 语句运行并启动任务。

审查异步编程的注意事项

使用异步编程时,有几个细节需要记住,这可以防止意外行为。

在async()方法主体中使用await

使用 async 修饰符时,应在方法正文中包含一个或多个 await 表达式。 如果编译器未遇到 await 表达式,该方法将无法生成。 尽管编译器生成警告,但代码仍会编译,编译器会运行该方法。 由 C# 编译器为异步方法生成的状态机无法完成任何工作,因此整个过程效率很低。

将“Async”后缀添加到异步方法名称

.NET 样式约定是将所有异步方法名称添加“Async”后缀。 此方法有助于更轻松地区分同步和异步方法。 代码(如事件处理程序或 Web 控制器方法)未显式调用的某些方法不一定在此方案中适用。 由于这些项不是由代码显式调用的,因此使用显式命名并不重要。

仅从事件处理程序返回“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”
检索后台任务的结果 Task.WaitTask.Result await
在任何任务完成时继续 Task.WaitAny await Task.WhenAny
完成所有任务后继续 Task.WaitAll await Task.WhenAll
在一段时间后继续 Thread.Sleep await Task.Delay

考虑使用 ValueTask 类型

当异步方法返回 Task 对象时,可能会在某些路径中引入性能瓶颈。 由于 Task 是引用类型,因此从堆分配对象 Task 。 如果使用 async 修饰符声明的方法返回缓存结果或同步完成,那么额外分配可能会在代码的性能关键部分累积显著的时间成本。 当分配在紧密循环中发生时,这种情况可能会变得昂贵。 有关详细信息,请参阅通用的异步返回类型

了解何时设置 ConfigureAwait(false)

开发人员经常询问何时使用 Task.ConfigureAwait(Boolean) 布尔值。 此 API 允许 Task 实例为实现任何 await 表达式的状态机配置上下文。 如果未正确设置布尔值,性能可能会降低或发生死锁。 有关详细信息,请参阅 ConfigureAwait 常见问题解答

编写更少有状态代码

避免编写依赖于全局对象状态或执行某些方法的代码。 请仅依赖方法的返回值。 编写不具有状态的代码有很多好处:

  • 更容易推理代码
  • 更易于测试代码
  • 更易于混合异步和同步代码
  • 能够在代码中避免争用条件
  • 易于协调依赖于返回值的异步代码
  • (附加优点)可在代码中很好地支持依赖项注入

建议的目标是实现代码中完整或接近完整的引用透明度。 这种方法会产生一个可预测、可测试并且可维护的代码库。

查看完整示例

以下代码表示完整的示例,该示例在 Program.cs 示例文件中可用。

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()
    {
        // <PerformGameCalculation>
        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);
        };
        // </PerformGameCalculation>
    }

    private static void DisplayDamage(DamageResult damage)
    {
        Console.WriteLine(damage.Damage);
    }

    private static void Download(string URL)
    {
        // <UnblockingDownload>
        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);
        };
        // </UnblockingDownload>
    }

    private static void DoSomethingWithData(object stringData)
    {
        Console.WriteLine($"Displaying data: {stringData}");
    }

    // <GetUsersForDataset>
    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);
    }
    // </GetUsersForDataset>

    // <GetUsersForDatasetByLINQ>
    private static async Task<User[]> GetUsersAsyncByLINQ(IEnumerable<int> userIds)
    {
        var getUserTasks = userIds.Select(id => GetUserAsync(id)).ToArray();
        return await Task.WhenAll(getUserTasks);
    }
    // </GetUsersForDatasetByLINQ>

    // <ExtractDataFromNetwork>
    [HttpGet, Route("DotNetCount")]
    static public async Task<int> GetDotNetCount(string URL)
    {
        // Suspends GetDotNetCount() 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;
    }
    // </ExtractDataFromNetwork>

    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 GetDotNetCount(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("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.