WebSurfer's Home

トップ > Blog 1   |   ログイン
APMLフィルター

try - catch で OperationCanceledException を捕捉できない

by WebSurfer 2021年7月17日 14:53

タスク並列ライブラリ (TPL) の Parallel.For メソッドの複数の並列処理をキャンセルするコードを書いているときにハマって悩んだので、今後そういうことがないよう備忘録を書いておきます。

Parallel.For ループのキャンセル

ちなみにコードはこの記事の下の方に記載したもので、Microsoft のドキュメント「方法: Parallel.For または ForEach ループを取り消す」を参考にキャンセル処置を実装しました。Windows Forms アプリなので UI スレッドをブロックしないようにしている点が違いますが、基本的には同じです。

で、何にハマったかと言うと、Visual Studio から[デバッグ(D)]⇒[デバッグの開始(S)]でアプリを実行すると try - catch で OperationCanceledException を捕捉できないということです

下の画像を見てください。try - catch 構文で try 句内で発生した OperationCanceledException を catch 句があるにもかかわらず捕捉できていません。(実はそこは思い違いだったのですが。詳細後述)

OperationCanceledException

Visual Studio から[デバッグ(D)]⇒[デバッグなしで開始(H)]で実行すれば上の画像のようなことは起こらず、catch 句で OperationCanceledException を捕捉できます。ということは、先の記事「不正なクロススレッドコールの捕捉」の話と同様にデバッグ実行でないと検出できない不正な何かがあると思い込んでいました。

