Dotnet線程取消的深度進階
取消的概念
通常我們最熟悉的,是一個方法的中止。中止是完全的。一個方法中止了,則這個方法不再往下執行,方法中前面已經完成的部分會被拋棄,并返回一個設定的結果。
取消則不同。
通常,取消是由其它代碼發出的命令,也就是說,是由一些代碼去請求取消,另一部分代碼的響應取消。而且,實際發生的情況,是請求代碼只是通知響應代碼,希望它能停止執行;響應代碼會按照自己設定的方式對取消請求做出響應,有可能立即停止任務,也有可能繼續運行下去,直到一個可以停止的點,甚至可能完全忽略這個取消請求。
概念清楚了,怎么做?
取消令牌
既然是一方請求,另一方響應,那對于響應代碼來說,重要的是能夠知道并響應取消請求。
在 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 ) =>
{
...
});
...
}
寫成這樣,才是正確的表達,表達委托本身需要響應令牌。
這是一個容易搞錯的知識點,記一下。