Day 4 - 取消与超时
建议用时:240-300 分钟
你将学会什么
- 为什么异步任务不能无限等待
CancellationTokenSource和CancellationToken分别负责什么- 手动取消、超时取消、传递取消信号怎么写
- 为什么取消是“协作”,不是强行杀死任务
- 如何在循环、文件 IO、批量任务里响应取消
- 取消和普通异常应该怎么区分处理
取消不是失败,也不是崩溃。取消表示外部告诉任务:“不用继续了,请你在合适的位置停下来。”
本页固定顺序
- 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
- 再学第二部分:把刚学的知识组合成一个完整例子。
- 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
- 最后做第四部分:先独立完成作业,再用完整答案检查。
学习衔接
上一页学习的是“并发控制”,今天继续学习“取消与超时”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。
今天的最低通过线
第一次学习不要求背完整页。完成下面 3 项,就可以继续:
- 能用自己的话说明“取消与超时”解决什么问题。
- 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
- 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。
第一部分:先学原理和最小知识
这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。取消机制的核心不是语法,而是理解“谁发信号、谁接信号、谁决定停下来”。
1. 为什么需要取消
异步任务可能等很久:
- 网络一直没响应。
- 文件很大,读取很慢。
- 用户关闭页面或窗口。
- 用户点击取消按钮。
- 后台批量任务已经不需要继续。
如果没有取消机制,程序就只能一直等。
2. 取消不是强行杀死线程
C# 的取消是协作式取消。
意思是:
外部发出取消信号。
任务内部或异步 API 检查到信号。
任务自己在合适位置停止。取消不会直接把线程“砍掉”。
如果任务代码完全不检查 token,取消信号就没有效果。
3. CancellationTokenSource 是什么
CancellationTokenSource 是取消信号的来源。
可以理解成遥控器。
常见写法:
| 写法 | 含义 |
|---|---|
new CancellationTokenSource() | 手动决定什么时候取消 |
new CancellationTokenSource(TimeSpan.FromSeconds(5)) | 5 秒后自动取消 |
cts.Cancel() | 现在发出取消信号 |
cts.CancelAfter(1000) | 1000 毫秒后发出取消信号 |
4. CancellationToken 是什么
CancellationToken 是传给任务的取消信号。
可以理解成任务拿到的“信号接收器”。
常见写法:
await SomeAsync(cts.Token)如果方法内部还会调用其他异步方法,也要继续传下去:
await File.ReadAllTextAsync(path, token)
await Task.Delay(1000, token)这叫传播取消信号。
5. 为什么要把 token 一层一层传下去
如果只创建了 CancellationTokenSource,但没有把 token 传给真正的耗时操作,就不会生效。
错误理解:
我创建了 cts,所以任务会自动取消。正确理解:
我创建 cts,并把 cts.Token 传给会等待的代码,任务才有机会响应取消。6. 超时是什么
超时是自动取消的一种。
例如:
超过 3 秒还没完成,就不要继续等。常见写法:
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3))或者:
cts.CancelAfter(3000)7. 取消时为什么会抛 OperationCanceledException
很多支持取消的异步 API,在收到取消信号后会抛:
OperationCanceledException这不是程序崩了,而是告诉你:
任务按取消信号停止了。所以取消通常单独捕获:
catch (OperationCanceledException)不要把取消和普通错误混在一起。
8. 取消和失败的区别
| 情况 | 含义 | 处理方式 |
|---|---|---|
| 用户点击取消 | 正常停止 | 提示“已取消” |
| 超过时间 | 保护性停止 | 提示“超时” |
| 文件不存在 | 操作失败 | 提示路径问题 |
| 网络 500 | 服务器错误 | 提示请求失败 |
| 代码空引用 | 程序错误 | 修代码 |
取消是业务流程的一部分,不一定是错误。
9. 取消后要做清理
任务取消后可能要:
- 恢复按钮状态。
- 删除临时文件。
- 释放并发名额。
- 写一条日志。
- 更新任务状态。
清理逻辑通常放在 finally 里。
第二部分:把知识组合成完整例子
前面已经学过最小知识。现在把它们组合起来,先读懂执行顺序,再完整敲一遍。今天最终要能写出一个可取消的异步任务。
先看效果:超过 1 秒自动取消
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
try
{
async Task DownloadReportAsync(CancellationToken token)
{
for (int step = 1; step <= 5; step++)
{
token.ThrowIfCancellationRequested();
Console.WriteLine($"正在下载第 {step} 部分");
await Task.Delay(400, token);
}
}
await DownloadReportAsync(cts.Token);
Console.WriteLine("报表下载完成");
}
catch (OperationCanceledException)
{
Console.WriteLine("报表下载已取消或超时");
}
这个程序里最重要的是三件事:
CancellationTokenSource负责发出取消信号。CancellationToken负责把取消信号传给任务。ThrowIfCancellationRequested()和Task.Delay(..., token)负责响应取消。
第三部分:跟着敲代码
从这里开始动手。每个例子都是完整代码,可以直接放进 Program.cs 运行。
动手前先做这 3 件事
- 打开一个控制台项目。
- 每次只保留一个例子的代码,运行通过后再换下一个。
- 每个例子都要改一次超时时间或循环次数,再运行观察结果。
例子 1:手动取消信号
using CancellationTokenSource cts = new CancellationTokenSource();
Console.WriteLine($"取消前: {cts.Token.IsCancellationRequested}");
cts.Cancel();
Console.WriteLine($"取消后: {cts.Token.IsCancellationRequested}");Cancel() 会把 token 标记为已经请求取消。
例子 2:任务开始前已经取消
using CancellationTokenSource cts = new CancellationTokenSource();
cts.Cancel();
try
{
async Task WorkAsync(CancellationToken token)
{
token.ThrowIfCancellationRequested();
Console.WriteLine("开始工作");
await Task.Delay(500, token);
Console.WriteLine("工作完成");
}
await WorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("任务开始前就已取消");
}
ThrowIfCancellationRequested() 会检查取消信号。如果已经取消,就抛出取消异常。
例子 3:超时取消
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(800));
try
{
Console.WriteLine("开始等待");
await Task.Delay(3000, cts.Token);
Console.WriteLine("等待完成");
}
catch (OperationCanceledException)
{
Console.WriteLine("等待超时,任务已取消");
}Task.Delay(3000, cts.Token) 原本要等 3 秒,但 token 在 800 毫秒后取消,所以会提前停止。
例子 4:CancelAfter
using CancellationTokenSource cts = new CancellationTokenSource();
cts.CancelAfter(1000);
try
{
async Task LongWorkAsync(CancellationToken token)
{
for (int i = 1; i <= 10; i++)
{
Console.WriteLine($"处理第 {i} 步");
await Task.Delay(300, token);
}
}
await LongWorkAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("超过 1 秒,自动取消");
}
CancelAfter(1000) 表示 1 秒后自动发出取消信号。
例子 5:循环中主动检查取消
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(900));
try
{
async Task ProcessItemsAsync(CancellationToken token)
{
for (int i = 1; i <= 10; i++)
{
token.ThrowIfCancellationRequested();
Console.WriteLine($"处理项目 {i}");
await Task.Delay(250, token);
}
}
await ProcessItemsAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("循环处理被取消");
}
长循环里要定期检查取消信号,否则取消不一定能及时生效。
例子 6:把 token 传给下一层方法
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(700));
try
{
async Task ValidateOrderAsync(string orderNo, CancellationToken token)
{
Console.WriteLine("校验订单");
await Task.Delay(300, token);
}
async Task WriteOrderAsync(string orderNo, CancellationToken token)
{
Console.WriteLine("写入订单");
await Task.Delay(800, token);
}
async Task SaveOrderAsync(string orderNo, CancellationToken token)
{
Console.WriteLine($"准备保存订单: {orderNo}");
await ValidateOrderAsync(orderNo, token);
await WriteOrderAsync(orderNo, token);
Console.WriteLine("订单保存完成");
}
await SaveOrderAsync("SO-001", cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("保存订单被取消");
}
SaveOrderAsync 收到 token 后,又继续传给 ValidateOrderAsync 和 WriteOrderAsync,这叫传播取消。
例子 7:异步文件读取支持取消
string path = "big.txt";
await File.WriteAllTextAsync(path, string.Join('\n', Enumerable.Range(1, 1000)));
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(1));
try
{
string text = await File.ReadAllTextAsync(path, cts.Token);
Console.WriteLine($"读取字符数: {text.Length}");
}
catch (OperationCanceledException)
{
Console.WriteLine("文件读取被取消");
}很多异步 IO API 都可以接收 CancellationToken。
例子 8:取消后做清理
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(800));
try
{
async Task ExportReportAsync(CancellationToken token)
{
string tempFile = "report.tmp";
try
{
await File.WriteAllTextAsync(tempFile, "临时内容", token);
Console.WriteLine("开始生成报表");
await Task.Delay(2000, token);
Console.WriteLine("报表生成完成");
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
Console.WriteLine("临时文件已清理");
}
}
}
await ExportReportAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("导出已取消");
}
取消后也要清理临时文件,这类逻辑适合放在 finally。
例子 9:区分取消和普通异常
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
try
{
async Task<string> LoadCustomerAsync(string customerId, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(customerId))
{
throw new ArgumentException("客户编号不能为空");
}
await Task.Delay(1000, token);
return "张三";
}
string result = await LoadCustomerAsync("", cts.Token);
Console.WriteLine(result);
}
catch (OperationCanceledException)
{
Console.WriteLine("读取客户被取消");
}
catch (ArgumentException ex)
{
Console.WriteLine($"参数错误: {ex.Message}");
}
参数错误和取消不是一类问题,所以分开捕获。
例子 10:手动取消加超时取消
using CancellationTokenSource userCts = new CancellationTokenSource();
using CancellationTokenSource timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(userCts.Token, timeoutCts.Token);
userCts.CancelAfter(800);
try
{
async Task WorkAsync(CancellationToken token)
{
for (int i = 1; i <= 10; i++)
{
Console.WriteLine($"工作中: {i}");
await Task.Delay(300, token);
}
}
await WorkAsync(linkedCts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("用户取消或任务超时");
}
CreateLinkedTokenSource 可以把多个取消来源合成一个 token。用户取消和超时取消,任意一个发生都会取消任务。
例子 11:批量任务中取消
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
using SemaphoreSlim semaphore = new SemaphoreSlim(2);
int[] ids = Enumerable.Range(1, 8).ToArray();
Task[] tasks = ids
async Task ProcessWithLimitAsync(int id, SemaphoreSlim gate, CancellationToken token)
{
await gate.WaitAsync(token);
try
{
Console.WriteLine($"开始任务 {id}");
await Task.Delay(700, token);
Console.WriteLine($"完成任务 {id}");
}
finally
{
gate.Release();
}
}
.Select(id => ProcessWithLimitAsync(id, semaphore, cts.Token))
.ToArray();
try
{
await Task.WhenAll(tasks);
}
catch (OperationCanceledException)
{
Console.WriteLine("批量任务已取消");
}
WaitAsync(token) 表示等待并发名额时也能响应取消。
常见错误和修法
| 错误 | 为什么错 | 修法 |
|---|---|---|
| 创建 token 后不传递 | 取消请求不会影响实际任务 | 把 token 传给 Delay、文件读取、等待方法 |
| 把取消当错误处理 | 取消是预期流程 | 单独捕获 OperationCanceledException |
| 超时时间写死太短 | 正常任务也会被取消 | 根据业务设置合理超时 |
| 取消后不恢复状态 | 界面可能一直显示处理中 | 在 finally 中恢复状态 |
| 子方法不接收 token | 取消链路断掉 | 所有耗时子方法都加 CancellationToken 参数 |
小白重复敲写训练
取消训练必须同时练“发出取消”和“任务响应取消”。
训练 1:开始前取消
using var cts = new CancellationTokenSource();
cts.Cancel();
Console.WriteLine(cts.Token.IsCancellationRequested);记住:Cancel() 只是发出信号。
训练 2:超时取消等待
using var cts = new CancellationTokenSource();
cts.CancelAfter(500);
try
{
await Task.Delay(5000, cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("任务被取消");
}改动任务:把超时改成 2000 毫秒。
训练 3:循环主动检查
using var cts = new CancellationTokenSource(700);
try
{
for (int i = 1; i <= 10; i++)
{
cts.Token.ThrowIfCancellationRequested();
Console.WriteLine(i);
await Task.Delay(200);
}
}
catch (OperationCanceledException)
{
Console.WriteLine("循环提前结束");
}运行两次,分别把取消时间改短和改长。
每日小测
做完本页后,用这 5 题检查是否真的掌握。
1. 判断题
本页的目标不是只把代码运行起来,还要能说清楚“为什么这样写”。
答案:对。能运行只是第一步,能解释原理、常用操作和常见错误,才说明本页内容进入了可复用能力。
2. 填空题
本页主题是:取消与超时。今天至少要掌握的 3 个点是:
1. 为什么异步任务不能无限等待
2. `CancellationTokenSource` 和 `CancellationToken` 分别负责什么
3. 手动取消、超时取消、传递取消信号怎么写答案:以上 3 点必须能用自己的代码跑通,不能只停留在阅读。
3. 流程题
遇到本页相关功能时,先按什么顺序处理?
答案:先看完整例子,确认最终效果;再读原理和名词;然后跟着第三部分从空项目敲代码;最后对照作业答案检查。
4. 找错误题
如果本页代码运行失败,第一步应该做什么?
答案:先看终端或 IDE 里的第一条错误,找到文件名和行号;不要同时改很多地方。再回到本页的“常见错误和修法”表格,对照错误类型逐项排查。
5. 改需求题
在本页完整例子跑通后,至少改一个小需求。
可选改法:
- 改一个字段名称。
- 多加一个校验条件。
- 多输出一行结果。
- 把固定数据改成用户输入。
- 把一次处理改成多条数据处理。
答案标准:修改后能重新运行,并能说明这次修改影响了哪一段逻辑。重点检查:为什么异步任务不能无限等待。
上位机专项练习
停止采集和连接超时是上位机必备能力。CancellationToken 让异步任务可以被安全取消。
下面 3 个例子都要亲手敲。先运行原代码,再完成每个例子后面的改动任务。
专项例子 1:两秒后停止采集
class Program
{
static async Task CollectAsync(CancellationToken token)
{
while (true)
{
token.ThrowIfCancellationRequested();
Console.WriteLine("采集一次");
await Task.Delay(500, token);
}
}
static async Task Main()
{
using var cts = new CancellationTokenSource(2000);
try { await CollectAsync(cts.Token); }
catch (OperationCanceledException) { Console.WriteLine("采集已停止"); }
}
}运行结果或界面效果:
采集多次
采集已停止改动任务: 把停止时间改成 1000 毫秒。
专项例子 2:设备连接超时
class Program
{
static async Task ConnectAsync(CancellationToken token)
{
await Task.Delay(3000, token);
}
static async Task Main()
{
using var cts = new CancellationTokenSource(500);
try { await ConnectAsync(cts.Token); }
catch (OperationCanceledException) { Console.WriteLine("连接超时"); }
}
}运行结果或界面效果:
连接超时改动任务: 把连接等待改成 200 毫秒,显示“连接成功”。
专项例子 3:手动发出停止命令
class Program
{
static async Task WorkAsync(CancellationToken token)
{
await Task.Delay(5000, token);
}
static async Task Main()
{
using var cts = new CancellationTokenSource();
Task work = WorkAsync(cts.Token);
cts.Cancel();
try { await work; }
catch (OperationCanceledException) { Console.WriteLine("收到停止命令"); }
}
}运行结果或界面效果:
收到停止命令改动任务: 把 Cancel 暂时注释掉,观察任务正常等待。
第四部分:作业完整答案
这一部分给出当天作业的完整答案。建议先照着敲一遍,再修改超时时间验证。
作业 1:超时取消任务
要求:
- 创建一个 1 秒后取消的
CancellationTokenSource。 - 任务本身模拟需要 3 秒。
- 超时后输出提示,程序不崩溃。
完整答案
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
try
{
Console.WriteLine("开始任务");
await Task.Delay(TimeSpan.FromSeconds(3), cts.Token);
Console.WriteLine("任务完成");
}
catch (OperationCanceledException)
{
Console.WriteLine("任务超时,已取消");
}作业 2:可取消的循环
要求:
- 循环处理 10 个项目。
- 每个项目等待 300 毫秒。
- 1 秒后自动取消。
- 取消后输出提示。
完整答案
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
try
{
async Task ProcessItemsAsync(CancellationToken token)
{
for (int i = 1; i <= 10; i++)
{
token.ThrowIfCancellationRequested();
Console.WriteLine($"处理项目 {i}");
await Task.Delay(300, token);
}
}
await ProcessItemsAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("处理已取消");
}
作业 3:把 token 传到下一层
要求:
- 写
SaveOrderAsync。 - 内部调用
ValidateAsync和WriteAsync。 - 每一层都接收并传递
CancellationToken。
完整答案
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(700));
try
{
async Task ValidateAsync(string orderNo, CancellationToken token)
{
Console.WriteLine($"校验订单: {orderNo}");
await Task.Delay(300, token);
}
async Task WriteAsync(string orderNo, CancellationToken token)
{
Console.WriteLine($"写入订单: {orderNo}");
await Task.Delay(800, token);
}
async Task SaveOrderAsync(string orderNo, CancellationToken token)
{
await ValidateAsync(orderNo, token);
await WriteAsync(orderNo, token);
}
await SaveOrderAsync("SO-001", cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("保存过程被取消");
}
作业 4:取消后清理文件
要求:
- 创建临时文件。
- 模拟长时间导出。
- 超时取消后删除临时文件。
完整答案
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(500));
try
{
async Task ExportAsync(CancellationToken token)
{
string tempFile = "export.tmp";
try
{
await File.WriteAllTextAsync(tempFile, "temporary", token);
await Task.Delay(2000, token);
}
finally
{
if (File.Exists(tempFile))
{
File.Delete(tempFile);
Console.WriteLine("临时文件已删除");
}
}
}
await ExportAsync(cts.Token);
}
catch (OperationCanceledException)
{
Console.WriteLine("导出被取消");
}
作业 5:批量任务支持取消
要求:
- 准备 6 个任务。
- 一次最多处理 2 个。
- 1 秒后取消。
SemaphoreSlim的名额必须释放。
完整答案
using CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(1));
using SemaphoreSlim semaphore = new SemaphoreSlim(2);
int[] ids = Enumerable.Range(1, 6).ToArray();
Task[] tasks = ids
async Task ProcessAsync(int id, SemaphoreSlim gate, CancellationToken token)
{
await gate.WaitAsync(token);
try
{
Console.WriteLine($"开始任务 {id}");
await Task.Delay(700, token);
Console.WriteLine($"完成任务 {id}");
}
finally
{
gate.Release();
Console.WriteLine($"释放名额 {id}");
}
}
.Select(id => ProcessAsync(id, semaphore, cts.Token))
.ToArray();
try
{
await Task.WhenAll(tasks);
}
catch (OperationCanceledException)
{
Console.WriteLine("批量处理已取消");
}
本页最后要记住
CancellationTokenSource负责发出取消信号。CancellationToken负责把信号传给任务。- 取消是协作式的,任务必须检查或传递 token。
- 超时就是自动取消的一种。
- 取消通常会抛
OperationCanceledException。 - 取消和普通异常要分开处理。
- 清理逻辑放在
finally更稳。