なので、Unhandled OperationCanceledException when thrown from Parallel.ForEach に書いてあるように ThrowIfCancellationRequested メソッドを try - catch で囲ったり、await Task.Run( async () => ... と async を付与してデバッグ実行でも catch できるように対応してみました。

でも、実はそんなことをする必要はなかったです。(汗) 上の画像は、Visual Studio がデバッグ時にユーザーに便宜(?)を図るために、例外が発生した場所で一旦実行を止めて知らせたのだそうです。続行すれば catch 句まで進んで OperationCanceledException を補足できます。

そのことは Microsoft のドキュメント「例外処理(タスク並列ライブラリ)」の「注意」に以下のように書いてありました:

"[マイ コードのみ] が有効になっている場合、Visual Studio では、例外をスローする行で処理が中断され、"ユーザー コードで処理されない例外" に関するエラー メッセージが表示されることがあります。このエラーは問題にはなりません。 F5 キーを押して続行し、以下の例に示す例外処理動作を確認できます。 Visual Studio による処理が最初のエラーで中断しないようにするには、 [ツール] メニューの[オプション]、[デバッグ] の順にクリックし、[全般] で [マイ コードのみを有効にする] チェック ボックスをオフにします"

試してみましたが確かにその通りでした。

なお、すべてのケースで例外が発生した場所で一旦実行を止めて知らせるというわけではなくて、ある条件の時に限るようです。ある条件とは、多分、呼び出し元と別のスレッドで実行されているタスクで例外がスローされたが、その例外を呼び出し元で catch できるか不明な時ではないかと思われます (だから async を付与すると解決した?)。

検証に使った Windows Forms アプリのコードを以下に載せておきます。デバッグ実行してキャンセルをかけると上の画像のように例外が発生した場所で一旦止まります。

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

namespace WinFormsTPL
{
    public partial class Form2 : Form
    {
        private int currentProgress = 0;
        private CancellationTokenSource cts;

        public Form2()
        {
            InitializeComponent();

            toolStripStatusLabel1.Text = "";
            toolStripProgressBar1.Value = 0;
        }

        // 進捗をプログレスバーとラベルに表示するコールバック。UIスレッド
        // で呼び出される
        // 【注】Parallel.For は同期メソッドなので、下のコード例のように
        // await Task.Run(() => Parallel.For ...  を使って UI スレッドを
        // ブロックしないようにすること。でないと ShowProgress はキューに
        // 溜るだけで、Parallel.For が終了してから一気に 100% になりプロ
        // グレス表示にならない。
        private void ShowProgress(int percent)
        {
            currentProgress += percent;
            toolStripStatusLabel1.Text = currentProgress + "%完了";
            toolStripProgressBar1.Value = currentProgress;
        }     

        // Parallel.For で複数並列に実行する同期メソッド
        private string  WorkProgress(int number, IProgress<int> progress)
        {
            int id = Thread.CurrentThread.ManagedThreadId;
            string retunVlaue = $"n = {number}, ThreadID = {id}" +
                                $", start: {DateTime.Now:ss.fff}, ";

            // ここで 3 秒中断
            Thread.Sleep(3000);

            // 進捗をプログレスバーとラベルに表示
            progress.Report(1);

            retunVlaue += $"end: {DateTime.Now:ss.fff}\r\n";
            return retunVlaue;
        }

        // 画像の [ParallelForProgress] クリックのハンドラ
        // 上の WorkProgress メソッドを Parallel.For で 100 並列実行
        private async void ParallelForProgress_Click(object sender, EventArgs e)
        {
            currentProgress = 0;
            toolStripStatusLabel1.Text = "";
            toolStripProgressBar1.Value = 0;

            int id = Thread.CurrentThread.ManagedThreadId;
            label1.Text = $"UI Thread ID = {id}\r\n";

            // CancellationToken を ParallelOptions 経由で Parallel.For
            // に渡すため、ParallelOptions を初期化
            var option = new ParallelOptions();

            // WorkProgress の戻り値を保持する配列の定義と初期化
            string[] results = new string[100];

            using (cts = new CancellationTokenSource())
            {
                option.CancellationToken = cts.Token;
                var p = new Progress<int>(ShowProgress);

                try
                {
                    // Parallel.For は同期メソッドであることに注意。
                    // UI スレッドをブロックしないよう await Task.Run
                    // を用いてスレッドプールで Parallel.For を実行
                    await Task.Run(() => Parallel.For(0, 100, option,
                        (n) => {
                            results[n] = WorkProgress(n, p);

                            // 以下は無くてもキャンセルされるが、Microsoft
                            // のドキュメントに従って入れておく
                            option.CancellationToken
                                  .ThrowIfCancellationRequested();

                        }), cts.Token);
                }
                catch (OperationCanceledException)
                {
                    toolStripStatusLabel1.Text = "キャンセル";
                }
            }

            foreach (string result in results)
            {
                label1.Text += result;
            }

            // using を抜けて CancellationTokenSource が Dispose されても
            // すぐには null にならないので、再度キャンセルボタンをクリック
            // すると cts.Cancel() で例外がスローされる。その対応
            cts = null;
        }

        // 画像の [Cancel] クリックのハンドラ
        private void Cancel_Click(object sender, EventArgs e)
        {
            if (cts == null) return;

            cts.Cancel();
        }
    }
}

環境は Windows 10 v21H1, Visual Studio Cummunity 2019 v16.10.3 で .NET Framework 4.8 および .NET 5.0 の両方で試しました。

Tags: , , ,

.NET Framework

HttpClient のキャンセルは要求の中断に相当? (CORE)

by WebSurfer 2021年7月13日 12:31

下の画像のシステムで、クライアントがブラウザを操作して要求を中断した場合、Web API でのサーバーで実行中の処理をキャンセルできるでしょうか? 自分が検証した限りではできるようです。その詳細を以下に書きます。

システム構成

クライアントがブラウザを使って MVC にアクセスすると、MVC のサーバーは HttpClient クラスを使って Web API にアクセスして必要な情報を取得し、ブラウザに応答として返すというシステムです。

クライアントが要求を送信した後待ちきれなくなって、サーバーによる処理が終わって応答が返ってくる前に要求を中断した場合、それ以上サーバーのリソースを消費しないで済むよう、サーバー側の処理を MVC でも Web API でも中断できるかがポイントです。

なお、クライアントによる要求の中断とは、下の画像の赤丸印の中のブラウザの ✕ ボタンをクリックするとか Esc キーを押す、Ajax を使っての要求の場合は XMLHttpRequest.abort() メソッドを実行することを意味します。

要求の中断

先の記事「要求の中断による処理のキャンセル (CORE)」に書きましたように、処理のキャンセルには HttpContext.RequestAborted プロパティで取得できる CancellationToken を利用します。クライアントが要求を中断すると、取得した CancellationToken がキャンセル通知を配信しますので、それをリッスンして処理の中断を行います。

ブラウザ ⇔ MVC の間は、先の記事に書いたように、MVC のアクションメソッドの引数に渡された CancellationToken によるキャンセル通知を利用して MVC のサーバー内での処理を中断できます。

その先の MVC ⇔ Web API の間は MVC のサーバーから HttpClient クラスを利用して Web API にアクセスするというシステムですが、そこがどうできるかをこの記事の下の方に載せた検証用のコードを使って調べてみました。

MVC のアクションメソッドでは次のようにします。HttpClient クラスの SendAsync メソッドや PostAsync メソッドには引数に CancellationToken を取るオーバーロードがあるので、それに HttpContext.RequestAborted プロパティで取得できる CancellationToken を渡します。そうすることで、クライアントによる要求の中断で SendAsync メソッドや PostAsync メソッドの実行をキャンセルできます。

Web API のアクションメソッドの引数にも Web API のサーバー内で HttpContext.RequestAborted プロパティで取得できる CancellationToken を渡します。そのキャンセル通知をリッスンして処理を中断します。

そうした場合、MVC のサーバー内で SendAsync メソッドや PostAsync メソッドの実行がキャンセルされると、Web API のアクションメソッドの引数に渡した CancellationToken はキャンセル通知を配信してくれるかが問題です。

下に載せたコードで検証した結果 Web API の CancellationToken もキャンセル通知を配信してくれることが分かりました。

なので、ブラウザ ⇔ MVC ⇔ Web API という構成でも、適切に CancellationToken を渡してキャンセル通知で処理の中断を行う実装をしておけば、ブラウザで要求を中断しても、MVC でも Web API でもサーバー内の処理を中断できるようです。

参考に検証に使った MVC および Web API のコードを以下に載せておきます。.NET 5.0 の ASP.NET Core アプリで MVC と Web API のプロジェクトは異なります (検証の際のホストが異なりますので、HttpContext.RequestAborted プロパティで取得できる CancellationToken は MVC と Web API で違うものになります)。

MVC(MvcCore5App4)

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MvcCore5App4.Models;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;

namespace MvcCore5App4.Controllers
{
    public class HomeController : Controller
    {        
        private readonly ILogger<HomeController> _logger;
        private readonly IHttpClientFactory _clientFactory;

        public HomeController(ILogger<HomeController> logger,
                              IHttpClientFactory clientFactory)
        {
            _logger = logger;
            _clientFactory = clientFactory;
        }

        // ・・・中略・・・

        public async Task<IActionResult> Cancel(CancellationToken token)
        {
            HttpClient client = _clientFactory.CreateClient();
            var url = "https://localhost:44398/api/values";

            // GET 要求する場合はこちら
            //var request = new HttpRequestMessage(HttpMethod.Get, url);
            //HttpResponseMessage response = 
            //                await client.SendAsync(request, token);

            // POST 要求する場合はこちら
            HttpResponseMessage response =
                            await client.PostAsync(url, null, token);

            if (response.IsSuccessStatusCode)
            {
                string result = 
                    await response.Content.ReadAsStringAsync(token);
                ViewBag.Result = result;
            }

            return View();
        }
    }
}

Web API (MvcCore5App2)

using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using System.Threading;
using System.Threading.Tasks;
using System;

namespace MvcCore5App2.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ValuesController : ControllerBase
    {
        private readonly ILogger<ValuesController> _logger;

        public ValuesController(ILogger<ValuesController> logger)
        {
            _logger = logger;
        }

        [HttpGet]
        public async Task<IActionResult> Get(CancellationToken token)
        {
            _logger.LogInformation($"start: {DateTime.Now:ss.fff}");
            await Task.Delay(5000, token);
            _logger.LogInformation($"end: {DateTime.Now:ss.fff}");
            return Ok("GET 処理完了");
        }

        [HttpPost]
        public async Task<IActionResult> Post(CancellationToken token)
        {
            _logger.LogInformation($"start: {DateTime.Now:ss.fff}");
            await Task.Delay(5000, token);
            _logger.LogInformation($"end: {DateTime.Now:ss.fff}");
            return Ok("POST 処理完了");
        }
    }
}

