MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C#中的异步编程模式Async与Await

2024-10-034.0k 阅读

C# 异步编程基础概念

在现代软件开发中,特别是在处理 I/O 操作、网络请求、数据库访问等耗时操作时,异步编程变得至关重要。传统的同步编程方式在执行这些操作时,会阻塞主线程,导致应用程序在操作完成前无法响应用户输入或执行其他任务,严重影响用户体验。而异步编程允许程序在等待这些耗时操作完成的同时,继续执行其他代码,从而提高应用程序的响应性和性能。

线程与异步的关系

在理解异步编程之前,我们先来回顾一下线程的概念。线程是程序执行的最小单位,一个进程可以包含多个线程。在 C# 中,我们可以通过 System.Threading.Thread 类来创建和管理线程。例如:

using System;
using System.Threading;

class Program
{
    static void Main()
    {
        Thread newThread = new Thread(DoWork);
        newThread.Start();
        Console.WriteLine("主线程继续执行");
        newThread.Join();
    }

    static void DoWork()
    {
        Console.WriteLine("新线程开始工作");
        Thread.Sleep(2000);
        Console.WriteLine("新线程工作完成");
    }
}

在这个例子中,我们创建了一个新线程 newThread 并启动它来执行 DoWork 方法。主线程在启动新线程后继续执行,并输出 “主线程继续执行”。新线程会睡眠 2 秒,模拟一个耗时操作,然后输出 “新线程工作完成”。最后,主线程通过 newThread.Join() 等待新线程完成。

然而,直接使用线程进行异步操作存在一些问题。创建和管理线程的开销较大,过多的线程会消耗大量系统资源,导致性能下降。而且,线程之间的同步和通信也比较复杂,容易引发死锁等问题。这就是为什么 C# 引入了更高级的异步编程模型,即 AsyncAwait

Async 关键字

Async 关键字用于定义一个异步方法。一个异步方法是指该方法在执行过程中可以暂停,将控制权返回给调用者,而不会阻塞调用者所在的线程。异步方法的返回类型通常为 TaskTask<TResult>,如果异步方法没有返回值,则返回 Task;如果有返回值,则返回 Task<TResult>,其中 TResult 是返回值的类型。

定义异步方法

下面是一个简单的异步方法示例:

using System;
using System.Threading.Tasks;

class Program
{
    static async Task DoAsyncWork()
    {
        Console.WriteLine("异步方法开始");
        await Task.Delay(2000);
        Console.WriteLine("异步方法完成");
    }

    static async Task Main()
    {
        Console.WriteLine("主线程开始");
        await DoAsyncWork();
        Console.WriteLine("主线程完成");
    }
}

在这个例子中,DoAsyncWork 方法被定义为异步方法,因为它使用了 async 关键字。该方法内部使用 await Task.Delay(2000) 模拟一个耗时 2 秒的异步操作。Main 方法也被定义为异步方法,在 Main 方法中调用 DoAsyncWork 并使用 await 等待其完成。

异步方法的执行流程

DoAsyncWork 方法被调用时,它会开始执行直到遇到第一个 await 表达式。在这个例子中,await Task.Delay(2000) 会暂停 DoAsyncWork 方法的执行,并将控制权返回给调用者(即 Main 方法)。Main 方法在等待 DoAsyncWork 完成的同时可以继续执行其他代码。当 Task.Delay 完成后(即 2 秒后),DoAsyncWork 方法会从暂停的地方继续执行,输出 “异步方法完成”。然后 Main 方法中的 await 表达式完成,Main 方法继续执行,输出 “主线程完成”。

Await 关键字

Await 关键字只能在标记为 async 的方法内部使用。它用于暂停异步方法的执行,直到其等待的 TaskTask<TResult> 完成。当 await 一个 Task 时,异步方法会释放当前线程,让其可以执行其他任务。当 Task 完成后,异步方法会在同一个线程(如果是 UI 线程)或线程池中的一个线程上恢复执行。

等待单个任务

using System;
using System.Net.Http;
using System.Threading.Tasks;

class Program
{
    static async Task<string> GetWebPageContentAsync()
    {
        using (HttpClient client = new HttpClient())
        {
            HttpResponseMessage response = await client.GetAsync("https://www.example.com");
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsStringAsync();
        }
    }

    static async Task Main()
    {
        Console.WriteLine("开始获取网页内容");
        string content = await GetWebPageContentAsync();
        Console.WriteLine($"网页内容长度: {content.Length}");
    }
}

