Taskのキャンセルのしかた

もくじ
https://tera1707.com/entry/2022/02/06/144447

やりたいこと

タスクの中で行っている処理を、キャンセルしたい。

また、以前の記事で、タスクの状態遷移を調べたときに、 下表の中のCanceled(6)とFaulted(7)が、どういう状態なのかがよくわからなかったのだが、その辺がタスクをキャンセルすることに絡んでいるっぽい。

どういうことか調べたい。

やったこと

下記のドキュメントをお手本に、ともかく「タスクのキャンセル」をやってみた。

https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/task-cancellation

概要

上記ドキュメントによると、、、、


Taskクラスは、キャンセレーショントークンを使ったタスクのキャンセルをサポートしている。

The System.Threading.Tasks.Task and System.Threading.Tasks.Task<TResult> classes support cancellation by using cancellation tokens

CancellationTokenSource.Cancel()メソッドを使ってキャンセルを通知して、それを受けてタイムリーに処理を終了させられるデリゲート(自分で作った処理)が必要。

A successful cancellation involves the requesting code calling the CancellationTokenSource.Cancel method and the user delegate terminating the operation in a timely manner.

Taskをキャンセルするには、

  • 自作デリゲートをreturnで終了させる。(←大体はこれでOK)
  • OperationCanceledExceptionをthrowして、それをキャンセル要求に使ったtokenに含ませる

という方法がある。ThrowIfCancellationRequested()を使ってthrowするのがおすすめ。

You can terminate the operation by using one of these options:

By returning from the delegate. In many scenarios, this option is sufficient. However, a task instance that's canceled in this way transitions to the TaskStatus.RanToCompletion state, not to the TaskStatus.Canceled state.

By throwing an OperationCanceledException and passing it the token on which cancellation was requested. The preferred way to perform is to use the ThrowIfCancellationRequested method. A task that's canceled in this way transitions to the Canceled state, which the calling code can use to verify that the task responded to its cancellation request.

とのこと。

一旦の結論

ここまでを自分の言葉とコードでまとめると、

  • 時間のかかるタスクのキャンセルは、CancellationTokenSource を使っておこなう。
  • キャンセルするときは、キャンセルしたい処理の外からCancellationTokenSource.Cancel()メソッドを呼ぶ。
  • キャンセルしたい処理の中で、ct.IsCancellationRequestedを見ると、外からキャンセルされたことを知れる。
    • キャンセルされたら、単に処理をreturnさせるだけで大体はOK。
      • ただしその場合は、TaskのステータスはCancelにはならない。成功(RanToCompleteion)扱いになる。
    • キャンセルされたときにOperationCanceledExceptionをthrowしてやるとキャンセル扱いになり、TaskのステータスがCancelになる。
      • OperationCanceledExceptionをthrowする際にはThrowIfCancellationRequested()を使うのがおすすめ(ct.IsCancellationRequestedがtrueならOperationCanceledExceptionをthrowする、をやってくれる)。
    • それ以外の例外をthrowしてTaskが終了すると、キャンセルではなくFault扱いになる。

下記の「★キャンセルされたことを知って処理を抜ける部分」のところのようにcts.Token.ThrowIfCancellationRequested()を書くと、 cts.Cancel()が呼ばれてキャンセルされていたら、そこでOperationCanceledExceptionを投げてくれる。(Cancelされてないとなにもしない)

TaskがOperationCanceledExceptionで終了していると、その後TaskのStatusを見ると、「Canceled」になっている。

using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;

namespace CancellationTokenJikken
{
    public partial class MainWindow : Window
    {
        HashSet<Task> taskList = new HashSet<Task>();
        Task? t;
        CancellationTokenSource cts = new CancellationTokenSource();

        public MainWindow() => InitializeComponent();

        private async void Button_Click(object sender, RoutedEventArgs e)
        {
            t = Task.Run(async () =>
            {
                Thread.Sleep(5000);
                cts.Token.ThrowIfCancellationRequested(); //★キャンセルされたことを知って処理を抜ける部分
            }, cts.Token);
        }

        private void Button_Click_1(object sender, RoutedEventArgs e)
        {
            Debug.WriteLine($"{t.Status} ");
        }

        private void Button_Click_2(object sender, RoutedEventArgs e)
        {
            cts.Cancel();//★キャンセルを指示する部分
        }
    }
}

上記のTask.Runの部分を、cts.Token.ThrowIfCancellationRequested();を使わずに自分でOperationCanceledExceptionを投げるようにしても、Cancel扱いになってくれる。(ともかくOperationCanceledExceptionがthrowされれば、タスクはキャンセル扱いになる様子)
OperationCanceledExceptionの派生のTaskCanceledExceptionでも同じ。

t = Task.Run(async () =>
{
    Thread.Sleep(5000);
    throw new OperationCanceledException();
}, cts.Token);

