1. Task 类
- Task 类是对异步操作、并行任务等的封装。
- 通过语法糖
async/await来启用异步编程。 - 通过
Task.Run()可以将代码放在线程池线程上执行(适用于 CPU 密集型任务)。
2. 异步编程和同步编程
- 同步编程:代码按顺序从上到下执行,遇到耗时操作(如 I/O、网络请求)会阻塞当前线程。
- 异步编程:使用
async/await,遇到耗时操作时不会阻塞线程,线程可以被释放去做其他工作。
3. 异步编程和多线程
- 异步是一种编程模型,核心是“不阻塞”。异步操作不一定需要新线程,例如 I/O 异步依赖于硬件和操作系统的通知机制,无需占用额外线程。
- 多线程是一种技术,指同时运行多个线程(通常是 CPU 密集型并行)。
- 只有使用
Task.Run()或显式创建线程时,异步操作才会真正涉及多线程(线程池)。
4. I/O 密集和 CPU 密集
- I/O 密集型:涉及数据库、Redis、HTTP 请求、文件读写等。推荐使用原生
async/await(如HttpClient.GetStringAsync、Stream.ReadAsync),不需要Task.Run。 - CPU 密集型:涉及复杂计算、加密算法、图像处理等。推荐使用
Task.Run将其放到线程池线程执行,避免阻塞 UI 或请求线程。
5. 异步的不阻塞机制
当遇到 await 时:
- 当前方法暂停(yield),线程被释放回线程池或消息循环。
await后面的代码需要等待异步操作完成后才会继续执行。- 外层调用者如果也
await了这个方法,外层也会暂停;但如果不await(或使用其他方式),外层可继续执行。 - 异步操作完成后,线程池会分配某个线程(不一定是原先的线程)回来继续执行
await之后的代码。
示例:
public async Task MainAsync()
{
Console.WriteLine("start main method.");
await TestAsync(); // 暂停,释放线程
Console.WriteLine("End main method"); // 等待 TestAsync 完成后才执行
}
public async Task TestAsync()
{
Console.WriteLine("Start Test method");
await Task.Delay(2000); // 暂停,释放线程
Console.WriteLine("End Test method"); // 2秒后恢复执行
}
6. 同步上下文 SynchronizationContext
SynchronizationContext表示代码运行所在的“环境”(如 UI 线程、ASP.NET 请求上下文等)。await默认会捕获当前SynchronizationContext,异步操作完成后,会尝试回到原来的上下文执行剩余代码(例如在 WPF 中回到 UI 线程)。- 这个机制是实现“自动切回 UI 线程”的基础。
7. ConfigureAwait 控制是否切回上下文
ConfigureAwait(true):默认行为,尝试回到原始上下文(UI/请求上下文)。ConfigureAwait(false):不切回原始上下文,后续代码在线程池线程上执行。- 使用建议:
- 在 UI 或 ASP.NET Core(旧版)之后需要操作 UI 或 HttpContext 时,必须
true。 - 在通用库代码、不需要上下文的地方,使用
false可减少线程切换开销,提升性能。
- 在 UI 或 ASP.NET Core(旧版)之后需要操作 UI 或 HttpContext 时,必须
8. 异步取消:CancellationToken 和 CancellationTokenSource(详细)
概念区分
| 类型 | 角色 | 能否主动触发取消 | 用途 |
|---|---|---|---|
CancellationTokenSource | 信号的发起者 | ✅ 可以(Cancel() 方法) | 创建取消信号,控制取消的时机(例如超时、用户点击取消按钮)。 |
CancellationToken | 信号的监听者 | ❌ 不可以,只读 | 传递给异步方法,让方法内部可以定期检查是否应该停止。 |
简单比喻:
CancellationTokenSource是一个开关(你可以按下它),CancellationToken是连接到这个开关的电线(被通知的设备只能看信号,不能改变开关)。
核心使用总结
CancellationTokenSource与CancellationToken必须配套使用。- 外部控制取消:通过
CancellationTokenSource调用Cancel()(立即取消)或CancelAfter(毫秒)(延时取消)。 - 内部响应取消:在异步方法内部拿到
CancellationToken后:- 主动抛出异常:
token.ThrowIfCancellationRequested()– 如果已取消,则抛出OperationCanceledException,外部可捕获该异常。 - 条件判断:
if (token.IsCancellationRequested)– 可自行决定如何退出(如直接return、记录日志、执行清理后再抛异常等)。
- 主动抛出异常:
⚠️ 重要:
ThrowIfCancellationRequested() 仅当外部已经调用了 Cancel() 或超时触发时才会抛异常。如果外部从未取消,该方法什么也不做。内部无法通过它主动抛异常。
超时取消的特别说明
- 超时(
CancelAfter或CancellationTokenSource(TimeSpan))只是定时发出取消信号,不会强制终止任务。 - 任务不会自动停止,必须内部检查 token 并响应(如调用
ThrowIfCancellationRequested)。 - 如果任务从未检查 token,即使超时已过,它仍会继续运行直到完成。
错误示范:
var cts = new CancellationTokenSource(1000);
var token = cts.Token;
Task badTask = Task.Run(async () =>
{
await Task.Delay(5000); // 5秒后才完成,超时被忽略
Console.WriteLine("完成");
});
正确示范:
Task goodTask = Task.Run(async () =>
{
for (int i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested(); // 超时1秒后会抛出异常
await Task.Delay(500, token); // Delay 内部也会响应取消
}
}, token);
完整代码示例
using System;
using System.Threading;
using System.Threading.Tasks;
public class CancellationDemo
{
public static async Task Main()
{
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;
Task longRunningTask = DoLongWorkAsync(token);
await Task.Delay(1000);
Console.WriteLine("[Main] 用户取消了操作!");
cts.Cancel();
try
{
await longRunningTask;
Console.WriteLine("[Main] 任务正常完成。");
}
catch (OperationCanceledException)
{
Console.WriteLine("[Main] 任务已被取消。");
}
}
static async Task DoLongWorkAsync(CancellationToken token)
{
for (int i = 1; i <= 10; i++)
{
if (token.IsCancellationRequested)
{
Console.WriteLine($"[DoLongWork] 检测到取消,退出循环");
token.ThrowIfCancellationRequested();
}
Console.WriteLine($"[DoLongWork] 进度:{i}/10");
await Task.Delay(500, token);
}
Console.WriteLine("[DoLongWork] 任务完成");
}
}
输出示例:
[DoLongWork] 进度:1/10
[DoLongWork] 进度:2/10
[Main] 用户取消了操作!
[DoLongWork] 检测到取消,退出循环
[Main] 任务已被取消。
高级用法
- 超时取消:
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));或者cts.CancelAfter(5000); - 链接多个 Token:
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(token1, token2); - 注册取消回调:
token.Register(() => Console.WriteLine("取消时执行此动作"));
9. WhenAll 和 WhenAny
- 单个
await不会缩短总执行时间,只是不阻塞线程。 - 并发执行多个异步任务:先启动所有任务(不立即
await),然后使用Task.WhenAll或Task.WhenAny等待。 Task.WhenAll:等待所有任务完成,所有结果聚合。Task.WhenAny:任意一个任务完成即返回。
var task1 = DoWorkAsync();
var task2 = DoOtherAsync();
await Task.WhenAll(task1, task2);
10. 异步转同步、同步转异步(注意事项与反模式)
异步转同步(⚠️ 容易导致死锁)
- 使用
.Result或.Wait()将异步方法变为同步阻塞。 - 危险:在 UI 线程或 ASP.NET 旧版请求上下文中容易造成死锁(因为上下文线程被阻塞,异步操作无法回到该线程)。
- 安全做法:使用
GetAwaiter().GetResult()风险相同;最佳方案是自底向上全部改为异步。
同步转异步
- 不要用
Task.Run包装同步方法来伪装异步(这只会浪费线程)。 - 真正的异步应使用原生
async方法(如File.ReadAllTextAsync、HttpClient.GetStringAsync)。 - 如果要包装一个同步但耗时的 CPU 方法,可以用
Task.Run,但调用方应理解这是 CPU 密集型任务。
11. 异步捕获异常
async方法内的异常会被包装到返回的Task中。- 使用
try-catch包围await即可捕获异常。 - 同时等待多个任务时(
WhenAll),异常会聚合到AggregateException中,但await WhenAll只会抛出第一个异常。若要获取所有异常,需要捕获并访问task.Exception.InnerExceptions。
try
{
await Task.WhenAll(task1, task2);
}
catch (Exception ex)
{
// 只会捕获第一个发生的异常
// 要获取所有异常:检查 task1.Exception, task2.Exception
}
- 若忘记
await或未观察Task上的异常,会引发UnobservedTaskException(.NET 4.5+ 默认会吞噬并触发进程退出)。
12. 返回值
Task:表示一个没有返回值的异步操作。调用方可以await等待完成。Task<T>:表示返回类型为T的异步操作。void:仅限 UI 事件处理程序(如按钮点击)。其他任何地方都不要使用async void,因为异常无法被捕获,且调用方无法等待它完成。
13. 死锁(同步与异步混用)
- 典型死锁场景:在有同步上下文的环境(WPF UI、WinForms、旧版 ASP.NET)中,调用
Task.Result或Task.Wait()等待一个尚未完成的异步方法。 - 原因:异步方法完成后需要回到原上下文,但原线程被
Result阻塞,无法处理消息,导致互相等待。 - 解决方案:
- 全链路使用
async/await。 - 或者在子方法中使用
ConfigureAwait(false),但这不一定完全可靠。 - 最彻底:避免使用
Result/Wait。
- 全链路使用
14. ValueTask
ValueTask和ValueTask<TResult>是值类型的任务表示,用于性能敏感且结果经常同步返回的场景。- 优点:减少堆内存分配。
- 限制:
- 只能被
await一次。 - 不能并发
await。 - 不能使用
WhenAll/WhenAny。 - 通常由框架/库作者使用,一般应用开发中直接用
Task即可。
- 只能被
15. 面试题补充:WPF 的 Dispatcher.Invoke vs async/await
- 老旧代码:使用
Dispatcher.Invoke将操作派送到 UI 线程,需要手动传递委托。 async/await:通过SynchronizationContext自动捕获 UI 上下文,await之后天然回到 UI 线程(除非使用ConfigureAwait(false)),更简洁、现代、灵活。
✅ 本指南已包含完整取消机制示例(含配套关系、外部取消、内部响应),以及超时取消的常见误区说明。