在这个例子中,GetWebPageContentAsync 方法使用 HttpClient 来异步获取网页内容。await client.GetAsync("https://www.example.com") 会等待 HTTP 请求完成,并返回一个 HttpResponseMessage。然后 await response.Content.ReadAsStringAsync() 会等待读取响应内容为字符串。在 Main 方法中,await GetWebPageContentAsync() 等待获取网页内容的任务完成,并将结果赋值给 content 变量。

等待多个任务

在实际应用中,我们经常需要同时执行多个异步任务,并等待它们全部完成。可以使用 Task.WhenAll 方法来实现这一点。

using System;
using System.Net.Http;
using System.Threading.Tasks;

class Program
{
    static async Task<string> GetWebPageContentAsync(string url)
    {
        using (HttpClient client = new HttpClient())
        {
            HttpResponseMessage response = await client.GetAsync(url);
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsStringAsync();
        }
    }

    static async Task Main()
    {
        string[] urls = { "https://www.example.com", "https://www.google.com", "https://www.microsoft.com" };
        Task<string>[] tasks = new Task<string>[urls.Length];
        for (int i = 0; i < urls.Length; i++)
        {
            tasks[i] = GetWebPageContentAsync(urls[i]);
        }
        string[] contents = await Task.WhenAll(tasks);
        for (int i = 0; i < contents.Length; i++)
        {
            Console.WriteLine($"URL: {urls[i]}, 内容长度: {contents[i].Length}");
        }
    }
}

在这个例子中,我们创建了一个任务数组 tasks,每个任务负责获取一个网页的内容。然后使用 Task.WhenAll(tasks) 等待所有任务完成,并将结果存储在 contents 数组中。最后,我们遍历 contents 数组并输出每个网页的 URL 和内容长度。

异步编程中的异常处理

异步编程中的异常处理与同步编程中的异常处理类似,但也有一些需要注意的地方。当一个异步方法内部抛出异常时,如果没有在该异步方法内部捕获,异常会被包装在 Task 中。当 await 这个 Task 时,异常会被重新抛出,我们可以在 try - catch 块中捕获它。

单个异步任务的异常处理

using System;
using System.Net.Http;
using System.Threading.Tasks;

class Program
{
    static async Task<string> GetWebPageContentAsync(string url)
    {
        using (HttpClient client = new HttpClient())
        {
            HttpResponseMessage response = await client.GetAsync(url);
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsStringAsync();
        }
    }

    static async Task Main()
    {
        try
        {
            string content = await GetWebPageContentAsync("https://www.invalidurl.com");
            Console.WriteLine($"网页内容长度: {content.Length}");
        }
        catch (HttpRequestException ex)
        {
            Console.WriteLine($"发生 HTTP 请求异常: {ex.Message}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"发生其他异常: {ex.Message}");
        }
    }
}

在这个例子中,如果请求的 URL 无效,GetWebPageContentAsync 方法内部的 response.EnsureSuccessStatusCode() 会抛出 HttpRequestException。在 Main 方法中,await 表达式会重新抛出这个异常,我们可以在 try - catch 块中捕获并处理它。

多个异步任务的异常处理

当使用 Task.WhenAll 等待多个任务完成时,如果其中一个或多个任务抛出异常,Task.WhenAll 会抛出一个 AggregateException,其中包含所有任务抛出的异常。

using System;
using System.Net.Http;
using System.Threading.Tasks;

class Program
{
    static async Task<string> GetWebPageContentAsync(string url)
    {
        using (HttpClient client = new HttpClient())
        {
            HttpResponseMessage response = await client.GetAsync(url);
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadAsStringAsync();
        }
    }

    static async Task Main()
    {
        string[] urls = { "https://www.example.com", "https://www.invalidurl.com", "https://www.google.com" };
        Task<string>[] tasks = new Task<string>[urls.Length];
        for (int i = 0; i < urls.Length; i++)
        {
            tasks[i] = GetWebPageContentAsync(urls[i]);
        }
        try
        {
            string[] contents = await Task.WhenAll(tasks);
            for (int i = 0; i < contents.Length; i++)
            {
                Console.WriteLine($"URL: {urls[i]}, 内容长度: {contents[i].Length}");
            }
        }
        catch (AggregateException ex)
        {
            foreach (var innerEx in ex.InnerExceptions)
            {
                if (innerEx is HttpRequestException httpEx)
                {
                    Console.WriteLine($"发生 HTTP 请求异常: {httpEx.Message}");
                }
                else
                {
                    Console.WriteLine($"发生其他异常: {innerEx.Message}");
                }
            }
        }
    }
}

在这个例子中,由于其中一个 URL 无效,Task.WhenAll 会抛出 AggregateException。我们在 catch 块中遍历 InnerExceptions 来处理每个任务抛出的异常。

异步编程与 UI 线程

在基于 Windows 窗体或 WPF 的应用程序中,UI 线程负责处理用户界面的绘制和事件处理。如果在 UI 线程中执行耗时操作,会导致 UI 冻结,用户无法与应用程序交互。使用异步编程可以避免这种情况。

Windows 窗体应用程序中的异步操作

以下是一个简单的 Windows 窗体应用程序示例,演示如何在 UI 线程中进行异步操作:

using System;
using System.ComponentModel;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private async void button1_Click(object sender, EventArgs e)
        {
            button1.Enabled = false;
            progressBar1.Visible = true;
            progressBar1.Minimum = 0;
            progressBar1.Maximum = 100;
            for (int i = 0; i <= 100; i++)
            {
                progressBar1.Value = i;
                await Task.Delay(50);
            }
            progressBar1.Visible = false;
            button1.Enabled = true;
            MessageBox.Show("操作完成");
        }
    }
}