なお、上記のように キャンセルができるのは、IIS を使ったインプロセス ホスティング モデルに限った話ですので注意してください。

インプロセス ホスティング モデル

先の記事「要求の中断による処理のキャンセル (CORE)」に書きましたように、アウトプロセス ホスティング モデルや Linux 系の OS で Nginx とか Apache をリバースプロキシに使う場合は CancellationToken のキャンセル通知を配信できませんので、サーバーでの処理の中断はできません。

それから、データベースサーバーを相手にする場合、Entity Framework で使う ToListAsync とか SaveChangesAsync などではどうなるかですが、そこはまだ調べ切れていません。走り出したらキャンセルは効かないということもあるかもしれません。今後の検討課題にしたいと思います。

Tags: , , ,

CORE

要求の中断による処理のキャンセル (MVC5)

by WebSurfer 2021年7月12日 10:30

.NET Framework 版の ASP.NET Web アプリで、クライアントによる要求の中断を検出してサーバー側の処理をキャンセルする話を書きます。(ASP.NET Core の場合は先の記事「要求の中断による処理のキャンセル (CORE)」を見てください)

要求の中断

クライアントによる要求の中断とは、上の画像の赤丸印の中のブラウザの ✕ ボタンをクリックするとか Esc キーを押す、Ajax を使っての要求の場合は XMLHttpRequest.abort() メソッドを実行することを意味します。

