Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

フォームアプリにおける非同期(async/await)時の連続押し禁止、キャンセル、引数と戻り値 など

解決したいこと

いつもお世話になっております。
非同期(async/await)の超初心者です。
いろんなサイトを閲覧していますが、フォームアプリの例があまり無く苦心しております。
動く様になったのですが、先輩方の目から見て「何やってんねん」という部分があればご指導お願い致します。

環境等:
 Visual Studio 2015
 C#
 クラシックなフォームアプリ

動作内容:

  1. [Delayのテスト]ボタンを押して、重い[DelaySample]関数を起動。
  2. [キャンセル]ボタンを押して、上記プロセスをキャンセル。
  3. Escキーを押しても、キャンセル。

フォーム.png

発生している問題・エラー

 一応動いてます。

該当するソースコード

    public partial class Form1 : Form
    {

        CancellationTokenSource cancelTokenSource;   //←ここで宣言
        CancellationToken cancellationToken;         //←ここで宣言       
        
        public Form1()
        {
            InitializeComponent();
        }
 
        private async void buttonDelayTest_Click(object sender, EventArgs e)
        {
            buttonDelayTest.Enabled = false;        //連続押し防止の為、ボタン押下禁止

            //キャンセルトークンの設定
            cancelTokenSource = new CancellationTokenSource() ;
            cancellationToken = cancelTokenSource.Token;

            string result = await Task.Run(() => DelaySample(cancellationToken,"パラメータだよ"));

            cancelTokenSource.Cancel();

            Console.WriteLine("result:" + result);

            buttonDelayTest.Enabled = true;         //ボタン押下復活
        }

        private async Task<string> DelaySample(CancellationToken cancellationToken, string param)
        {
            Console.WriteLine("param⇒" + param);
            for (int i = 0; i < 10; i++)
            {
                if (cancellationToken.IsCancellationRequested)
                {
                    return "キャンセルされました。";
                }
                Console.WriteLine(i);
                await Task.Delay(1000);
            }
            return "完了";
        }


        private void buttonCancel_Click(object sender, EventArgs e)
        {
            //非同期処理をキャンセルする。
            cancelTokenSource.Cancel();
        }


        private void buttonCancel_PreviewKeyDown(object sender, PreviewKeyDownEventArgs e)
        {
            //エスケープキーを押しても、キャンセルする。
            switch (e.KeyCode)
            {
                case Keys.Escape:
                    Console.WriteLine("Escapeキーが押されました。");
                    buttonCancel.PerformClick();
                    break;
            }
        }

    }

自分で試したこと

これでいいのか?の箇所:
・ [Delayのテスト]ボタンの連続押しを防止する為に.Enabledを、false⇔true した。
・ [キャンセル]ボタンで、.Cancel()を効かせる為、
   CancellationTokenSource
   CancellationToken
  をクラス内の共通変数とした。
・ラムダ式これでいいのでしょうか?
・[DelaySample]関数の引数、戻り値の書き方、cancellationTokenの渡し方

0

1Answer

以下の Microsoft のドキュメントは読みましたか?

マネージド スレッドのキャンセル
https://learn.microsoft.com/ja-jp/dotnet/standard/threading/cancellation-in-managed-threads

質問のコードは上記ドキュメントに書いてある「ポーリングによるリッスン」の方法のようですが、ざっと見それに準拠してないところを以下に書きます。(見落としがあるかもしれませんが)

(1) CancellationTokenSource クラスは、必ず CancellationTokenSource.Dispose メソッドを呼び出して、キャンセル トークン ソースが保持しているアンマネージ リソースを解放する必要があります。

(2) DelaySample で ThrowIfCancellationRequested メソッドを呼び出すと、キャンセルされていた場合は OperationCanceledException がスローされます。DelaySample を呼び出す側で OperationCanceledException をキャッチして適切な処理を行うということを考えた方が良さそうです。

(3) buttonCancel_Click で Cancel する前に CancellationTokenSource が null でないことを確認した方が良さそうです。

具体例は以下の記事の button1_Click メソッド(進捗表示なし)と button2_Click メソッド(進捗表示あり)を見てください。

非同期タスクのキャンセル
http://surferonwww.info/BlogEngine/post/2020/09/27/cancellation-of-async-task.aspx


【追記】

(4) buttonDelayTest_Click の中で使っている Task.Run は不要です。DelaySample は非同期メソッドなので。以下のようにして試してみてください。

string result = await DelaySample(cancellationToken,"パラメータだよ");

0Like