在这个例子中,当用户点击按钮 button1 时,button1_Click 方法被调用。该方法被标记为 async,在方法内部,我们禁用按钮并显示进度条。然后通过一个循环模拟一个耗时操作,每次循环中使用 await Task.Delay(50) 暂停异步方法的执行,让 UI 线程有机会更新进度条。当操作完成后,隐藏进度条并重新启用按钮,最后显示一个消息框。

WPF 应用程序中的异步操作

在 WPF 应用程序中,异步操作的处理方式类似。以下是一个简单的 WPF 示例:

<Window x:Class="WpfApp1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="MainWindow" Height="350" Width="525">
    <Grid>
        <Button Content="开始操作" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Width="75" Click="Button_Click"/>
        <ProgressBar x:Name="progressBar1" HorizontalAlignment="Left" Margin="10,40,0,0" VerticalAlignment="Top" Width="400" Visibility="Collapsed"/>
    </Grid>
</Window>
using System;
using System.Threading.Tasks;
using System.Windows;

namespace WpfApp1
{
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();
        }

        private async void Button_Click(object sender, RoutedEventArgs e)
        {
            ((Button)sender).IsEnabled = false;
            progressBar1.Visibility = Visibility.Visible;
            progressBar1.Minimum = 0;
            progressBar1.Maximum = 100;
            for (int i = 0; i <= 100; i++)
            {
                progressBar1.Value = i;
                await Task.Delay(50);
            }
            progressBar1.Visibility = Visibility.Collapsed;
            ((Button)sender).IsEnabled = true;
            MessageBox.Show("操作完成");
        }
    }
}

这个 WPF 示例与 Windows 窗体示例类似,当用户点击按钮时,执行异步操作,在操作过程中更新进度条,操作完成后恢复按钮状态并显示消息框。

异步编程的性能优化

虽然异步编程可以提高应用程序的响应性,但在某些情况下,不正确的使用可能会导致性能问题。以下是一些异步编程性能优化的建议:

避免不必要的异步操作

并非所有操作都需要异步执行。对于一些非常短的计算任务,同步执行可能更高效,因为异步操作本身也有一定的开销,例如创建和管理 Task 对象。例如,简单的数学计算就不适合使用异步方式。

// 同步方式
int result = CalculateSync(10, 20);
// 异步方式
Task<int> task = CalculateAsync(10, 20);
int resultAsync = task.Result;

int CalculateSync(int a, int b)
{
    return a + b;
}

async Task<int> CalculateAsync(int a, int b)
{
    return await Task.Run(() => a + b);
}

在这个例子中,CalculateSync 方法同步执行简单的加法运算,而 CalculateAsync 方法通过 Task.Run 将计算放到线程池中执行。对于这种简单的计算,同步方式更高效,因为异步操作的开销可能大于计算本身的时间。

合理使用线程池

在异步编程中,很多异步操作会使用线程池中的线程。如果同时有大量异步任务需要执行,可能会导致线程池线程不足,从而影响性能。可以通过 TaskScheduler 来控制任务的调度,例如使用 TaskScheduler.Default 表示使用线程池,或者自定义 TaskScheduler 来满足特定的需求。

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static async Task DoWork()
    {
        Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 开始工作");
        await Task.Delay(2000);
        Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 工作完成");
    }

    static async Task Main()
    {
        Task task1 = Task.Run(() => DoWork());
        Task task2 = Task.Run(() => DoWork());
        await Task.WhenAll(task1, task2);
    }
}

在这个例子中,Task.Run 使用线程池中的线程来执行 DoWork 方法。如果有大量类似的任务,需要注意线程池的负载情况。

