TreeNode変更でTaskキャンセル

タグの編集
投稿者 mayopee  (社会人) 投稿日時 2020/5/28 14:52:33
環境:VB2019,.Net4.7.2,WinForm

エクスプローラーのようなツールを作成しています。左ペインにフォルダTreeViewがあり、
TreeNode変更で選択フォルダ内のファイルに対して様々な処理を行います。
ファイル列挙時に途中キャンセルを実装したいのですが、希望動作になりません。
希望動作は「列挙途中でキャンセル指示がでれば列挙を中止して、新しい列挙を開始する」
としたいのです。

実際とは異なりますが、再現可能なコードを掲載するので、試して戴けないでしょうか?
EscキーでTaskキャンセルとしています。

↓のコードで 「1」=>「2」=>「1」=>「2」=>「Task Cancel」とNodeを素早くクリックした時、
以下の【NGパターン】となります。

【希望動作】
1の列挙開始=>1のキャンセル=>2の列挙開始=>2のキャンセル=>1の列挙開始=>.....

【NGパターン】
1の列挙開始=>2の列挙開始=>1のキャンセル=>2の列挙再開=>1の列挙開始=>.....

続けて、「ListBox Clear」を押して同じ操作をすると、2回目は希望動作になります。
メモリにキャッシュされる感じで動作も軽くなります。理由はわかりません。

又、Escキーの押下で以下のようにすると希望動作にはなります。
「1」=>「Escキー」=>「2」=>「Escキー」=>「1」=>「Escキー」=>「2」=>「Escキー」

TreeNodeの変更で上記【希望動作】とするには、どうすれば良いでしょうか?
TreeNodeの変更はTreeViewのAfterSelectをトリガとしています。

Imports System.Threading
Public Class Form1
    Private _TV As New TreeView With {.Dock = DockStyle.Fill}
    Private _LB As New ListBox With {.Dock = DockStyle.Fill}
    Private _cts As CancellationTokenSource
    Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
        Dim sp As New SplitContainer With {.Dock = DockStyle.Fill}
        Dim root As New TreeNode("Task Cancel")
        Dim tn1 As New TreeNode("1")
        Dim tn2 As New TreeNode("2")
        Dim tn3 As New TreeNode("ListBox Clear")
        root.Nodes.AddRange({tn1, tn2, tn3})
        _TV.Nodes.Add(root)
        sp.Panel1.Controls.Add(_TV)
        sp.Panel2.Controls.Add(_LB)
        Controls.Add(sp)
        root.Expand()
        _TV.SelectedNode = root
        AddHandler _TV.AfterSelect, AddressOf TreeView_AfterSelect
        KeyPreview = True
        AddHandler Me.KeyDown, Sub(o, k)
                                   If k.KeyCode = Keys.Escape Then
                                       _cts?.Cancel()
                                   End If
                               End Sub
    End Sub
    Private Async Sub TreeView_AfterSelect(sender As Object, e As TreeViewEventArgs)
        Try
            SendKeys.SendWait("{ESC}")
            'Await Task.Delay(1000) 
            _cts = New CancellationTokenSource()
            Select Case e.Node.Text
                Case "1"
                    Await Task.Run(Sub()
                                       Try
                                           For Each item In Enumerable.Range(1, 10000)
                                               _cts?.Token.ThrowIfCancellationRequested()
                                               SetText($"Node1({item})")
                                           Next
                                       Catch ex As OperationCanceledException
                                           Invoke(Sub() _LB.Items.Add("1 キャンセル ★★★★★★★★★★★★★★★★★★★★★★★★"))
                                       End Try
                                   End Sub)
                Case "2"
                    Await Task.Run(Sub()
                                       Try
                                           For Each item In Enumerable.Range(1, 10000)
                                               _cts?.Token.ThrowIfCancellationRequested()
                                               SetText($"Node2({item})")
                                           Next
                                       Catch ex As OperationCanceledException
                                           Invoke(Sub() _LB.Items.Add("2 キャンセル ★★★★★★★★★★★★★★★★★★★★★★★★"))
                                       End Try
                                   End Sub)
                Case "ListBox Clear"
                    _LB.Items.Clear()
            End Select
        Finally
            _cts?.Dispose()
            _cts = Nothing
        End Try
    End Sub
    Private Sub SetText(s As String)
        If InvokeRequired Then
            Invoke(Sub() SetText(s))
        Else
            _LB.Items.Add(s)
        End If
    End Sub
