C#中的异步编程模式Async与Await
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# 引入了更高级的异步编程模型,即 Async
和 Await
。
Async 关键字
Async
关键字用于定义一个异步方法。一个异步方法是指该方法在执行过程中可以暂停,将控制权返回给调用者,而不会阻塞调用者所在的线程。异步方法的返回类型通常为 Task
或 Task<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
的方法内部使用。它用于暂停异步方法的执行,直到其等待的 Task
或 Task<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.Result
或 Task.Wait
,这会破坏异步的优势,导致线程阻塞。
合理处理异常
在异步方法中,要合理捕获和处理异常,避免异常在异步调用链中传播而未被处理,导致应用程序崩溃。同时,在处理多个任务的异常时,要正确处理 AggregateException
。
优化资源管理
在异步操作中,要注意资源的及时释放,例如在使用 HttpClient
、Stream
等资源时,要确保在操作完成后正确关闭和释放资源,可以使用 using
语句来简化资源管理。
总结异步编程在 C# 中的重要性
异步编程在 C# 中是一种强大的编程模式,它能够显著提高应用程序的性能和响应性,特别是在处理耗时操作时。通过 Async
和 Await
关键字,我们可以更简洁、高效地编写异步代码,避免线程阻塞和提高系统资源的利用率。在实际开发中,无论是开发 Web 应用程序、桌面应用程序还是服务端应用程序,都应该熟练掌握异步编程技术,遵循最佳实践,以创建高性能、响应迅速的应用程序。同时,随着硬件技术的发展和应用场景的不断扩展,异步编程的重要性将日益凸显,开发者需要不断深入理解和掌握这一技术,以应对日益复杂的开发需求。