在C#开发中,文件读写往往涉及磁盘IO,耗时可能从几十毫秒到数分钟不等。当用户在界面上点击停止按钮,或者程序需要优雅退出时,如果文件任务无法及时中断,就会造成资源浪费甚至界面无响应。CancellationToken提供了一套协作式取消模型,但很多开发者在文件异步操作中只是随意传参,导致取消根本不生效。

一、CancellationToken的基本原理
CancellationToken本身并不强制中止任何代码,它只是一个轻量的状态标志。真正决定是否停止工作的是被调用的异步方法,以及我们自己在循环或业务逻辑里对令牌的检查。.NET基础类库中的Stream、HttpClient等方法大多提供了接收CancellationToken的重载,当令牌被取消时,这些方法会从内部抛出OperationCanceledException。
如果你调用的是不带令牌的ReadAsync,那么即使外部取消了TokenSource,底层方法也不会感知到,任务会一直执行到自然结束。因此,正确取消文件读写的第一步,就是确保所有IO调用都使用带CancellationToken参数的重载版本,而不是事后用Task.Wait或者线程中断去补救。
二、创建与传递令牌
通常使用CancellationTokenSource(简称CTS)来生成令牌。CTS可以被取消一次,之后由它创建的Token会进入已取消状态。在UI程序中,可以把CTS保存在字段里,按钮点击时调用Cancel,后台任务就能收到信号。
下面的代码展示了如何初始化CTS,并把Token传给文件读取方法:
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
class FileReader
{
private CancellationTokenSource _cts = new CancellationTokenSource();
public void Stop()
{
// 用户点击停止时调用
_cts.Cancel();
}
public async Task ReadFileAsync(string path)
{
// 将令牌传递给OpenRead和ReadAsync
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous);
byte[] buffer = new byte[8192];
int read;
// 在循环里传入_token
while ((read = await fs.ReadAsync(buffer, 0, buffer.Length, _cts.Token)) > 0)
{
// 处理数据
}
}
}
注意,上面代码中ReadAsync明确接收了_cts.Token。如果此处漏掉Token参数,循环就不会响应取消。另外,FileStream的构造虽然不直接收Token,但设置FileOptions.Asynchronous能确保IO走真正的异步路径,避免线程池线程被同步阻塞。
三、取消文件复制的完整示例
文件复制是典型的长耗时操作。使用Stream.CopyToAsync可以一行代码完成拷贝,但它同样需要Token才能取消。下面示例演示带进度和取消能力的复制方法:
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
static class FileCopier
{
public static async Task CopyWithCancelAsync(string src, string dest, CancellationToken token)
{
using var source = new FileStream(src, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, FileOptions.Asynchronous);
using var target = new FileStream(dest, FileMode.Create, FileAccess.Write, FileShare.None, 8192, FileOptions.Asynchronous);
try
{
// 传入token,复制过程可被取消
await source.CopyToAsync(target, 8192, token);
await target.FlushAsync(token);
}
catch (OperationCanceledException)
{
// 捕获取消异常,删除半成品文件
if (File.Exists(dest))
{
File.Delete(dest);
}
throw; // 向上层表明已取消
}
}
}
在这个例子里,CopyToAsync内部会按块读取并写入,每次写入前检查Token。一旦Cancel被调用,方法立刻抛出OperationCanceledException。我们在catch中删除未写完的目标文件,保证磁盘状态干净。如果不捕获而直接让异常冒泡,调用方也需要知道这是取消而非真正错误。
很多初学者会误以为在catch外判断token.IsCancellationRequested就够了,其实CopyToAsync在取消时一定会抛异常,靠返回值无法感知,必须处理异常。这样才能释放FileStream占用的句柄。
四、组合超时与手动取消
有时我们希望文件操作既能被用户取消,又能在超过一定时间后自动取消。可以使用CreateLinkedTokenSource把手动CTS和超时CTS连接起来。
using System;
using System.Threading;
using System.Threading.Tasks;
static class TimeoutDemo
{
public static async Task RunWithTimeout(string path)
{
using var manualCts = new CancellationTokenSource();
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
using var linked = CancellationTokenSource.CreateLinkedTokenSource(manualCts.Token, timeoutCts.Token);
try
{
await File.ReadAllTextAsync(path, linked.Token);
}
catch (OperationCanceledException ex)
{
if (timeoutCts.IsCancellationRequested)
{
Console.WriteLine("读取超时取消");
}
else
{
Console.WriteLine("用户手动取消");
}
}
}
}
通过链接令牌,任何一个源取消都会导致linked.Token取消。在异常处理中检查具体哪个源触发,可以给出不同提示。这种方式比在循环里自己算时间更可靠,也不会漏掉框架内部IO的取消点。
需要提醒的是,链接令牌本身也要Dispose,否则会造成CTS对象堆积。在using块里声明是最省心的做法,离开作用域自动释放。
五、常见误区与注意事项
第一个误区是认为调用Cancel之后任务会马上停止。实际上只有代码运行到检查Token的地方才会退出,如果某段同步计算很重且没有检查点,依然会卡住。文件读写因为底层API支持Token,通常反应很快,但混合了CPU密集处理时就要自己加Token检查。
第二个误区是在finally里再次调用可能阻塞的IO却不传Token,导致取消后关闭流也卡住。建议关闭Stream的操作不要设置很长超时,或者确保取消后状态一致即可。此外,不要对同一个CTS多次Cancel,它设计上只生效一次,重复调用无害但也没意义。
| 做法 | 是否支持取消 | 说明 |
|---|---|---|
| ReadAsync(buffer)无Token | 否 | 忽略外部取消信号 |
| ReadAsync(buffer, token) | 是 | 令牌触发即抛异常 |
| Task.Run里不检查Token | 否 | 后台线程跑完才返回 |
| CopyToAsync(stream, token) | 是 | 块级复制可中断 |
掌握上述写法后,你的C#文件异步操作就能做到用户随时终止、资源不泄露、异常可区分。把CancellationToken当作贯穿整个调用链的参数,而不是临时补救手段,是写出健壮IO代码的关键。
C#CancellationToken文件异步操作修改时间:2026-08-02 21:45:41