End Class
投稿者 るきお  (社会人) 投稿日時 2020/5/28 19:21:30
私の苦手なところなので満額回答ではありませんがご容赦を。

これで【希望動作】になっているように思いますが、確認できますか?

Imports System.Threading

Public Class Form4

    Private _TV As New TreeView With {.Dock = DockStyle.Fill}
    Private _LB As New ListBox With {.Dock = DockStyle.Fill}
    Private _cts As CancellationTokenSource

    Private Sub Form4_Shown(sender As Object, e As EventArgs) Handles Me.Shown
        Dim sp As New SplitContainer With {.Dock = DockStyle.Fill}
        Dim root As New TreeNode("Task Cancel")
        Dim tn1 As New TreeNode("1")
        Dim tn2 As New TreeNode("2")
        Dim tn3 As New TreeNode("ListBox Clear")
        root.Nodes.AddRange({tn1, tn2, tn3})
        _TV.Nodes.Add(root)
        sp.Panel1.Controls.Add(_TV)
        sp.Panel2.Controls.Add(_LB)
        Controls.Add(sp)
        root.Expand()
        _TV.SelectedNode = root
        AddHandler _TV.AfterSelect, AddressOf TreeView_AfterSelect
        KeyPreview = True
        AddHandler Me.KeyDown, Sub(o, k)
                                   If k.KeyCode = Keys.Escape Then
                                       _cts?.Cancel()
                                   End If
                               End Sub
    End Sub

    Private Async Sub TreeView_AfterSelect(sender As Object, e As TreeViewEventArgs)

        If _cts Is Nothing Then
            _cts = New CancellationTokenSource
        Else
            _cts.Cancel()
            Task.Delay(20).Wait()
            _cts = New CancellationTokenSource
        End If


        Select Case e.Node.Text
            Case "1"
                Await Task.Run(Sub()

                                   For Each item In Enumerable.Range(1, 10000)
                                       Task.Delay(1).Wait()

                                       If _cts.Token.IsCancellationRequested Then
                                           Invoke(Sub() _LB.Items.Add("1 キャンセル ★★★★★★★★★★★★★★★★★★★★★★★★"))
                                           Return
                                       End If

                                       SetText($"Node1({item})")
                                   Next

                               End Sub, _cts.Token)

            Case "2"

                Await Task.Run(Sub()
                                   'Try 
                                   For Each item In Enumerable.Range(1, 10000)
                                       Task.Delay(1).Wait()

                                       If _cts.Token.IsCancellationRequested Then
                                           Invoke(Sub() _LB.Items.Add("2 キャンセル ★★★★★★★★★★★★★★★★★★★★★★★★"))
                                           Return
                                       End If
                                       SetText($"Node2({item})")
                                   Next

                               End Sub, _cts.Token)

            Case "ListBox Clear"
                _LB.Items.Clear()
        End Select

    End Sub

    Private Sub SetText(s As String)
        If InvokeRequired Then
            Invoke(Sub() SetText(s))
        Else
            _LB.Items.Add(s)
        End If
    End Sub
End Class
投稿者 mayopee  (社会人) 投稿日時 2020/5/28 21:33:37
るきお様、実験して戴き、ありがとうございます。

結果は提示してもらったコードで100回位、実行してみましたが、僕のより安定はしていますが、
「1」と「2」の列挙が並列実行される場面が2回程、発生しました。

ポイントは、「_cts.Cancelでキャンセルしても、メッセージキューに溜まっている前回の列挙情報が
処理される時間を待つ」ということだと思っているのですが........
るきお様も、Task.Delay(20).Wait()を入れておられますね。
僕の最初のコードでも  SendKeys.SendWait("{ESC}")の後に、Await Task.Delay(1000) と入れていた
のですが、希望動作とならず断念しました。
あとはForループの中で、キャンセルされているか監視のため、Task.Delayを入れられていますね。