优化 I/O 操作

在进行 I/O 操作(如文件读写、网络请求等)时,使用异步 I/O 可以显著提高性能。.NET 提供了很多异步 I/O 的 API,例如 Stream 类的异步读写方法、HttpClient 的异步方法等。同时,合理设置缓冲区大小也可以提高 I/O 性能。

using System;
using System.IO;
using System.Threading.Tasks;

class Program
{
    static async Task ReadFileAsync(string filePath)
    {
        using (StreamReader reader = new StreamReader(filePath))
        {
            string content = await reader.ReadToEndAsync();
            Console.WriteLine($"文件内容: {content}");
        }
    }

    static async Task Main()
    {
        await ReadFileAsync("example.txt");
    }
}

在这个例子中,ReadFileAsync 方法使用 StreamReader 的异步方法 ReadToEndAsync 来读取文件内容,避免了阻塞主线程。

异步编程中的上下文问题

在异步编程中,上下文问题是一个需要关注的重要方面。上下文主要涉及到当前执行环境的一些信息,例如线程的文化信息、安全上下文等。

同步上下文

在 Windows 窗体和 WPF 应用程序中,存在一个特殊的上下文,即同步上下文。同步上下文负责将任务调度回 UI 线程执行。当我们在 UI 线程中调用一个异步方法并 await 它时,await 之后的代码会在同步上下文的控制下回到 UI 线程执行。

using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }

        private async void button1_Click(object sender, EventArgs e)
        {
            Console.WriteLine($"点击按钮时的线程: {Thread.CurrentThread.ManagedThreadId}");
            await Task.Run(() =>
            {
                Console.WriteLine($"Task.Run 中的线程: {Thread.CurrentThread.ManagedThreadId}");
            });
            Console.WriteLine($"await 之后的线程: {Thread.CurrentThread.ManagedThreadId}");
        }
    }
}

在这个 Windows 窗体示例中,点击按钮时在 UI 线程执行,Task.Run 会将任务放到线程池线程执行,而 await 之后的代码会回到 UI 线程执行,这就是同步上下文的作用。

捕获上下文与不捕获上下文

在某些情况下,我们可能不希望 await 之后的代码回到原来的上下文执行,例如在性能敏感的场景中,希望避免上下文切换的开销。可以使用 ConfigureAwait(false) 来指示 await 不捕获当前上下文。

using System;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static async Task DoWork()
    {
        Console.WriteLine($"开始时的线程: {Thread.CurrentThread.ManagedThreadId}");
        await Task.Run(() =>
        {
            Console.WriteLine($"Task.Run 中的线程: {Thread.CurrentThread.ManagedThreadId}");
        }).ConfigureAwait(false);
        Console.WriteLine($"await 之后的线程: {Thread.CurrentThread.ManagedThreadId}");
    }

    static async Task Main()
    {
        await DoWork();
    }
}

在这个例子中,使用 ConfigureAwait(false) 后,await 之后的代码不会回到原来的上下文线程执行,而是可能在线程池中的任意线程执行。

异步编程的最佳实践

为了更好地使用异步编程,以下是一些最佳实践:

方法命名规范

异步方法的命名应该遵循一定的规范,通常在方法名后加上 Async 后缀,以便清晰地表明该方法是异步的。例如 GetWebPageContentAsync

避免阻塞异步方法

不要在异步方法内部使用同步阻塞的操作,例如 Task.ResultTask.Wait,这会破坏异步的优势,导致线程阻塞。

合理处理异常

在异步方法中,要合理捕获和处理异常,避免异常在异步调用链中传播而未被处理,导致应用程序崩溃。同时,在处理多个任务的异常时,要正确处理 AggregateException

优化资源管理

在异步操作中,要注意资源的及时释放,例如在使用 HttpClientStream 等资源时,要确保在操作完成后正确关闭和释放资源,可以使用 using 语句来简化资源管理。

总结异步编程在 C# 中的重要性

异步编程在 C# 中是一种强大的编程模式,它能够显著提高应用程序的性能和响应性,特别是在处理耗时操作时。通过 AsyncAwait 关键字,我们可以更简洁、高效地编写异步代码,避免线程阻塞和提高系统资源的利用率。在实际开发中,无论是开发 Web 应用程序、桌面应用程序还是服务端应用程序,都应该熟练掌握异步编程技术,遵循最佳实践,以创建高性能、响应迅速的应用程序。同时,随着硬件技术的发展和应用场景的不断扩展,异步编程的重要性将日益凸显,开发者需要不断深入理解和掌握这一技术,以应对日益复杂的开发需求。