异步编程
async/await
一、async/await 基础使用规范
1. 异步方法的定义
- 修饰符:使用
async关键字修饰的方法,称为异步方法。 - 返回值约定:
- 有返回值时,返回值类型声明为
Task<T>,其中T是实际返回的数据类型,例如Task<int>、Task<string>。 - 无返回值时,也建议声明为非泛型
Task(而非void),便于异常捕获与任务等待。
- 有返回值时,返回值类型声明为
- 命名惯例:异步方法名称建议以
Async结尾,如ReadAllTextAsync、WriteAllTextAsync。
2. 调用与 await 关键字
- 调用泛型异步方法时,在方法前加上
await关键字,可直接获取Task<T>中包裹的T类型结果,无需手动解析Task对象。 - 异步的“传染性”:一个方法内部如果使用了
await调用异步方法,那么这个方法本身也必须被async修饰。- 典型示例:控制台程序的入口方法需要声明为
static async Task Main(string[] args)。
- 典型示例:控制台程序的入口方法需要声明为
基础示例:
static async Task Main(string[] args)
{
string fileName = "d:/1.txt";
File.Delete(fileName);
// 异步写入文件
await File.WriteAllTextAsync(fileName, "hello async");
// 异步读取文件,await 直接拿到 string 结果
string s = await File.ReadAllTextAsync(fileName);
Console.WriteLine(s);
}
3. 同步与异步方法的选择原则
同一功能同时存在同步实现和异步实现时,优先使用异步方法,以提升程序的并发能力与资源利用率。
4. 异步转同步的风险用法
对于不支持异步调用的场景,可通过以下方式同步等待,但存在死锁风险,生产环境尽量避免使用:
Wait():用于无返回值的Task,同步阻塞等待任务完成。Result:用于Task<T>,同步阻塞获取结果。- 风险:在带有同步上下文(SynchronizationContext)的环境(如 WinForms、WPF、旧版 ASP.NET)中极易引发死锁。
二、async/await 底层原理
1. 本质是语法糖
async / await 是 C# 提供的语法糖,并非运行时原生实现。编译器会将异步代码重写为复杂的状态机结构,最终执行的仍然是普通 IL 代码。
- 验证方式:使用 ILSpy 等反编译工具,将编译后的 dll 反编译为 C# 4.0 版本(不支持 async/await 的版本),即可看到底层的状态机实现代码。
2. 编译后的状态机结构
C# 编译器会对 async 方法做以下转换:
- 生成一个实现了
IAsyncStateMachine接口的内部类(状态机类)。 - 将原方法中的代码按
await调用的位置切分为多个“状态片段”,每个片段对应状态机的一个状态。 - 对
async方法的调用,最终会被拆分为对状态机MoveNext()方法的多次调用:每遇到一个await就暂停并记录当前状态,等待任务完成后再次调用MoveNext()继续执行下一段代码。
3. “等待”的真相
代码层面 await 看起来是“等待”任务完成,但经过编译后:
- 没有真正的线程阻塞(wait),执行到
await时会立即返回,剩余代码被封装为“延续(continuation)”,等异步操作完成后再被调度执行。 - 这也是异步编程能够释放线程、提升吞吐量的核心原因。
三、异步方法的线程切换机制
1. 线程池复用机制
在 await 等待异步操作完成的期间:
- .NET 运行时会把当前占用的线程归还到线程池,供其他任务使用。
- 当异步操作执行完毕后,框架会再从线程池中取出一个可用线程,继续执行
await之后的代码。
这意味着:await 前后的执行线程可能不是同一个。
2. 常见误区:async 不会自动创建新线程
异步方法的代码不会自动在新线程中执行,它只是把代码切分成了多个可暂停的片段。
- 只有显式使用
Task.Run、Task.Factory.StartNew等方式将代码放到线程池执行时,才会开启新的线程。 - 对于 IO 密集型异步操作(如文件读写、网络请求),底层基于 IO 完成端口实现,等待期间全程不占用线程,这才是异步最大的价值。
3. async 方法的性能代价
异步编程并非没有成本,主要缺点有:
- 额外的内存与性能开销:编译器会生成状态机类,对象分配、状态切换都会带来少量性能损耗,运行效率低于普通同步方法。
- 线程占用可能增加:高并发场景下,大量异步任务的延续执行可能会占用更多的线程池线程,增加调度开销。
注:尽管有上述开销,但对于 IO 密集型场景,异步带来的吞吐量提升远大于这些损耗;而对于纯 CPU 计算场景,异步未必能带来收益。
四、线程阻塞与异步等待
1. Thread.Sleep 与 Task.Delay 的区别
| 方式 | 类型 | 行为 | 适用场景 |
|---|---|---|---|
Thread.Sleep(3000) |
同步阻塞 | 会阻塞当前线程,线程在此期间无法执行其他任务 | 同步代码中暂停 |
await Task.Delay(3000) |
异步等待 | 不会阻塞线程,将线程归还线程池,时间到后再调度执行 | 异步方法中暂停 |
2. 使用原则
- 在同步方法中暂停执行,可以使用
Thread.Sleep。 - 在异步方法中需要暂停/等待时,必须使用
await Task.Delay(),否则会阻塞线程,失去异步的意义。
五、任务提前终止:CancellationToken
1. 作用与使用场景
在实际开发中经常需要提前终止异步任务,例如:
- 网络请求超时
- 用户主动取消操作
- 程序关闭时终止后台任务
.NET 中绝大多数内置异步方法都提供了 CancellationToken 参数,用于接收提前终止的信号。
2. CancellationToken 核心成员
CancellationToken 是一个结构体,核心属性与方法:
None:表示一个空的、永远不会被取消的令牌。IsCancellationRequested:只读布尔属性,判断当前是否已收到取消请求。Register(Action callback):注册一个回调方法,当收到取消信号时自动执行该回调。ThrowIfCancellationRequested():如果任务已被取消,执行到该语句时会立即抛出OperationCanceledException异常,终止执行流。
3. 两种常用取消模式
- 主动轮询模式:在循环或分段代码中定期检查
IsCancellationRequested属性,若为true则主动清理资源并退出方法。 - 异常终止模式:调用
ThrowIfCancellationRequested(),通过抛出异常的方式立即中断执行,由上层调用者捕获取消异常。
4. 基础使用示例
// 调用方:创建取消源,2秒后触发取消
using var cts = new CancellationTokenSource();
cts.CancelAfter(2000);
try
{
await DoLongWorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("任务已被取消");
}
// 执行方:接收令牌并响应取消
async Task DoLongWorkAsync(CancellationToken cancellationToken)
{
for (int i = 0; i < 10; i++)
{
// 方式1:检查取消状态后主动退出
if (cancellationToken.IsCancellationRequested)
return;
// 方式2:直接抛出异常终止
// cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(500, cancellationToken);
Console.WriteLine($"执行第 {i+1} 步");
}
}
六、多任务组合:并行执行与竞速
1. Task.WhenAll:等待所有任务完成
用于批量执行多个异步任务,等待全部完成后统一获取结果,适合并发 IO 操作(批量下载、批量查询)。
async Task<string[]> ReadAllFilesAsync(string[] filePaths)
{
// 启动所有异步任务(并发执行)
var tasks = filePaths.Select(p => File.ReadAllTextAsync(p)).ToArray();
// 等待所有任务全部完成
string[] allContents = await Task.WhenAll(tasks);
return allContents;
}
2. Task.WhenAny:等待任一任务完成
等待任务集合中第一个完成的任务,常用于超时控制、多节点竞速请求。
// 向两个服务器发请求,谁先返回就用谁的结果
async Task<string> FastestApiRequestAsync()
{
var taskA = RequestFromServerAAsync();
var taskB = RequestFromServerBAsync();
// 等待任意一个任务完成
var firstCompleted = await Task.WhenAny(taskA, taskB);
return await firstCompleted;
}
3. 进阶:带超时的任务控制
结合 Task.Delay + WhenAny 实现简易超时控制:
async Task<string> RequestWithTimeoutAsync(int timeoutMs = 3000)
{
var requestTask = LongTimeRequestAsync();
var timeoutTask = Task.Delay(timeoutMs);
var winner = await Task.WhenAny(requestTask, timeoutTask);
if (winner == timeoutTask)
throw new TimeoutException("请求超时");
return await requestTask;
}
七、异步异常处理
1. 单任务异常捕获
await 会将 Task 中包裹的异常重新抛出,使用普通 try-catch 即可捕获,写法与同步代码一致。
async Task TestSingleExceptionAsync()
{
try
{
await RiskyReadFileAsync("not-exist.txt");
}
catch (FileNotFoundException ex)
{
Console.WriteLine($"文件不存在:{ex.FileName}");
}
catch (IOException ex)
{
Console.WriteLine($"IO 错误:{ex.Message}");
}
}
注意:如果不使用
await,异常会被“藏”在Task对象中,不会主动抛出。
2. 多任务异常与 AggregateException
使用 Task.WhenAll 时,如果多个任务都抛出异常,最终会被包装为 AggregateException,通过 InnerExceptions 可遍历所有异常。
async Task TestMultipleExceptionsAsync()
{
var task1 = ThrowErrorAsync("错误1");
var task2 = ThrowErrorAsync("错误2");
try
{
await Task.WhenAll(task1, task2);
}
catch (AggregateException ex)
{
foreach (var inner in ex.InnerExceptions)
{
Console.WriteLine($"异常:{inner.Message}");
}
}
}
async Task ThrowErrorAsync(string msg)
{
await Task.Delay(100);
throw new Exception(msg);
}
八、ConfigureAwait 与同步上下文
1. 核心作用
默认情况下,await 完成后会尝试回到原来的同步上下文(例如 UI 线程、ASP.NET 请求上下文)继续执行。
ConfigureAwait(false):告诉运行时不需要回到原上下文,直接在线程池线程上执行后续代码。
2. 使用原则
- 类库代码:几乎都应该加
ConfigureAwait(false),减少上下文切换开销,同时大幅降低死锁风险。 - UI 业务代码:如果后续代码需要操作界面控件,则不能加,必须回到 UI 线程。
// 类库中的标准写法
async Task<string> ReadFileForLibraryAsync(string path)
{
// 不回到原上下文,在线程池继续执行
string content = await File.ReadAllTextAsync(path).ConfigureAwait(false);
return content.Trim();
}
九、async void 的坑与适用场景
1. 为什么不推荐 async void
- 无法使用
await等待方法执行,调用方无法获知任务何时完成。 - 异常无法被常规
try-catch捕获,会直接上升到全局异常,可能导致程序崩溃。 - 无法追踪任务状态,难以进行错误处理和取消控制。
2. 唯一合法场景:事件处理器
async void 唯一被推荐的用法是UI 事件处理程序(如按钮点击、窗体加载),因为事件的签名本身就是 void 返回类型。
// ✅ 正确:WPF/WinForms 按钮点击事件
private async void btnDownload_Click(object sender, RoutedEventArgs e)
{
btnDownload.IsEnabled = false;
try
{
await DownloadLargeFileAsync();
MessageBox.Show("下载完成");
}
catch (Exception ex)
{
MessageBox.Show($"下载失败:{ex.Message}");
}
finally
{
btnDownload.IsEnabled = true;
}
}
// ❌ 错误:普通业务方法用 async void
async void BadBusinessMethodAsync()
{
await Task.Delay(1000);
throw new Exception("这个异常很难被捕获");
}
十、ValueTask:减少异步内存分配
1. 适用场景
当异步方法存在同步完成的快速路径(例如缓存命中、内存读取)时,使用 Task<T> 会每次分配一个堆对象,增加 GC 压力。
ValueTask<T> 是值类型,同步完成时不会产生堆分配,可优化性能。
2. 使用示例
private string _cachedConfig;
// 缓存命中时同步返回,不分配 Task 对象
async ValueTask<string> GetConfigAsync()
{
if (_cachedConfig != null)
{
// 同步完成路径:直接返回值
return _cachedConfig;
}
// 真正异步路径:从数据库读取
_cachedConfig = await LoadConfigFromDbAsync();
return _cachedConfig;
}
3. 注意事项
- 一个
ValueTask只能被await一次,多次 await 会产生未定义行为。 - 仅在“同步完成路径占比高”的热点路径上使用,普通场景用
Task即可。
十一、Task.Run 的正确使用
1. 正确场景:CPU 密集型任务
Task.Run 的作用是将CPU 密集型计算放到线程池线程中执行,避免阻塞主线程(如 UI 线程)。
// ✅ 正确:把耗 CPU 的计算交给线程池
async Task<long> CalculateSumAsync(int max)
{
return await Task.Run(() =>
{
long sum = 0;
for (int i = 0; i < max; i++)
sum += i;
return sum;
});
}
2. 常见误区:包装 IO 异步方法
不要用 Task.Run 包裹一个已经是异步的 IO 操作,这是多此一举,还会白白占用一个线程池线程。
// ❌ 错误:没必要用 Task.Run 包一层异步方法
async Task<string> BadReadAsync(string path)
{
return await Task.Run(async () => await File.ReadAllTextAsync(path));
}
// ✅ 正确:直接 await 异步方法
async Task<string> GoodReadAsync(string path)
{
return await File.ReadAllTextAsync(path);
}
十二、异步死锁的成因与避免
1. 死锁产生条件
在带有同步上下文的环境(WinForms、WPF、旧版 ASP.NET)中,同时满足以下两点就会死锁:
- 调用方使用
.Result或.Wait()同步阻塞等待异步任务。 - 异步方法内部
await后默认要回到原同步上下文继续执行。
结果:上下文被 .Result 阻塞住,await 结束后回不去,形成永久等待。
2. 死锁示例
// 此代码在 UI 线程中调用会发生死锁
public void DeadlockDemo()
{
// 1. 阻塞 UI 同步上下文
string result = GetDataAsync().Result;
}
async Task<string> GetDataAsync()
{
// 2. 等待结束后想回到 UI 上下文,但被阻塞了
await Task.Delay(1000);
return "data";
}
3. 避免方案
- 全程异步:调用方也用
await,不混用.Result/.Wait(),这是最根本的方案。 - 切断上下文:异步方法内部使用
ConfigureAwait(false),不回到原上下文。
// 方案1:全程 await
public async Task NoDeadlock1()
{
string result = await GetDataAsync();
}
// 方案2:ConfigureAwait(false)
async Task<string> GetDataSafeAsync()
{
await Task.Delay(1000).ConfigureAwait(false);
return "data";
}
十三、异步流:IAsyncEnumerable
1. 作用
用于逐步产生数据的异步场景(如分页查询、流式读取、实时推送),可以一边生产一边消费,不用等所有数据生成完毕。
2. 使用示例
// 生产异步流:逐批返回数据
async IAsyncEnumerable<int> GenerateNumbersAsync(int count)
{
for (int i = 0; i < count; i++)
{
await Task.Delay(500); // 模拟异步获取数据
yield return i;
}
}
// 消费异步流
async Task ConsumeStreamAsync()
{
await foreach (var num in GenerateNumbersAsync(10))
{
Console.WriteLine($"收到数据:{num}");
}
}
十四、取消令牌进阶
1. 自动超时取消
通过 CancellationTokenSource 的超时构造,实现“到期自动取消”。
async Task<string> RequestWithAutoTimeoutAsync()
{
// 3 秒后自动触发取消
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
try
{
return await LongTimeRequestAsync(cts.Token);
}
catch (OperationCanceledException)
{
return "请求超时";
}
}
2. 令牌联动:合并多个取消信号
使用 CreateLinkedTokenSource 将多个令牌合并,任意一个触发取消,最终令牌都会取消。
async Task DoWorkWithLinkedTokenAsync(CancellationToken userCancelToken)
{
// 超时令牌 + 用户取消令牌 合并
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var linkedCts = CancellationTokenSource
.CreateLinkedTokenSource(timeoutCts.Token, userCancelToken);
// 传入合并后的令牌
await LongTimeWorkAsync(linkedCts.Token);
}

浙公网安备 33010602011771号