VB のたまご

作成日: 2017/10/01, 更新日: 2017/10/01


非同期処理その2(2017年10月版)


タスクの並列化(Task)

  •  以降の処理で登場するいくつかのクラスは、それぞれ以下の名前空間を使いますので、あらかじめインポートしておきます。 都度、各メソッドには書きませんので注意してください。
  • Imports System.Threading ' Thread.Sleep()
    Imports System.Threading.Tasks ' Task クラス
    Imports System.Collections.Concurrent ' ConcurrentXxx クラス(コレクション、ディクショナリ...)
    

  •  タスクってなんでしょう?よく仕事現場で使っていますよね。今日はこのタスクとあのタスクをこなさなきゃとか、 抱えているこのタスクがなかなか思うように進まなくて・・・とか。タスクを無理やり日本語に変えるとすると、「1つ分の作業」みたいな意味合いでしょう。 大きなくくり(目的・ゴール)があって、そこに到達するために、複数の作業タスクに分解して進めていく、と言う風に使います(よね?)。

  •  このような概念を .NET Framework に持ち込んだものが Task クラスを使った非同期処理です。
  • Public Sub Test9()
    
        ' 「文字列を出力する」という作業
        Task.Run(Sub() Console.WriteLine("aaa"))
    
        ' 「足し算する」という作業
        Task.Run(Sub()
                     Dim i1 = 1
                     Dim i2 = 1
                     Dim i3 = i1 + i2
                     Console.WriteLine($"{i1}+{i2}={i3}")
                 End Sub)
    
        ' 各作業は【他の人がやってくれる】ので、終わる(と思われる十分な時間)まで待機
        Thread.Sleep(100)
        Console.WriteLine("OK")
    
        ' aaa
        ' 1+1=2
        ' OK
    
    End Sub
    

  •  タスクは、【あるまとまりの処理】を【1つのタスク】とみなして、他の人に実行してもらう(自分も担当できる)という機能です。 メソッド呼び出しでもいいし、直接処理を書いてもいいし、まさに指定の作業をこなすというタスクのようなイメージです。

  •  それでは、使い方を見ていきます。
  • Public Sub Test10()
    
        ' タスクの準備
        Dim task1 = New Task(AddressOf Test10Core)
        ' タスクの実行
        task1.Start()
    
        ' タスクの準備と実行
        Dim task2 = Task.Factory.StartNew(Sub() Console.WriteLine("bbb"))
        Dim task3 = Task.Run(Sub() Console.WriteLine("ccc"))
    
        ' タスク完了の待機
        ' Parallel クラスと違って、非同期処理のまま終わっていくので、同期処理側で完了を待機する
        ' 個別の待機、複数の待機
        Console.WriteLine("OK1")
        task1.Wait()
        Task.WaitAll(task2, task3)
    
        ' タスクを実行しっぱなし
        ' 実行後、処理が完了したのかどうかは分からない
        ' 勝手に完了して終わり、報告無しは不安(頼んだ処理が、成功したのか失敗したのか)
        Task.Factory.StartNew(Sub() Console.WriteLine("ddd"))
        Task.Run(Sub() Console.WriteLine("eee"))
    
        Console.WriteLine("OK2")
        Thread.Sleep(100)
    
        ' OK1
        ' aaa
        ' ccc
        ' bbb
        ' OK2
        ' ddd
        ' eee
    
    End Sub
    
    Private Sub Test10Core()
        Console.WriteLine("aaa")
    End Sub
    

  •  タスクは準備と実行を分けたり、準備と実行を同時におこなうことができます。 タスクの準備と実行するには、Task.Factory.StartNew() と Task.Run() に分かれますが、複雑なオプションで実行したい場合は、Task.Factory.StartNew() を使います。

  •  Task.Factory.StartNew() は、.NET Framework 4.0 から、Task.Run() は .NET Framework 4.5 から登場しました。 どちらも非同期処理を実行するための命令だと考えると、同じ機能の命令が2つあってどちらを使えばいいのか混乱します。

  •  もし、Task.Run() が Task.Factory.StartNew() の後継だとすれば、Task.Factory.StartNew() は Obsolete 属性を付けて非推奨にするべきですよね。 ところが、.NET Framework 4.5 でも Task.Factory.StartNew() は非推奨にはなっていません。 このことから、両者の関係は同一扱いではなく、おそらく Task.Run() は、より非同期処理を簡易的に実行できるようにしたヘルパーメソッドの位置づけと思われます。 よって、Task.Run() ではできないことがあった時に、代わりに Task.Factory.StartNew() を使う運用になるのかと見ています。

  •  続いて、複数のタスクのうち、1つだけ完了するまで待機する場合です。
  • Public Sub Test11()
    
        ' 実行中のタスクのうち、どれか1つだけ完了すればいい場合
        Dim task1 = Task.Run(Sub() Thread.Sleep(300))
        Dim task2 = Task.Run(Sub() Thread.Sleep(200))
        Dim task3 = Task.Run(Sub() Thread.Sleep(100))
    
        Dim completedIndex = Task.WaitAny(task1, task2, task3)
    
        Select Case completedIndex
            Case 0
                Console.WriteLine("task1 が完了しました")
            Case 1
                Console.WriteLine("task2 が完了しました")
            Case 2
                Console.WriteLine("task3 が完了しました")
    
        End Select
    
        ' task3 が完了しました
    
    End Sub
    

  •  このような場合には、Task.WaitAny() を使います。 どれか1つのタスクが完了した際、戻り値として、どのタスクが完了したかを判断できるように、ジェネリックデリゲートコレクションのインデックスを返します。

  •  続いては、タスクの処理結果を受け取るサンプルです。
  • ' タスクの処理結果を受け取る
    Public Sub Test12()
    
        ' 結果を取得したい場合は、Function ラムダ式を使う
        Dim task1 As Task(Of Integer) = Task.Run(Function() Enumerable.Range(1, 10).Sum())
    
        ' 結果は Result プロパティから取得できる
        ' (Result プロパティ内で Wait メソッドを呼び出しているので、そのまま Result プロパティを見るだけでよい
        ' 明示的に Wait メソッドを呼び出さなくていい)
        Console.WriteLine($"答えは、{task1.Result}")
    
        ' 答えは、55
    
    End Sub
    

  •  処理結果を受け取るためには、Function のラムダ式を使ってタスクを実行します。 戻り値は Result プロパティを参照することで取得でき、Result プロパティ内で Wait メソッドを呼び出しているので、明示的に Wait メソッドを呼び出さなくても戻り値を取得することができます。

  •  Task では、戻り値がある/無いに関わらず、Task 型という戻り値が返ってきます。
  • ' Task 型と Task(Of T) 型について
    Public Async Sub Test13()
    
        ' 戻り値無しのタスク処理 Sub() は、戻り値 Task を返します。
        ' これは、タスク処理内の戻り値は無いので気にしなくてもいいのですが、
        ' それとは別に、実行したタスクが完了したのか失敗したのか、タスク自体の状態を把握するために戻り値が必要になるためです。
        Dim task1 As Task = Task.Run(Sub() Console.WriteLine("aaa"))
        ' そのため、Task 側だと Result プロパティがありません。
        ' task1.Result
    
        ' 戻り値ありのタスク処理 Function() は、戻り値 Task(Of T) を返します。
        ' 処理本体自体の戻り値 T 型と、タスク自体の戻り値 Task 型ということです。
        Dim task2 As Task(Of Integer) = Task.Run(Function() 1 + 2)
        Dim result As Integer = task2.Result
    
        ' タスクの戻り値に Await を付けると、Wait() の効果+包んだタスクをはがして返してくれます。
        Dim task3 As Integer = Await Task.Run(Function() 1 + 2)
    
    End Sub
    

  •  戻り値が無い場合でも Task 型の戻り値が返ってくるのは、タスク側の都合、依頼に対するホウレンソウをするためです。 処理の結果とは別に、タスク自体の処理が失敗したのか完了したのかを、依頼者は知る権利があります。 Task クラスには、IsCompleted, IsCanceled, IsFaulted プロパティなどの処理結果を確認できるものがありますので、これを確認しながら後続処理をすることができます。

  •  続いては、順番通りに動かす非同期処理の方法です。
  • Public Sub Test14()
    
        Task.Run(Sub() Console.WriteLine("aaa")).
            ContinueWith(Sub(result) Console.WriteLine("bbb")).
            ContinueWith(Sub(result) Console.WriteLine("ccc")).
            ContinueWith(Sub(result) Console.WriteLine("ddd")).
            Wait()
    
        Dim task2 = Task.Run(Sub() Console.WriteLine("eee"))
        task2.ContinueWith(Sub(result) Console.WriteLine("fff"))
        task2.Wait()
    
        ' aaa
        ' bbb
        ' ccc
        ' ddd
        ' eee
        ' fff
    
    End Sub
    

  •  ある非同期処理Aが終わった後で動かしたい非同期処理Bがあったとすると、Aを終えた後、ContinueWith メソッドを使うことで、非同期処理のまま処理を継続することができます。 ContinueWith メソッドはいくらでもつなげることができ、任意の順序を保証した非同期を実行することができます。

  •  続いて、条件付きの継続処理の方法です。
  • Public Sub Test15()
    
        ' 条件に合う場合だけ継続処理する
    
        ' タスクが成功した場合の継続処理
        Dim task1 = Task.Run(
            Function()
                Return 2
            End Function)
    
        task1.ContinueWith(
            Sub(ret)
                Console.WriteLine($"継続処理: 答えは、{ret.Result}")
            End Sub,
            TaskContinuationOptions.OnlyOnRanToCompletion) ' 完了時のみ継続を受け付けるオプション
    
    
    
        ' タスクが失敗した(例外エラーが発生した)場合の継続処理
        Dim task2 = Task.Run(
            Sub()
                Throw New ArgumentNullException("引数が Nothing です")
            End Sub)
    
        ' ※ Try-Catch で囲わなくても大丈夫
        task2.ContinueWith(
            Sub(ret)
                Console.WriteLine($"継続処理: {ret.Exception.Message}")
            End Sub,
            TaskContinuationOptions.OnlyOnFaulted) ' 失敗時のみ継続を受け付けるオプション
    
    
    
        ' タスクをキャンセルした場合の継続処理
        ' タスクを開始する前にキャンセル状態にしてしまう(のがサンプルとしては簡単)
        Dim source = New CancellationTokenSource
        source.Cancel()
    
        Dim task3 = Task.Run(
            Sub()
                source.Token.ThrowIfCancellationRequested()
            End Sub,
            source.Token)
    
        ' ※ Try-Catch で囲わなくても大丈夫
        task3.ContinueWith(
            Sub(ret)
                Console.WriteLine($"継続処理: IsCanceled = {ret.IsCanceled}")
            End Sub,
            TaskContinuationOptions.OnlyOnCanceled) ' キャンセル時のみ継続を受け付けるオプション
    
    
    
        'task1.Wait()
        'task2.Wait() 待機するとシステムダウン
        'task3.Wait() 待機するとシステムダウン
        Thread.Sleep(1000)
    
        ' 継続処理: IsCanceled = True
        ' 継続処理: 答えは、2
        ' 継続処理: 1 つ以上のエラーが発生しました。
    
    End Sub
    

  •  完了した後でおこなう継続処理、失敗した時におこなう継続処理、キャンセルした時におこなう継続処理と、実際の運用では繊細な処理を要求されると思いますが、 このような場合でも、TaskContinuationOptions 列挙体を指定することで専用の継続処理をおこなうことができます。

  •  続いて、タスクの中で動かすタスク処理、入れ子になった子タスクの処理です。
  • Public Sub Test16()
    
        ' インデントがひどいので、適度に改行します。見づらいです。
    
        Console.WriteLine("main: start")
        Dim parent = Task.Run(
            Sub()
    
                Console.WriteLine("parent: start")
                Dim child = Task.Run(
                Sub()
    
                    Console.WriteLine("child: start")
                    Console.WriteLine("child: end")
    
                End Sub)
    
                child.Wait()
                Console.WriteLine("parent: start")
    
            End Sub)
    
        parent.Wait()
        Console.WriteLine("main: end")
    
        ' main: start
        ' parent: start
        ' child: start
        ' child: end
        ' parent: start
        ' main: end
    
        'child.Wait() をコメントアウトすると、順序がバラバラになってしまう
        ' main: start
        ' parent: start
        ' parent: end
        ' child: start
        ' child: end
        ' main: end
    
    End Sub
    

  •  タスク処理内で動かす子タスクの扱いは、今までの扱いと同様です。子タスクを親タスク内で完了したい場合は、明示的に Wait メソッドを呼び出します。 上記の説明上親子関係で説明しましたが、特に親子関係を築くような設定はしていないですね。

  •  以下は、明示的な親子関係を築いたサンプルです。
  • Public Sub Test17()
    
        ' インデントがひどいので、適度に改行します。見づらいです。
    
        Console.WriteLine("main: start")
        Dim parent = Task.Factory.StartNew(
            Sub()
    
                Console.WriteLine("parent: start")
                Dim child = Task.Factory.StartNew(
                Sub()
    
                    Console.WriteLine("child: start")
                    Console.WriteLine("child: end")
    
                End Sub,
                TaskCreationOptions.AttachedToParent)
    
                'child.Wait()
                Console.WriteLine("parent: end")
    
            End Sub)
    
        parent.Wait()
        Console.WriteLine("main: end")
    
        ' main: start
        ' parent: start
        ' child: start
        ' child: end
        ' parent: end
        ' main: end
        ' 
        ' child.Wait() をコメントアウトしても、子タスクの完了を暗黙的に待機する
        ' main: start
        ' parent: start
        ' parent: end
        ' child: start
        ' child: end
        ' main: end
    
    End Sub
    

  •  親子関係を築くためには、TaskCreationOptions 列挙体から AttachToParent をオプション指定します。 これは、Task.Run() ではできませんので、Task.Factory.StartNew() で実行します。 これにより、基本的には上記と同様、明示的な Wait() 呼び出しが必要ですが、呼び出さない場合でも、親タスクは子タスクが完了するのを待機するようになります。 ただし、この場合でも動作順序は保証できないので、同様に、子タスクを親タスク内で完了したい場合は、明示的に Wait メソッドを呼び出します。

  •  続いては、非同期処理内で発生した例外エラーの補足方法です。
  • Public Sub Test18()
    
        ' Parallel クラスと同様、例外エラーは複数発生するので、
        ' AggregateException で処理します。
    
        Dim task1 = Task.Run(Sub() Throw New IndexOutOfRangeException("aaa"))
    
        Try
            task1.Wait()
        Catch ex As AggregateException
    
            For Each inner In ex.InnerExceptions
                Console.WriteLine($"{inner.Message}: {inner.GetType()}")
            Next
    
        End Try
    
        ' aaa: System.IndexOutOfRangeException
    
    End Sub
    

  •  これは、Parallel クラスで解説したものと同様です。

  •  それでは、入れ子タスクの場合でも同様に、詳細な例外エラーを見ることができるでしょうか。
  • Public Sub Test19()
    
        ' 子タスクから発生した例外エラーを、親タスクで捕まえて投げる
        Dim parent = Task.Run(
            Sub()
    
                Task.Run(
                Sub()
                    Throw New OutOfMemoryException("child: Exception")
                End Sub).Wait()
    
                Console.WriteLine("子タスク内で例外が発生")
    
            End Sub)
    
        Try
            parent.Wait()
        Catch ex As AggregateException
    
            For Each inner In ex.InnerExceptions
                Console.WriteLine($"{inner.Message}, {inner.GetType()}")
            Next
    
        End Try
    
        ' 1 つ以上のエラーが発生しました。, System.AggregateException
    
        ' InnerException で詳細の例外エラーを見ているのに、
        ' AggregateException として出力されてしまった
        ' これは、親タスクから見ると、子タスク内で発生した例外エラーが AggregateException だからというシンプルなもの
        ' 1タスクからスローされる例外エラーは、入れ子であっても AggregateException だからである
    
    End Sub
    

  •  やってみると、できませんでした。子タスクから発生した例外エラーは、複数の実際の例外エラーを包んだ AggregateException だからです。 つまり、AggregateException を再帰的に InnerExceptions コレクションデータから、実際の例外エラーを抽出する必要があります。

  •  これに対応した、修正後のサンプルが以下です。
  • Public Sub Test20()
    
        ' 子タスクから発生した例外エラーを、親タスクで捕まえて投げる
        Dim parent = Task.Run(
            Sub()
    
                Task.Run(
                Sub()
                    Throw New OutOfMemoryException("child: Exception")
                End Sub).Wait()
    
                Console.WriteLine("子タスク内で例外が発生")
    
            End Sub)
    
        Try
            parent.Wait()
        Catch ex As AggregateException
    
            For Each inner In ex.Flatten().InnerExceptions
                Console.WriteLine($"{inner.Message}, {inner.GetType()}")
            Next
    
        End Try
    
        ' child: Exception, System.OutOfMemoryException
    
        ' 子タスク内の例外エラーを取り出すことに成功しました
        'AggregateException.Flatten メソッドを呼ぶことで、
        'InnerExceptions コレクションデータを平坦化(階層的に分かれたデータを、一次元に集約)することができます。
    
    End Sub
    

  •  このような場合に備えて、AggregateException クラスの Flatten メソッドが用意されています。 このメソッドを呼び出すことで、入れ子に含まれた AggregateException クラスの InnerExceptions コレクションデータを、 一次元のコレクションデータに集約することができます。これは扱いやすいです。

  •  続いて、非同期処理内で発生した例外エラーを捕捉しない場合、どうなるかについてです。
  • Public Sub Test21()
    
        Console.WriteLine("main: start")
        Task.Run(Sub() Throw New IndexOutOfRangeException("aaa"))
        Task.Run(Sub() Throw New InvalidCastException("bbb"))
        Task.Run(Sub() Throw New InvalidOperationException("ccc"))
        Thread.Sleep(1000)
        Console.WriteLine("main: internal")
        GC.Collect()
        GC.WaitForPendingFinalizers()
        Console.WriteLine("main: end")
    
        ' ★
        ' .NET Framework 4.5 の場合、捕捉されなかった例外は最後まで無視されたまま消滅する(誰も知らないまま消えていく)
        ' main: start
        ' main: internal
        ' main: end
        ' 
        ' この動作を以前の動作に戻す
        ' xxx.exe.config ファイルの内容に以下を追加して、.NET Framework 4.0 時の例外エラー動作(プログラムのダウン)に変える
        ' runtime タグの部分を追加
        ' 
        ' <?xml version="1.0" encoding="utf-8" ?>
        ' <configuration>
        '     <startup> 
        '         <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5.2" />
        '     </startup>
        '     <runtime>
        '         <ThrowUnobservedTaskExceptions enabled="true" />
        '     </runtime>
        ' </configuration>
        ' 
        ' ★
        ' .NET Framework 4.0 の場合(または、上記コンフィグファイルの内容の場合)
        ' main: start
        ' main: internal
        ' 
        ' ハンドルされていない例外: System.AggregateException: タスクの例外が、タスクの待機によっても、タスクの Exception プロパティへのアクセスによっても監視されませんでした。
        ' その結果、監視されていない例外がファイナライザー スレッドによって再スローされました。
        '  ---> System.InvalidOperationException: ccc
        '    場所 ConsoleApplication3.Module1._Closure$__._Lambda$__14-2() 場所 C:\~\Module1.vb:行 402
        '    場所 System.Threading.Tasks.Task.InnerInvoke()
        '    場所 System.Threading.Tasks.Task.Execute()
        '    --- 内部例外スタック トレースの終わり ---
        '    場所 System.Threading.Tasks.TaskExceptionHolder.Finalize()
    
    End Sub
    

  •  ちょっと長いですが、非同期処理内から発生した例外エラーを補足しなかった場合、そのうちガベージコレクションが回収作業をおこなうのですが、 ガベージコレクションが発生した際に、タスクインスタンスの解放と共に、未処理例外エラーがある場合、ファイナライザの処理として、未処理例外エラーがスローされます。 この時の動作が .NET Framework 4.0 ではシステムダウン、.NET Framework 4.5 以降では無視と言う風に仕様変更されました。

  •  ただし、.NET Framework 4.5 上であってもコンフィグファイルの設定を変更することで .NET Framework 4.0 と同等の動き方に戻すことができます。 それが上記のコメントと動作結果です。

  •  このようなことにならないように、補足されなかった例外エラーを捕捉するイベントをハンドルして使います。
  • ' UnobservedTaskException イベントを購読する
    ' Un observed(観測されなかった)Task(タスクの)Exception(例外エラー)ですね
    Public Sub Test22()
    
        AddHandler TaskScheduler.UnobservedTaskException, AddressOf TaskScheduler_UnobservedTaskException
    
        Console.WriteLine("main: start")
        Task.Run(Sub() Throw New IndexOutOfRangeException("aaa"))
        Task.Run(Sub() Throw New InvalidCastException("bbb"))
        Task.Run(Sub() Throw New InvalidOperationException("ccc"))
        Thread.Sleep(1000)
        Console.WriteLine("main: internal")
        GC.Collect()
        GC.WaitForPendingFinalizers()
        Console.WriteLine("main: end")
    
        ' main: start
        ' main: internal
        ' message: aaa, type: System.IndexOutOfRangeException
        ' message: bbb, type: System.InvalidCastException
        ' message: ccc, type: System.InvalidOperationException
        ' main: end
    
    End Sub
    
    Private Sub TaskScheduler_UnobservedTaskException(sender As Object, e As UnobservedTaskExceptionEventArgs)
    
        For Each inner In e.Exception.Flatten().InnerExceptions
            Console.WriteLine($"message: {inner.Message}, type: {inner.GetType()}")
        Next
        ' プロセスが終了しないように、処置済みにする
        e.SetObserved()
    
    End Sub
    

  •  TaskScheduler クラスの UnobservedTaskException イベントを購読することで、補足されなかった例外エラーを捕捉することができるようになります。

  •  続いては、タスクのキャンセルです。

  •  タスクのキャンセルはちょっとややこしくて、タスク自体のキャンセルと、タスクの中で動かすラムダ式(処理自体)のキャンセルの2つに分かれています。 使う側からすると、どちらも同じように感じてしまうのですが、Microsoft 的には別物扱いという意思があるみたいです。

  •  最初は、処理自体によるキャンセルです。
  • ' タスクの中で動かしている処理自体のキャンセル(内側からのキャンセル)
    Public Sub Test23()
    
        Dim source = New CancellationTokenSource
        Dim task1 = New Task(Sub()
                                 Console.WriteLine("task: start")
                                 source.Token.ThrowIfCancellationRequested()
                                 Console.WriteLine("task: end")
                             End Sub)
    
        task1.Start()
        source.Cancel()
    
        Try
            task1.Wait()
    
        Catch ex As AggregateException
    
            For Each inner In ex.Flatten.InnerExceptions
                Console.WriteLine($"AggregateException:")
                Console.WriteLine($"Message = {inner.Message}, Type={inner.GetType()}")
            Next
    
        End Try
    
        ' task: start
        ' AggregateException:
        ' Message = 操作は取り消されました。, Type=System.OperationCanceledException
    
    End Sub
    

  •  処理をキャンセルする場合、CancellationTokenSource クラスをインスタンス生成して使います。 このクラスの Cancel メソッドを呼び出すと、タスク処理に対してキャンセル要求を出すことができます。 タスク処理内では、動作中の処理内に、キャンセル要求が出たかどうかをチェックする ThrowIfCancellationRequested メソッドを仕込んでおきます。 このメソッドは、キャンセル要求が出ていた場合、例外エラーをスローするメソッドです。

  •  このサンプルでは、タスク開始と同時に即キャンセル要求を出しているので、すぐにキャンセルの例外エラーがスローされます。

  •  続いて、タスク自体によるキャンセルです。
  • ' タスク自体のキャンセル(外側からのキャンセル)
    Public Sub Test24()
    
        Dim source = New CancellationTokenSource
    
        Dim task1 = New Task(Sub()
                                 Console.WriteLine("task: start")
                                 Console.WriteLine("task: end")
                             End Sub, source.Token)
    
        task1.Start()
        source.Cancel()
    
        Try
            task1.Wait()
    
        Catch ex As AggregateException
    
            For Each inner In ex.Flatten.InnerExceptions
                Console.WriteLine($"AggregateException:")
                Console.WriteLine($"Message = {inner.Message}, Type={inner.GetType()}")
            Next
    
        End Try
    
        ' AggregateException:
        ' Message = タスクが取り消されました。, Type=System.Threading.Tasks.TaskCanceledException
    
    End Sub
    

  •  タスク側でキャンセルする場合は、CancellationTokenSource クラスの Token プロパティをラムダ式と一緒に渡しておきます。 今回はすぐにキャンセル要求を出すので、ラムダ式内で ThrowIfCancellationRequested メソッドは呼びません(不要です)。

  •  実行結果を見ると「タスクが取り消されました」と出力されて、「task: start」の出力すらされていないことが分かります。

  •  それでは、今度はタスクの作業中にキャンセル要求を出してみます。次のサンプルです。
  • Public Sub Test25()
    
        Dim source = New CancellationTokenSource
        Dim task1 = New Task(Sub()
                                 For Each item In Enumerable.Range(1, 10)
                                     source.Token.ThrowIfCancellationRequested() ' これを入れないとキャンセルしない
                                     Console.WriteLine(item)
                                     Thread.Sleep(1000)
                                 Next
                             End Sub, source.Token)
    
        task1.Start()
        Thread.Sleep(2000)
        Console.WriteLine("2秒待った")
        source.Cancel()
    
        Try
            task1.Wait()
    
        Catch ex As AggregateException
    
            For Each inner In ex.Flatten.InnerExceptions
                Console.WriteLine($"AggregateException:")
                Console.WriteLine($"Message = {inner.Message}, Type={inner.GetType()}")
            Next
    
        End Try
    
        ' 1
        ' 2
        ' 2秒待った
        ' AggregateException:
        ' Message = タスクが取り消されました。, Type=System.Threading.Tasks.TaskCanceledException
    
    End Sub
    

  •  タスクを開始して2秒待機して、タスクが動くのを待ちます。その後キャンセルすると、処理内に記載している ThrowIfCancellationRequested メソッドが検知して、キャンセル用の例外エラーをスローします。

  •  タスクにトークンを渡すとタスクによるキャンセル、トークンを渡さないと処理自体によるキャンセルという違いになります。 処理自体によるキャンセルの時に、OperationCanceledException が発生するのは、ユーザー操作によるキャンセルをしたという意味で、 タスク自体によるキャンセルの時に、TaskCanceledException が発生するのは、プログラムによる意図的なキャンセルをしたという意味があります。

  •  Exception の継承関係的にも、OperationCanceledException を継承したものとして TaskCanceledException が作られており、想定でしかありませんが、 以下のような問題から生まれた解決方法であることが読み取れます。
  • Public Sub Test26()
    
        ' 作り方によっては、最初からキャンセル要求を出しているトークンを渡す場合がある
        ' これを、タスク実行時に渡したとき、(始まってもいないタイミングでキャンセルされたという状態が)
        ' 処理中にキャンセル要求が出されたと言えるか、というと厳密には言えない
        ' よって、OperationCanceledException を継承して TaskCanceledException を別途用意した
        Dim source = New CancellationTokenSource
        source.Cancel()
    
        Dim task1 = Task.Run(Sub()
    
                             End Sub,
                             source.Token)
    
        Try
            task1.Wait()
            Console.WriteLine("OK")
        Catch ex As AggregateException
    
            For Each inner In ex.Flatten.InnerExceptions
                Console.WriteLine($"AggregateException:")
                Console.WriteLine($"Message = {inner.Message}, Type={inner.GetType()}")
            Next
    
        End Try
    
        ' AggregateException:
        ' Message = タスクが取り消されました。, Type=System.Threading.Tasks.TaskCanceledException
    
    End Sub
    

  •  使い分けが難しいところですが、Bojan さんによると、一般的なユーザ操作からのキャンセル要求時は、OperationCanceledException で十分ではないかと述べられています。

  •  非同期処理その3(2017年10月版)に続きます。