Skip to Content
Week 04Day 4 - 取消与超时

Day 4 - 取消与超时

建议用时:240-300 分钟

你将学会什么

  • 为什么异步任务不能无限等待
  • CancellationTokenSourceCancellationToken 分别负责什么
  • 手动取消、超时取消、传递取消信号怎么写
  • 为什么取消是“协作”,不是强行杀死任务
  • 如何在循环、文件 IO、批量任务里响应取消
  • 取消和普通异常应该怎么区分处理

取消不是失败,也不是崩溃。取消表示外部告诉任务:“不用继续了,请你在合适的位置停下来。”

本页固定顺序

  1. 先学第一部分:弄懂今天最小、最重要的知识,并运行短例子。
  2. 再学第二部分:把刚学的知识组合成一个完整例子。
  3. 然后做第三部分:自己跟着敲,再完成重复训练和每日小测。
  4. 最后做第四部分:先独立完成作业,再用完整答案检查。

学习衔接

上一页学习的是“并发控制”,今天继续学习“取消与超时”。先使用上一页已经会的写法,再只增加今天这个新知识点;如果前置内容还不能独立敲出,先回上一页复习,不要硬跳。

今天的最低通过线

第一次学习不要求背完整页。完成下面 3 项,就可以继续:

  • 能用自己的话说明“取消与超时”解决什么问题。
  • 把第一部分的短例子亲手敲完,并确认每个例子都能运行。
  • 不看完整答案完成第三部分至少前 3 个例子,再主动改一个值观察结果。

第一部分:先学原理和最小知识

这一部分从最小知识开始。先读解释,再把紧跟着的短例子敲一遍。取消机制的核心不是语法,而是理解“谁发信号、谁接信号、谁决定停下来”。

1. 为什么需要取消

异步任务可能等很久:

  1. 网络一直没响应。
  2. 文件很大,读取很慢。
  3. 用户关闭页面或窗口。
  4. 用户点击取消按钮。
  5. 后台批量任务已经不需要继续。

如果没有取消机制,程序就只能一直等。

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. 取消后要做清理

任务取消后可能要:

  1. 恢复按钮状态。
  2. 删除临时文件。
  3. 释放并发名额。
  4. 写一条日志。
  5. 更新任务状态。

清理逻辑通常放在 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("报表下载已取消或超时"); }

这个程序里最重要的是三件事:

  1. CancellationTokenSource 负责发出取消信号。
  2. CancellationToken 负责把取消信号传给任务。
  3. ThrowIfCancellationRequested()Task.Delay(..., token) 负责响应取消。

第三部分:跟着敲代码

从这里开始动手。每个例子都是完整代码,可以直接放进 Program.cs 运行。

动手前先做这 3 件事

  1. 打开一个控制台项目。
  2. 每次只保留一个例子的代码,运行通过后再换下一个。
  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 后,又继续传给 ValidateOrderAsyncWriteOrderAsync,这叫传播取消。

例子 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. 创建一个 1 秒后取消的 CancellationTokenSource
  2. 任务本身模拟需要 3 秒。
  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:可取消的循环

要求:

  1. 循环处理 10 个项目。
  2. 每个项目等待 300 毫秒。
  3. 1 秒后自动取消。
  4. 取消后输出提示。

完整答案

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 传到下一层

要求:

  1. SaveOrderAsync
  2. 内部调用 ValidateAsyncWriteAsync
  3. 每一层都接收并传递 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:取消后清理文件

要求:

  1. 创建临时文件。
  2. 模拟长时间导出。
  3. 超时取消后删除临时文件。

完整答案

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:批量任务支持取消

要求:

  1. 准备 6 个任务。
  2. 一次最多处理 2 个。
  3. 1 秒后取消。
  4. 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("批量处理已取消"); }

本页最后要记住

  1. CancellationTokenSource 负责发出取消信号。
  2. CancellationToken 负责把信号传给任务。
  3. 取消是协作式的,任务必须检查或传递 token。
  4. 超时就是自动取消的一种。
  5. 取消通常会抛 OperationCanceledException
  6. 取消和普通异常要分开处理。
  7. 清理逻辑放在 finally 更稳。