また、タスクがCancelされたのか、正常終了したのかをあとで知る必要があるなどでなければ、 特に例外を投げずに、そとからキャンセルされてたらreturnする、でOK。(成功扱いでRanToCompletionになる)
(MSの言う通り、普通はこれで十分だと思う)
※十分だと思ったけど、そうじゃないかも?下の追記参照

t = Task.Run(async () =>
{
    Thread.Sleep(5000);
    if (cts.IsCancellationRequested)
        return;
}, cts.Token);

OperationCanceledException()以外の例外が起きると、キャンセル扱いではなく、Fault扱いになる。
下記の場合は、例外発生後にTaskのステータスを見ると、Faultになっている。

t = Task.Run(async () =>
{
    Thread.Sleep(5000);
    //cts.Token.ThrowIfCancellationRequested();
    throw new InvalidOperationException();//→Faultになる
}, cts.Token);

※タスクの例外の処理の仕方は、以前調べた下記を参照。

https://qiita.com/tera1707/items/d5a3bc12ffa5f80069a1

(追記) キャンセル時に普通にreturnしてRanToCompletionで終わらせるのは悪い設計

.NETのクラスライブラリ設計 の本の「9.2 非同期パターン P304」によると、上のように、キャンセルされたのに普通にreturnして成功したことにするのはバグの温床になる悪い設計、らしい。確かにそうかも、と思う。(でも、MSのドキュメントに「通常は普通にreturnするでいいよ」的なこと書いてるけど...)

OperationCanceledException と TaskCanceledException

Task.Delay(5000, cts.Token)と書いて、ctsをキャンセルしてやると、Task.DelayはOperationCanceledExceptionではなくTaskCanceledExceptionを投げてくる。

TaskCanceledExceptionは、OperationCanceledExceptionを継承した例外クラスのようで、下記のように書けば、両方の例外を受けることができる。

try
{
    await Task.Run(async () =>
    {
        await Task.Delay(5000, cts.Token);
    }, cts.Token);
}
catch (OperationCanceledException tce)
{
    // TaskCanceledException も OperationCanceledException も両方ここにくる
    Debug.WriteLine(tce);
}

TaskCanceledExceptionは、OperationCanceledExceptionの違いは、
TaskCanceledExceptionは、タスクのコンテキスト情報を含んでいるらしい。ConfigureAwaitでいろいろやるアレかな...(詳細は調べきれてない)

Task.Run()の第二引数にのせるCancellationTokenはどういう意味か?

Task.Run()の第二引数にCancellationTokenを載せれるが、何のために乗せるのか?がわからなかった。 MSDocsには、

「Taskに渡した処理(デリゲート)の中でOperationCanceledExceptionをthrowしたトークンと、Task.Runに渡されたトークンを比較して、一緒だったらタスクのStatusをCancelにして、トークンが一致しなかったら通常の例外扱いにしてStatusをFaultにする」

と書かれてるが、試した限り、下記のように別々のトークンを渡して、トークンが一致しないであろう状態を作っても、Cancelになっていた。

t = Task.Run(async () =>
{
    Thread.Sleep(5000);
    cts.Token.ThrowIfCancellationRequested();
}, ctsdummy.Token);

下記に、同じ疑問を持った方が調べた結果を載せてくださっていた。

https://ricka.co.jp/?p=16922

なるほど、Task.Runで作ったタスクだと、tokenが一致するとかしないとかは関係なさそう...

...いろいろ調べた&やってみた結果、Task.Runの第二引数に乗せるCancellationTokenは、特になんの役にも立ってないような気がする。

一旦は、第二引数には何も載せず、第一引数に渡すデリゲートで、

  • 例外なく終了させると成功扱い(RanToCompletion)
  • OperationCanceledExceptionをthrowさせて終了させるとキャンセル扱い(Cancelled)
  • それ以外の例外で終了させると失敗扱い(Fault)

になる、とだけ理解してればOKな気がした。

その他、ぼやっと気づいたこと

Task.Runと、Task t をt.Startするのは、中のスレッドの動きが違う? →t.Start()は、上のページでいうところの「Task.Factory.StartNew()」に近そうな気がする。

参考

Task cancellation(MS公式)

https://learn.microsoft.com/en-us/dotnet/standard/parallel-programming/task-cancellation

タスクの状態一覧(MS公式)

https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskstatus?view=net-7.0

Task.Runの第二引数に乗せるCancellationTokenの意味を調べておられるページ

https://ricka.co.jp/?p=16922

Taskの中で例外が起きた時のキャッチの仕方

https://qiita.com/tera1707/items/d5a3bc12ffa5f80069a1

.NETのクラスライブラリ設計
この本の「9.2 非同期パターン」がめちゃ参考になった。