ここらが、ポイントだとすると、PC性能に依存し、環境により結果が異なるという話になりそうです。
待機時間を変更して、もう少し、追試してみます。
投稿者 るきお  (社会人) 投稿日時 2020/5/28 21:41:15
実行中のタスクと、タスクの外側で _cts を共有しているところに難しさがあって、キャンセル操作時に _cts の New が先に実行されてしますと、タスク内で _cts を参照するところで、タスク開始時の _cts とは違うものを参照することになってしまいます。
それで、Newするまでの間に少し猶予 Task.Delay(20).Wait() を入れてみました。
ご認識の通り時間で指定しているところが良くないところなんですよね。

試してはいませんが、タスク側で処理が終わった時点でフラグをたてて、フラグが立っているのを確認してから_cts を New するなど時間に依存しないロジックが書ければそちらの方が良いと思います。

とりあえず Task.Delay(20).Wait() の 20 を大きくするだけでも安定性は上がるので、この処理のクリティカル具合によってはこれでも妥協できるかもしれませんが・・・。
投稿者 mayopee  (社会人) 投稿日時 2020/5/29 10:45:50
るきお様、ありがとうございます。

あれから、色々と待機時間を変えて試行錯誤したのですが、やはり時間で待機する方法では
失敗する場合があり、実務では使えないと判断しました。

るきお様のご指摘の通リ、フラグ管理にしてみたら、今のところ100%成功しています。
これで、当面運用したいと思います。色々、ご指導ありがとうございました。
テスト用に使ったコードをアップしておきます。

Imports System.Threading
Public Class Form1
    Private _TV As New TreeView With {.Dock = DockStyle.Fill}
    Private _LB As New ListBox With {.Dock = DockStyle.Fill}
    Private _cts As CancellationTokenSource
    'Task実行中を示すフラグ 
    Private _IsTaskRunning As Boolean = False
    Private Sub Form1_Load(sender As Object, e As EventArgs) Handles MyBase.Load
        Dim sp As New SplitContainer With {.Dock = DockStyle.Fill}
        Dim root As New TreeNode("Task Cancel")
        Dim tn1 As New TreeNode("1")
        Dim tn2 As New TreeNode("2")
        Dim tn3 As New TreeNode("ListBox Clear")
        root.Nodes.AddRange({tn1, tn2, tn3})
        _TV.Nodes.Add(root)
        sp.Panel1.Controls.Add(_TV)
        sp.Panel2.Controls.Add(_LB)
        Controls.Add(sp)
        root.Expand()
        _TV.SelectedNode = root
        AddHandler _TV.AfterSelect, AddressOf TreeView_AfterSelect
        KeyPreview = True
        AddHandler Me.KeyDown, Sub(o, k)
                                   If k.KeyCode = Keys.Escape Then
                                       _cts?.Cancel()
                                   End If
                               End Sub
    End Sub
    Private Async Sub TreeView_AfterSelect(sender As Object, e As TreeViewEventArgs)
        Try
            If _IsTaskRunning Then _cts.Cancel()
            While _IsTaskRunning
                Await Task.Delay(10)
            End While
            _cts = New CancellationTokenSource
            Select Case e.Node.Text
                Case "1"
                    Await Task.Run(Sub()
                                       _IsTaskRunning = True
                                       For Each item In Enumerable.Range(1, 10000)
                                           Task.Delay(1).Wait()
                                           If _cts.Token.IsCancellationRequested Then
                                               Invoke(Sub() _LB.Items.Add("1 キャンセル ★★★★★★★★★★★★★★★★★★★★★★★★"))
                                               Return
                                           End If
                                           SetText($"Node1({item})")
                                       Next
                                   End Sub, _cts.Token)
                Case "2"
                    Await Task.Run(Sub()
                                       _IsTaskRunning = True
                                       For Each item In Enumerable.Range(1, 10000)
                                           Task.Delay(1).Wait()
                                           If _cts.Token.IsCancellationRequested Then
                                               Invoke(Sub() _LB.Items.Add("2 キャンセル ★★★★★★★★★★★★★★★★★★★★★★★★"))
                                               Return
                                           End If
                                           SetText($"Node2({item})")
                                       Next
                                   End Sub, _cts.Token)
                Case "ListBox Clear"
                    _LB.Items.Clear()
            End Select
        Finally
            _IsTaskRunning = False
            _cts.Dispose()
        End Try
    End Sub
    Private Sub SetText(s As String)
        If InvokeRequired Then
            Invoke(Sub() SetText(s))
        Else
            _LB.Items.Add(s)
        End If
    End Sub
End Class