ASP.NET 4.5 以降には HttpResponse.ClientDisconnectedToken プロパティが追加されています。このプロパティで取得できる CancellationToken によって、クライアントが要求の中断操作を行った場合に操作を取り消す通知を配信できます。(ASP.NET Core の HttpContext.RequestAborted プロパティと同様)

ただし、ASP.NET 4.5 以降という条件以外にも、上に紹介した Microsoft のドキュメントに書いてあるように IIS 7.5 以降の統合モードでのみサポートされているということですので注意してください

Visual Studio 2019 を使って、以下のコードを [デバッグ(D)] ⇒ [デバッグの開始(S)] で IIS 10 Express で実行して検証してみました。検証に使用したブラウザは Edge v91.0.864.67, Chrome v91.0.4472.124, Firefox v89.0.2, IE11, Opera v77.0.4054.203 で、いずれもクライアントによる要求の中断によってサーバー側での処理の中断が確認できました。

using System;
using System.Web.Mvc;
using System.Threading.Tasks;
using System.Diagnostics;

namespace Mvc5App2.Controllers
{
    public class HomeController : Controller
    {

        // ・・・中略・・・

        public async Task<ActionResult> Cancel()
        {
            var token = Response.ClientDisconnectedToken;

            Debug.WriteLine($"start: {DateTime.Now:ss.fff}");
            await Task.Delay(5000, token);
            Debug.WriteLine($"end: {DateTime.Now:ss.fff}");
            return View();
        }
    }
}

ASP.NET Core MVC と違ってアクションメソッドの引数に CancellationToken を追加しても ClientDisconnectedToken プロパティで取得される CancellationToken はバインドされないので注意してください。なので、上のコードのようにする必要があります。(引数に追加すると CancellationToken がバインドされますが、それは ClientDisconnectedToken プロパティで取得できるものとは別物のようで要求の中断による通知は出ません)

上のコードの Task.Delay(5000, token) では渡した token を Deley メソッドの中で継続的に観察しているようで、このメソッドが実行が開始された���でもそれから 5 秒以内ならブラウザで要求を中断すると TaskCanceledException がスローされて処理が終わります。

ただし、Entity Framework を使ってデータベースにアクセスして処置を行うときに使う ToListAsync とか SaveChangesAsync などや、HttpClient の SendAsync とか PostAsync なども同様かどうかは調べてなくて分かりません。今後の検討課題ということで・・・

Tags: , , ,

ASP.NET

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。

Calendar

<<  2024年4月  >>
31123456
78910111213
14151617181920
21222324252627
2829301234
567891011

View posts in large calendar