Comments

  1. @OhiKazuma

    Questioner

    ありがとうございます。
    来週、勉強します。

  2. @OhiKazuma

    Questioner

    @SurferOnWww先生:
    ありがとうございます。
    とても勉強になっています。
    今日の午前は、先生の指導及びソースを拝見させて頂いておりました。
    以下は改善した(つもりの)コードです。

    【Escキーを使うにあたって】
    簡単なRPAアプリを作っています。
    他のアプリを開いて画像マッチングしながらマウスの位置を決めてクリックするやつで、ほぼ出来ていていまして非同期の部分で考え込んでいました。
    RPA実行中、他のアプリがアクティブになっている時に、Escキー押しても意味無いですが、マッチング画像調整中に「Escキー」を押したいので、付ける方向にしました。(最悪無くても良い)

    [問題1]
    そうすると、[キャンセル]ボタンのEnabledを true⇔falseしていると、[Delayのテスト]ボタン動作中に「buttonCancel_PreviewKeyDown」が効かなくなったので、[キャンセル]ボタンは常にtrueにした。
     ⇒★2)

    [問題2]
    となると、不意に[キャンセル]ボタンを押されてエラーが出ない様に、
    if (this.cancelTokenSource == null)
    で確認しようとすると、
    usingステートメントを通過した(Disiposeされた?)[cancelTokenSource]がnullになっていなかった!
    そこで、明示的に
    this.cancelTokenSource = null;
    とした。
     ⇒★1)

    先生のコードに「this.cancelTokenSource = null;」は無かったです。
    Disiposeされたらnullと思っていましたが、どうなんでしょ?

            private CancellationTokenSource cancelTokenSource = null;
    
            private void Form1_Load(object sender, EventArgs e)
            {
                //ボタンの初期化
                buttonDelayTest.Enabled = true;     //[Delayのテスト]ボタンは押せる
                //buttonCancel.Enabled = false;       //[キャンセル]ボタンは押せない★2)
    
                //↑★2) buttonCancel_PreviewKeyDownでEscキーを検出できなくなるので、
                //[キャンセル]ボタンは常にEnabled = true
            }
    
    
            private async void buttonDelayTest_Click(object sender, EventArgs e)
            {
                buttonDelayTest.Enabled = false;    //[Delayのテスト]ボタン押下禁止(連続押し禁止)
                //buttonCancel.Enabled = true;        //[キャンセル]ボタンを押せるようにする。★2)
    
                using (this.cancelTokenSource = new CancellationTokenSource())
                {
                    CancellationToken cancellationToken = this.cancelTokenSource.Token;
                    try
                    {
                        string result = await DelaySample(cancellationToken, "パラメータだよ");
                        Console.WriteLine("result:" + result);
                    }
                    catch (OperationCanceledException)
                    {
                        // 必要なら何らかの処置
                    }
                    finally
                    {
                        //this.cancelTokenSource.Dispose();
                        //★1) usingを終えただけではnullにはならない(?)ので、cancelTokenSourceを明示的にnullにする
                        this.cancelTokenSource = null;    
                    }
                }
    
                buttonDelayTest.Enabled = true;  //[Delayのテスト]ボタン押下復活!!!
                //buttonCancel.Enabled = false;  //[キャンセル]ボタンは押せない★2)
            }
    
    
            private async Task<string> DelaySample(CancellationToken cancellationToken, string param)
            {
                Console.WriteLine("param⇒" + param);
                for (int i = 0; i < 10; i++)
                {
                    if (cancellationToken.IsCancellationRequested)
                    {
                        return "キャンセルされました。";
                    }
    
                    Console.WriteLine(i);
                    await Task.Delay(1000);
                }
                return "完了";
            }
    
    
            private void buttonCancel_Click(object sender, EventArgs e)
            {
                //非同期処理をキャンセルする。
                //開始前のボタン押し回避(cancelTokenSourceの状態表示のため冗長してます)
                if (this.cancelTokenSource == null)
                {
                    Console.WriteLine("cancelTokenSource ⇒ null");
                }
                else
                {
                    Console.WriteLine("cancelTokenSource ⇒ Not null");
                    cancelTokenSource.Cancel();
                }
            }
    
    
            private void buttonCancel_PreviewKeyDown(object sender, PreviewKeyDownEventArgs e)
            {
                //エスケープキーを押しても、キャンセルする。
                switch (e.KeyCode)
                {
                    case Keys.Escape:
                        Console.WriteLine("Escapeキーが押されました。");
                        buttonCancel.PerformClick();
                        break;
                }
            }
    
    
  3. そうすると、[キャンセル]ボタンのEnabledを true⇔falseしていると、[Delayのテスト]ボタン動作中に「buttonCancel_PreviewKeyDown」が効かなくなったので、[キャンセル]ボタンは常にtrueにした。

    PreviewKeyDown イベントというのは当該コントロールにフォーカスがあるときにキーが押された場合に発生するイベントです。効かなくなった理由は [キャンセル] ボタンからフォーカスが外れるためだと思います。

    以下のように buttonCancel.Enabled = true; のコメントアウトを解除して、buttonCancel.Focus(); を一行追加してフォーカスが当たるようにして試してみてください。

    private async void buttonDelayTest_Click(object sender, EventArgs e)
    {
        buttonDelayTest.Enabled = false;    //[Delayのテスト]ボタン押下禁止(連続押し禁止)
        buttonCancel.Enabled = true;        //[キャンセル]ボタンを押せるようにする。★2) 
        buttonCancel.Focus();
    
        using (this.cancelTokenSource = new CancellationTokenSource())
        {
        // ・・・略・・・
    }     
    

    それで ESC キーによるキャンセルが効くようになると思います。それで OK なら finally 句で this.cancelTokenSource = null; とするような必要は無くなりますし、問題 1, 2 すべて解決するのではないでしょうか。

    以下、本題とは関係ないことですが・・・

    catch (OperationCanceledException)
    {
        // 必要なら何らかの処置
    }
    

    というコードがありますが、質問者さんのコードのキャンセルで OperationCanceledException はスローされないはずなので、そこに制御は来ないと思います。以下のMicrosoft のドキュメントに書いてありますが、DelaySample メソッドで ThrowIfCancellationRequested を呼び出すことを考えてはいかがですか?

    キャンセル要求のリッスンと応答
    https://learn.microsoft.com/ja-jp/dotnet/standard/threading/cancellation-in-managed-threads#listening-and-responding-to-cancellation-requests

    "操作を終了するための正しい方法はユーザー デリゲートから ThrowIfCancellationRequested メソッドを呼び出すことです。これにより、OperationCanceledException がスローされます。 ライブラリ コードでは、ユーザー デリゲートのスレッドでこの例外をキャッチし、例外のトークンを調べて、この例外が連携によるキャンセルを示すのか、それ以外の例外的な状況を示すのかを判断できます"

    Disiposeされたらnullと思っていましたが、どうなんでしょ?

    そういうことはないです。

    Dispose パターンというのがあって(下の画像参照)、CancellationTokenSource の Dispose もそれに沿って実装されているはずで、Dispose メソッドの実行で行うのはそれだけです。

    dispose.png

    this.cancelTokenSource を null に設定することはありません。

  4. @OhiKazuma

    Questioner

    @SurferOnWww先生:
    ありがとうございます。
    コードがスッキリしました。

    [1] buttonCancel.Focus();の件
    なるほどです。判りました。

    [2] 『ThrowIfCancellationRequested を呼び出すことを考えてはいかがですか?』の件
    ★3でどでしょ?
    今日の午前中、アチコチとネットをさがしました。

    
     private CancellationTokenSource cancelTokenSource = null;
    
    private void Form1_Load(object sender, EventArgs e)
    {
        //ボタンの初期化
        buttonDelayTest.Enabled = true;     //[Delayのテスト]ボタンは押せる
        buttonCancel.Enabled = false;       //[キャンセル]ボタンは押せない
    }
    
    private async void buttonDelayTest_Click(object sender, EventArgs e)
    {
        buttonDelayTest.Enabled = false;    //[Delayのテスト]ボタン押下禁止(連続押し禁止)
        buttonCancel.Enabled = true;        //[キャンセル]ボタンを押せるようにする。
        buttonCancel.Focus();               //エスケープキーを有効にするために必要!
        //【重要】PreviewKeyDown イベントは当該コントロールにフォーカスがあるときにキーが押された場合に発生するイベント
    
        using (this.cancelTokenSource = new CancellationTokenSource())
        {
            CancellationToken token = this.cancelTokenSource.Token;
            try
            {
                string result = await DelaySample(token, "パラメータだよ");
                Console.WriteLine("result:" + result);
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine("OperationCanceledExceptionが発生しました。");
            }
        }
    
        buttonDelayTest.Enabled = true;     //[Delayのテスト]ボタン押下復活!!!
        buttonCancel.Enabled = false;       //[キャンセル]ボタンは押せないようにする
    }
    
    private async Task<string> DelaySample(CancellationToken token, string param)
    {
        Console.WriteLine("param⇒" + param);
        for (int i = 0; i < 10; i++)
        {
            if (token.IsCancellationRequested)
            {
                return "キャンセルされました。";
            }
    
            //★3 キャンセルが効かなかった場合、「OperationCanceledExceptionエラー」を発生させ強制停止!
            token.ThrowIfCancellationRequested();
    
            Console.WriteLine(i);
            await Task.Delay(1000);
    
        }
        return "完了";
    }
    
    
    private void buttonCancel_Click(object sender, EventArgs e)
    {
        //非同期処理をキャンセルする。
        //【重要】『非同期処理中のみ』ボタンを押せるようにすること。
        cancelTokenSource.Cancel();
    }
    
    
    private void buttonCancel_PreviewKeyDown(object sender, PreviewKeyDownEventArgs e)
    {
        //エスケープキーを押しても、キャンセルする。
        switch (e.KeyCode)
        {
            case Keys.Escape:
                Console.WriteLine("Escapeキーが押されました。");
                buttonCancel.PerformClick();
                break;
        }
    }
    
    
  5. ★3でどでしょ?

    試してみましたか?

    そこに置いたのでは、キャンセルした時、OperationCanceledException がスローされるより先に return されるので、catch (OperationCanceledException) では catch できず、何も動作は変わらないという結果になったと思いますけど。

    以下のようにして試してみてください。

    private async Task<string> DelaySample(CancellationToken token, string param)
    {
        Console.WriteLine("param⇒" + param);
        for (int i = 0; i < 10; i++)
        {
            token.ThrowIfCancellationRequested();
    
            Console.WriteLine(i);
            await Task.Delay(1000);
        }
        return "完了";
    }
    

    だだし、先の私のコメントで書いた「DelaySample メソッドで ThrowIfCancellationRequested を呼び出すことを考えてはいかがですか」というのは、前のコード、

    catch (OperationCanceledException)
    {
        // 必要なら何らかの処置
    }
    

    にあった「何らかの処置」が必要な場合です。「何らかの処置」が不要なら ThrowIfCancellationRequested メソッドも catch 句も不要です。


    【追記】

    //★3 キャンセルが効かなかった場合、「OperationCanceledExceptionエラー」を発生させ強制停止

    そこ、誤解があるようです。「キャンセルが効かなかった場合」ということではなくて、キャンセルされたら例外スローして中断し、必要な後処理を catch 句の中で行うということです。

  6. @OhiKazuma

    Questioner

    @SurferOnWww先生:
    ご指導ご鞭撻、ありがとうございます。
    そうゆうことなのですね。↓

    そこ、誤解があるようです。「キャンセルが効かなかった場合」ということではなくて、キャンセルされたら例外スローして中断し、必要な後処理を catch 句の中で行うということです。

    このサンプル程度では例外は発生しませんが、
    規模が大きくなり例外が予期(不安視)されるような場合。

    token.IsCancellationRequested

    で「キャンセル」を待つのではなく、

    token.ThrowIfCancellationRequested();

    を発生させて「キャンセル」と「例外」をまとめて処理する、ってことですね。

  7. このサンプル程度では例外は発生しませんが、規模が大きくなり例外が予期(不安視)されるような場合。
    token.IsCancellationRequested
    で「キャンセル」を待つのではなく、
    token.ThrowIfCancellationRequested();
    を発生させて「キャンセル」と「例外」をまとめて処理する、ってことですね。

    そういうわけではないです。

    どちらもユーザーの操作によるキャンセルを待って「処理」することを考えてのものです。

    ThrowIfCancellationRequested メソッドというのは、以下の Microsoft のドキュメント、

    CancellationToken.ThrowIfCancellationRequested メソッド
    https://learn.microsoft.com/ja-jp/dotnet/api/system.threading.cancellationtoken.throwifcancellationrequested?view=net-8.0

    に書いてありますように、

    if (token.IsCancellationRequested)   
        throw new OperationCanceledException(token);  
    

    ということと同じです。

    ユーザーによりキャンセルされた時、

    (1) 単純に DelaySample から戻ればよい。

    (2) DelaySample で OperationCanceledException をスローし、呼び出し元 buttonDelayTest_Click で catch して必要な処理を行う。

    ・・・という 2 つのケースが考えられますが、ケースによって使い分けるということです。

  8. @OhiKazuma

    Questioner

    @SurferOnWww先生:

    ありがとうございます。
    具体的に例外が発生するような状況をイメージできないので、先生の説明を完全に理解できないのだと思います。

    ユーザーによりキャンセルされた時、

    (1) 単純に DelaySample から戻ればよい。

    (2) DelaySample で OperationCanceledException をスローし、呼び出し元 buttonDelayTest_Click で catch して必要な処理を行う。

    ・・・という 2 つのケースが考えられますが、ケースによって使い分けるということです。

    ↑これですね。
    肝に銘じておきます。

    長々とお付き合い頂き、ありがとうございました。
    非同期の入口に立てたような気がします。

    これにて一旦、クローズとさせていただきます。

Your answer might help someone💌