1. 引言
C# 异步编程是现代 .NET 开发中不可或缺的核心技能。无论是构建高并发的 Web 服务、流畅的桌面应用,还是高效的移动端程序,合理使用异步编程都能显著提升应用的响应速度和资源利用率。本文将从基础概念出发,逐步深入讲解 async/await、Task、异步模式以及常见实战场景,帮助你系统掌握 C# 异步编程。
2. 为什么需要异步编程
在传统的同步编程模型中,当一个线程执行耗时操作(如文件读取、网络请求、数据库查询)时,该线程会一直阻塞等待操作完成。这会导致以下问题:
- UI 卡顿:在桌面或移动应用中,UI 线程被阻塞会导致界面无响应,严重影响用户体验。
- 资源浪费:服务器端大量线程被阻塞在 I/O 等待上,无法处理其他请求,造成线程池资源浪费。
- 扩展性差:面对高并发场景,同步模型难以支撑大规模请求。
异步编程通过让线程在等待 I/O 操作时主动让出执行权,去处理其他任务,从而大幅提升应用的吞吐量和响应速度。
3. async 和 await 基础
async 和 await 是 C# 5.0 引入的关键字,它们让异步代码的编写变得像同步代码一样直观。下面是一个最简单的异步方法示例:
csharp
public async Task<string> FetchDataAsync()
{
using var httpClient = new HttpClient();
string result = await httpClient.GetStringAsync("https://api.example.com/data");
return result;
}
使用 async 关键字修饰方法,表示该方法包含异步操作;await 关键字用于等待一个异步操作完成,同时不会阻塞当前线程。编译器会将 async 方法转换为状态机,自动管理异步操作的恢复和上下文切换。
3.1 async 方法的返回值类型
async 方法可以有以下三种返回类型:
- Task<TResult>:方法返回一个值。
- Task:方法不返回值,执行一个异步操作。
- void:仅用于事件处理器,不推荐在其他场景使用。
csharp
// 返回值的异步方法
public async Task<int> CalculateAsync()
{
await Task.Delay(100);
return 42;
}
// 无返回值的异步方法
public async Task DoWorkAsync()
{
await Task.Delay(100);
Console.WriteLine("完成");
}
// 事件处理器(唯一推荐使用 void 的场景)
public async void OnButtonClick(object sender, EventArgs e)
{
await LoadDataAsync();
}
4. Task 深入理解
Task 表示一个异步操作的单元,它封装了操作的执行状态、结果和异常信息。理解 Task 的生命周期和常用 API 是掌握异步编程的关键。
4.1 创建 Task
csharp
// 方式一:Task.Run 在线程池上执行
Task task = Task.Run(() =>
{
// 执行耗时计算
Thread.Sleep(1000);
Console.WriteLine("任务完成");
});
// 方式二:Task.Factory.StartNew(更底层)
Task<int> task2 = Task.Factory.StartNew(() =>
{
return 100;
});
// 方式三:Task.FromResult 返回已完成的任务
Task<string> completedTask = Task.FromResult("立即返回");
4.2 等待多个任务
在实际开发中,经常需要同时执行多个异步操作并等待它们全部完成或任一完成。Task 提供了 Task.WhenAll 和 Task.WhenAny 来应对这些场景。
csharp
// 等待所有任务完成
var task1 = FetchDataAsync("url1");
var task2 = FetchDataAsync("url2");
var task3 = FetchDataAsync("url3");
await Task.WhenAll(task1, task2, task3);
Console.WriteLine("所有请求完成");
// 等待任一任务完成
Task<string> firstCompleted = await Task.WhenAny(task1, task2, task3);
Console.WriteLine($"最先完成:{firstCompleted.Result}");
5. 异步编程的常见模式
5.1 异步流(IAsyncEnumerable)
C# 8.0 引入了异步流,允许异步地逐个产生和消费数据,非常适合处理分页数据、日志流等场景。
csharp
public async IAsyncEnumerable<int> GenerateNumbersAsync()
{
for (int i = 0; i < 10; i++)
{
await Task.Delay(100);
yield return i;
}
}
// 消费异步流
await foreach (var number in GenerateNumbersAsync())
{
Console.WriteLine(number);
}
5.2 取消异步操作
使用 CancellationToken 可以优雅地取消长时间运行的异步操作,避免资源浪费。
csharp
public async Task<string> FetchWithCancellationAsync(CancellationToken cancellationToken)
{
using var httpClient = new HttpClient();
return await httpClient.GetStringAsync(
"https://api.example.com/data",
cancellationToken);
}
// 使用方式
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try
{
string result = await FetchWithCancellationAsync(cts.Token);
Console.WriteLine(result);
}
catch (OperationCanceledException)
{
Console.WriteLine("操作已取消");
}
5.3 异步与并行
当需要同时执行多个 CPU 密集型任务时,可以结合 Task.Run 和 Task.WhenAll 实现并行计算。
csharp
var tasks = Enumerable.Range(1, 10).Select(i =>
Task.Run(() => ComputeHeavyWork(i))
);
int[] results = await Task.WhenAll(tasks);
Console.WriteLine($"结果总和:{results.Sum()}");
static int ComputeHeavyWork(int input)
{
// 模拟耗时计算
Thread.Sleep(500);
return input * input;
}
6. 异步编程的注意事项与最佳实践
6.1 避免 async void
async void 方法无法被调用方捕获异常,一旦发生异常会导致程序崩溃。除事件处理器外,应始终使用 Task 作为返回类型。
6.2 避免阻塞异步代码
在异步方法中应避免使用 .Result 或 .Wait() 等阻塞调用,这会导致死锁和线程池饥饿。
csharp
// 错误做法:阻塞等待
string result = FetchDataAsync().Result;
// 正确做法:使用 await
string result = await FetchDataAsync();
6.3 配置上下文(ConfigureAwait)
在类库代码中,使用 ConfigureAwait(false) 可以避免不必要的上下文切换,提升性能。
csharp
public async Task<string> ReadFileAsync(string path)
{
string content = await File.ReadAllTextAsync(path)
.ConfigureAwait(false);
return content;
}
6.4 异常处理
异步方法的异常处理与同步方法类似,使用 try-catch 即可捕获。但需要注意,多个任务并行时,Task.WhenAll 会抛出第一个异常,其余异常需要通过 Task.Exception 查看。
csharp
try
{
await Task.WhenAll(task1, task2, task3);
}
catch (Exception ex)
{
Console.WriteLine($"发生异常:{ex.Message}");
}
7. 实战案例:异步下载器
下面通过一个完整的实战案例,综合运用本文所学的异步编程知识,实现一个简单的异步文件下载器。
csharp
public class AsyncDownloader
{
private readonly HttpClient _httpClient = new HttpClient();
public async Task DownloadFilesAsync(
IEnumerable<string> urls,
string outputDirectory,
CancellationToken cancellationToken = default)
{
Directory.CreateDirectory(outputDirectory);
var tasks = urls.Select(url =>
DownloadFileAsync(url, outputDirectory, cancellationToken));
await Task.WhenAll(tasks);
Console.WriteLine("所有文件下载完成");
}
private async Task DownloadFileAsync(
string url,
string outputDirectory,
CancellationToken cancellationToken)
{
try
{
string fileName = Path.GetFileName(new Uri(url).LocalPath);
string filePath = Path.Combine(outputDirectory, fileName);
byte[] data = await _httpClient.GetByteArrayAsync(url, cancellationToken);
await File.WriteAllBytesAsync(filePath, data, cancellationToken);
Console.WriteLine($"已下载:{fileName}");
}
catch (OperationCanceledException)
{
Console.WriteLine($"已取消:{url}");
}
catch (Exception ex)
{
Console.WriteLine($"下载失败:{url},原因:{ex.Message}");
}
}
}
// 调用示例
var downloader = new AsyncDownloader();
var urls = new[]
{
"https://example.com/file1.zip",
"https://example.com/file2.zip",
"https://example.com/file3.zip"
};
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await downloader.DownloadFilesAsync(urls, @"C:\Downloads", cts.Token);
8. 总结
C# 异步编程是构建高性能、高响应性应用的核心技能。通过本文的学习,你已经掌握了 async/await 的基本用法、Task 的深入理解、常见异步模式以及最佳实践。在实际开发中,建议遵循以下原则:
- 始终使用 async/await 而非阻塞调用。
- 避免 async void,除非是事件处理器。
- 合理使用 CancellationToken 支持取消操作。
- 在类库中使用 ConfigureAwait(false) 提升性能。
- 结合 Task.WhenAll 和 Task.WhenAny 处理并发场景。
异步编程需要在实际项目中不断练习和积累经验,希望本文能为你打下坚实的基础,助你在 .NET 开发之路上更进一步。