取消的概念
通常我们最熟悉的,是一个方法的中止。中止是完全的。一个方法中止了,则这个方法不再往下执行,方法中前面已经完成的部分会被抛弃,并返回一个设定的结果。
取消则不同。
通常,取消是由其它代码发出的命令,也就是说,是由一些代码去请求取消,另一部分代码的响应取消。而且,实际发生的情况,是请求代码只是通知响应代码,希望它能停止执行;响应代码会按照自己设定的方式对取消请求做出响应,有可能立即停止任务,也有可能继续运行下去,直到一个可以停止的点,甚至可能完全忽略这个取消请求。
概念清楚了,怎么做?
取消令牌
既然是一方请求,另一方响应,那对于响应代码来说,重要的是能够知道并响应取消请求。
在 Dotnet 里,给出了一个东西,叫取消令牌 ( Cancellation Tokens )。这个令牌,就是请求取消的载体。
请求代码发起取消时,实际是发起了一个对「取消令牌」的取消操作,然后,响应代码将对这个被取消的令牌做出正确反应。
如果看到这儿有点混乱的话,看一下示例代码:
async Task SomethingAsync(int data, CancellationToken cancellationToken)
{
var result = await FirstStepAsync(data, cancellationToken);
await SecondStepAsync(intermediateValue, cancellationToken);
}
响应代码基本都是这个样子。这里面,CancellationToken 就是上面说的取消令牌。
CancellationToken 可以在任何地方被设置为取消:用户按下取消按钮,或客户端断开连接,超时,等等。重要的是,当它被设置为取消时,就表示响应代码需要处理取消了。
注意:一个 CancellationToken 只能被取消一次。一旦它被取消,就会永远保持取消状态。
带有取消令牌的方法定义
上面的示例,就是一个典型的带有取消令牌的方法定义。
按照微软的习惯,带有 CancellationToken 的方法有以下约定:
- CancellationToken 通常是最后一个参数
- 方法通常会提供一个重载,或默认参数值,以便调用者可以不提供取消令牌而直接调用
当然,这是一个非强制的约定。如果你不介意别人看着别扭,可以不管这个约定。
看几个例子:
Task SomethingAsync(int data) => SomethingAsync(data, CancellationToken.None);
async Task SomethingAsync(int data, CancellationToken cancellationToken)
{
...
}
async Task SomethingAsync(int data, CancellationToken cancellationToken = default)
{
...
}
在这里,CancellationToken 代表任何类型或任何原因的取消。
通过 CancellationToken 参数,方法声明了自己可以响应取消。而实际上,这只是个声明。代码中,CancellationToken 可能会被忽略。因此,有这个声明仅仅表示方法可能支持取消,而不是一定支持。
方法对取消的响应
上面说到了,响应代码可以响应取消,也可以不取消。
而即使响应代码真的去响应取消,通常也会有不同的情况。
通常来说,如果取消请求到达时,响应方法实际取消了一些工作,会抛出 OperationCanceledException 来通知调用程序;而如果取消被忽略,或者取消请求来的太晚而任务已经完成,那响应方法会正常返回,而且不抛出 OperationCanceledException 异常。这个在微软的基础类库(BCL)中,体现得很明显。
大多数情况下,异常会被逐层传出。再看一下上面的例子:
async Task SomethingAsync(int data, CancellationToken cancellationToken)
{
var result = await FirstStepAsync(data, cancellationToken);
await SecondStepAsync(intermediateValue, cancellationToken);
}
如果 FirstStepAsync 或 SecondStepAsync 抛出 OperationCanceledException,那这个异常也会从 SomethingAsync 中传出给调用者。
这里要强调一下:看过很多代码,在请求取消时会不抛出异常而直接返回。不要这样做。调用者不知道这个取消是被接受,还是被忽略,会出大问题的。
一个常见的错误用法
在代码 Review 时,见过好几次这样的情况:
async Task SomethingAsync(CancellationToken cancellationToken)
{
var test = await Task.Run(() =>
{
...
}, cancellationToken);
...
}
// 注意,这个例子的写法是错的。
这个有必要专门拿出来说一下。
很多人把委托和 CancellationToken 传递给 Task,期望在令牌取消时取消委托。注意,这个理解是错的。
Task.Run 是对线程池的委托调度,是一个立即完成的瞬时动作。CancellationToken 在这儿的作用是取消调度这个动作,而这个动作是立即完成的,换句说说,一旦走到这一行,调度操作会立即完成,这个取消令牌也就没有用了,会被忽略。
所以,这种情况不需要用 CancellationToken,要写成下面的方式:
async Task SomethingAsync(CancellationToken cancellationToken)
{
var test = await Task.Run(( cancellationToken ) =>
{
...
});
...
}
写成这样,才是正确的表达,表达委托本身需要响应令牌。
这是一个容易搞错的知识点,记一下。