C# Task / async/await / CancellationToken
一、Task
1.Task 理解
Task类似于我们去点餐,餐厅给你一张小票,这个小票就是Task。他表示现在还没有饭,以后会有,你可以去逛街,等饭做好,可以凭借小票(Task)取餐
而在C#
Task=小票
做饭=一个异步操作(可能是读文件、请求网站、查数据库)
逛街=不阻塞当前运行程序,代码可以干其他事。
需要注意的的是Task 不是线程。这是一个最核心也最容易误解的概念。Task 表示的是一项尚未完成的工作 或一个未来的结果,它更像一个异步操作的句柄,而不是线程本身
cs
Task<int> task = GetUserCountAsync();
这里的 task 表示用户数量这个结果以后会出来,但完全不等于已经为它开了一个新线程。
Task 的本质有三层含义
-
异步操作的统一抽象: 无论底层是线程池执行计算、操作系统完成异步 I/O、定时器触发,还是回调被包装,最后都可以统一表现为一个Task 或 Task。
-
带有状态: 一个 Task 会经历等待调度、运行中、成功完成、失败或被取消等状态,它还负责承载完成信号、异常、取消状态和 continuation。
-
可组合: Task 可以被组合使用,这是相比传统回调最重要的优势之一。
2. Task 的生命周期
Created 任务已创建,但尚未启动
Running 任务正在执行
RanToCompletion 任务成功完成,没有异常或取消
Faulted 任务因未处理的异常而失败
Canceled 任务被取消
视觉开发重点关心:Faulted和 Canceled
3. 常用 Task 组合器
| 方法 | 作用 | 视觉案例 |
|---|---|---|
| Task.WhenAll | 并行执行多个任务 | 同时处理多台相机的采集 |
| Task.WhenAny | 谁先完成就用谁的结果 | 多个算法模型并行推理,取最快结果 |
| Task.Delay | 非阻塞等待 | 定时轮询设备状态,不影响 UI |
二、async/await 写同步代码一样写异步逻辑
很多人认为 async/await 是某种运行时黑魔法------一个函数加上 async 就能自动变成非阻塞,加上 await 就能让线程"休息"而不会阻塞。实际上,async/await 完全是由编译器在编译期完成的状态机重写。运行时并不知道 async 关键字的存在,它看到的是经过转换的代码。
换句话说,async/await 是 C# 编译器提供的高级语法糖,它自动帮你将异步逻辑切分成多个片段,并在每个 await 点保存/恢复状态。
1. await 的"等待"到底等什么
await 的本质:不阻塞,只是"挂起并返回"
很多人误以为 await task; 会让当前线程阻塞等待 task 完成,这种理解是完全错误的。
await 的行为是:
- 检查 task 是否已经完成(IsCompleted == true)如果是,同步继续,不需要任何额外调度。如果否,进入第 2 步。
- 挂起当前方法:保存方法的状态(局部变量、执行位置等),并立即返回一个未完成的 Task 给调用者。
- 注册一个回调:当 task 完成时,该回调会恢复当前方法的执行(在某个线程上,通常是完成 task 的线程或捕获的同步上下文)。
因此,await 不会阻塞任何线程。这正是异步编程能提高吞吐量的核心:线程可以被释放去做其他工作,而不是白白等待 I/O。
对比阻塞等待
| 方式 | 线程行为 | 适用场景 |
|---|---|---|
| task.Wait() 或 task.Result | 当前线程阻塞,直到 task 完成 | 控制台 Main 方法(有限场景) |
| await task | 当前方法挂起,线程返回调用者,不阻塞 | 几乎所有异步场景 |
如果在 UI 线程(WPF/WinForms)或 ASP.NET Core 请求线程上使用 .Result,轻则降低响应性,重则导致死锁。
何时真的需要阻塞
极少数情况,比如控制台应用的 Main 方法(C# 7.1 之前不支持 async Main),或者某些无法改造为异步的遗留代码。即便如此,更好的做法是使用 await 并让调用链一直异步到入口点。
2. async 关键字的作用
async 关键字本身不创建异步,它只做两件事
- 允许在方法内使用 await(没有 async 就不能用 await)。
- 强制编译器将该方法转换为状态机,并将返回值包装为 Task/Task。
所以 async 更像是一个"标记",告诉编译器:这个方法体内有异步操作,请帮我生成状态机代码。
避免"异步 void"
public async void Start() → 异常无法捕获,调用方无法等待
public async Task Start(),UI 事件处理程序可以 _ = Start() 或改用 async void 但内部 try-catch
3. 异步方法的返回类型
| 返回类型 | 适用场景 | 说明 |
|---|---|---|
| Task | 没有返回值的异步操作 | 类似 void,但可被 await 和捕获异常 |
| Task | 有返回值的异步操作 | 返回 T 类型的结果 |
| void | 仅限 UI 事件处理程序 | 异步无返回值,但调用方无法等待、无法捕获异常(危险) |
| ValueTask / ValueTask | 高频调用、多数情况同步完成的场景 | 减少堆内存分配,但使用限制较多 |
小Tips:大多数普通应用不需要 ValueTask,使用 Task 更安全。
4. 异常处理
- 在 async 方法内部抛出异常
cs
public async Task<int> DivideAsync(int a, int b){
if (b == 0) throw new DivideByZeroException();
return await Task.FromResult(a / b);
}
调用者用 try/catch 包裹 await 即可捕获异常
cs
try{
int result = await DivideAsync(10, 0);
}catch (DivideByZeroException ex){
// 捕获成功
}
关键点: 异常在 await 处传播,而不是在调用 DivideAsync 时。因为 DivideAsync 返回的 Task 进入 Faulted 状态,await 检测到后会重新抛出异常。
5. async/await 与同步上下文的交互
SynchronizationContext 决定 await 后代码跑在哪个线程
UI 线程(WPF/WinForms):await 后自动回到 UI 线程 → 可直接更新控件
类库 / 后台服务:建议使用 ConfigureAwait(false) 避免不必要的上下文切换
cs
var data = await File.ReadAllTextAsync(path).ConfigureAwait(false);
6. await使用场景
| 场景 | 说明 | 典型API |
|---|---|---|
| 网络请求 | 从远程 API 获取数据,如 REST 调用、下载文件。 | HttpClient.GetStringAsync / PostAsync |
| 文件 I/O | 读写大文件,避免阻塞 UI 或线程池。 | File.ReadAllTextAsync / WriteAsync |
| 数据库操作 | 查询、插入、更新数据(尤其是 ORM 异步方法)。 | SqlCommand.ExecuteReaderAsync DbContext.SaveChangesAsync |
| 延迟或定时 | 模拟等待或实现超时。 | Task.Delay |
| 异步流处理 | 使用 IAsyncEnumerable 逐条消费数据。 | await foreach |
| 并行任务组合 | 同时等待多个异步操作完成。 | Task.WhenAll / WhenAny |
| 跨服务调用 | 微服务间 HTTP/gRPC 调用。 | GrpcClient.XXXAsync |
| 消息队列消费 | 异步接收和处理消息(如 RabbitMQ、Kafka 的异步客户端)。 | BasicConsumeAsync |
三、CancellationToken 取消异步任务
CancellationToken 其实就是一张"可以随时喊停"的凭证。就好比我们的小票,在饭还没开始做的时候可以喊停。(任务在合适的地方取消请求并退出)
CancellationToken 的基本用法
| 角色 | 组件 | 作用 |
|---|---|---|
| 遥控器 | CancellationTokenSource | 产生令牌,并控制取消 |
| 信号线 | CancellationToken | 传递给异步方法,供其监听 |
| 检测开关 | ThrowIfCancellationRequested() | 检测到取消时抛出 OperationCanceledException |
| 检测开关 | IsCancellationRequested | 非侵入式检查,可自己退出循环 |
1. 创建一个CancellationToken 取消程序
cs
//创建"遥控器"和"信号线"
// 1. 创建一个遥控器
CancellationTokenSource cts = new CancellationTokenSource();
// 2. 把信号线交给任务
CancellationToken token = cts.Token;
//写一个可以取消的任务
Task.Run(() =>{
for (int i = 0; i < 100; i++){
// 每次循环都看一眼:有人按取消了吗?
if (token.IsCancellationRequested){
Console.WriteLine("收到取消信号,退出!");
return;
}
// 否则继续干活
Thread.Sleep(100);
}
}, token); // 把 token 传给 Task.Run,让它可以响应取消
// 用户点了取消按钮
cts.Cancel();
另一种更"暴力"的写法
cs
Task.Run(() =>{
for (int i = 0; i < 100; i++){
// 如果取消了,直接抛异常(会被 Task 捕获,任务状态变成 Canceled)
token.ThrowIfCancellationRequested();
// 干活...
}
}, token);
两者区别:
IsCancellationRequested 是你自己判断、自己退出(可以做一些清理工作再 return)。
ThrowIfCancellationRequested 是直接抛异常,让上层 catch 处理,适合不想写一堆 if 的情况。
2. 带超时的取消
cs
// 方法1:使用 CancelAfter
CancellationTokenSource cts = new CancellationTokenSource();
cts.CancelAfter(200); // 200ms 后自动取消
// 方法2:直接传超时时间
await someTask.WaitAsync(TimeSpan.FromMilliseconds(200));
3. 多个取消信号合并
需要同时响应"用户取消"和"超时自动取消"。可以用 CreateLinkedTokenSource 把两个信号合并。
cs
var userCts = new CancellationTokenSource(); // 用户手动取消
var timeoutCts = new CancellationTokenSource(5000); // 5秒超时
// 把两个遥控器的信号合并成一个
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
userCts.Token, timeoutCts.Token);
var token = linkedCts.Token;
// 把这个 token 传给任务,无论是用户点取消还是超时,任务都会收到取消信号
await LongRunningTaskAsync(token);
4. 总结
| 我想做什么 | 写什么代码 |
|---|---|
| 创建遥控器 | var cts = new CancellationTokenSource(); |
| 取出信号线 | var token = cts.Token; |
| 把信号线交给任务 | Task.Run(action, token) 或 await xxxAsync(token) |
| 任务内部检查 | if (token.IsCancellationRequested) break; |
| 任务内部抛异常 | token.ThrowIfCancellationRequested(); |
| 触发取消 c | ts.Cancel(); |
| 自动取消(超时) | cts.CancelAfter(1000); |
| 用完释放 | cts.Dispose(); 或 using var cts = new ... |
| 合并多个信号 | CancellationTokenSource.CreateLinkedTokenSource(t1, t2) |
练习样例
控制台程序:异步下载 10 张图片,支持中途取消
cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
//控制台程序:异步下载 10 张图片,支持中途取消
namespace Console_program_Task_async_await{
class Program{
// 图片URL列表Lorem Picsum 提供的随机图片
private static readonly List<string> ImageUrls = new List<string>{
"https://picsum.photos/id/1/200/300",
"https://picsum.photos/id/2/200/300",
"https://picsum.photos/id/3/200/300",
"https://picsum.photos/id/4/200/300",
"https://picsum.photos/id/5/200/300",
"https://picsum.photos/id/6/200/300",
"https://picsum.photos/id/7/200/300",
"https://picsum.photos/id/8/200/300",
"https://picsum.photos/id/9/200/300",
"https://picsum.photos/id/10/200/300"
};
// 复用 HttpClient
private static readonly HttpClient httpClient = new HttpClient();
static async Task Main(string[] args)
{
Console.WriteLine("开始下载10张图片...");
Console.WriteLine("提示:按 C 键或按 Ctrl+C 可中途取消下载。\n");
// 创建 CancellationTokenSource 用于取消操作
using (var cts = new CancellationTokenSource())
{
// 注册 Ctrl + C 事件,实现取消
Console.CancelKeyPress += (sender, e) =>
{
Console.WriteLine("\n检测到 Ctrl+C,正在取消下载...");
e.Cancel = true; // 阻止进程立即终止
cts.Cancel(); // 触发取消令牌
};
// 创建一个任务来监听键盘按键(按 C 键取消)
var keyListenerTask = Task.Run(() =>{
while (!cts.Token.IsCancellationRequested){
if (Console.KeyAvailable){
if (Console.KeyAvailable && char.ToUpper(Console.ReadKey(true).KeyChar) == 'C'){
Console.WriteLine("\n用户按下了 C 键,正在取消下载...");
cts.Cancel();
break;
}
Thread.Sleep(100);
}
}
});
// 创建下载目录
string downloadPath = Path.Combine(Directory.GetCurrentDirectory(), "DownloadedImages");
Directory.CreateDirectory(downloadPath);
try{
//开始下载所有图片
await DownloadAllImagesAsync(ImageUrls, downloadPath, cts.Token);
Console.WriteLine("\n所有图片下载完成!");
}catch (OperationCanceledException){
Console.WriteLine("\n下载已被用户取消。");
} catch (Exception ex){
Console.WriteLine($"\n下载过程中发生错误: {ex.Message}");
}finally{
// 确保键盘监听任务结束
cts.Cancel();
await keyListenerTask;
}
// cts.Dispose() 自动调用
}
Console.WriteLine("按任意键退出...");
Console.ReadKey();
}
private static async Task DownloadAllImagesAsync(List<string> urls, string downloadPath, CancellationToken cancellationToken){
var downloadTasks = new List<Task>();
int index = 1;
foreach (string url in urls){
string filePath = Path.Combine(downloadPath, $"image_{index++}.jpg");
downloadTasks.Add(DownloadImageAsync(url, filePath, index, cancellationToken));
}
await Task.WhenAll(downloadTasks);
}
// 下载单张图片并保存到本地
private static async Task DownloadImageAsync(string url, string filePath, int imageIndex, CancellationToken cancellationToken){
try{
Console.WriteLine($"[任务 {imageIndex}] 开始下载:{url}");
using (HttpResponseMessage response = await httpClient.GetAsync(url, cancellationToken)){
response.EnsureSuccessStatusCode(); // 确保请求成功
byte[] imageBytes = await response.Content.ReadAsByteArrayAsync();
// 异步写入文件(支持取消)
using (var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, useAsync: true))
{
await fileStream.WriteAsync(imageBytes, 0, imageBytes.Length, cancellationToken);
}
}
Console.WriteLine($"[任务 {imageIndex}] 下载完成并保存至:{filePath}");
}catch (OperationCanceledException){
Console.WriteLine($"[任务 {imageIndex}] 下载已取消。");
throw;
}catch (Exception ex){
Console.WriteLine($"[任务 {imageIndex}] 下载失败:{ex.Message}");
throw;
}
}
}
}
本文参考:
C# 官方文档https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/how-to-create-pre-computed-tasks
博客园 https://www.cnblogs.com/yilezhu/p/10555849.html
Deepseek https://chat.deepseek.com/