WebSurfer's Home

Filter by APML

Task.Status が Canceled に変わるタイミング

by WebSurfer 21. August 2025 12:41

Microsoft のドキュメント「タスクの一覧を取り消す」のサンプルコードを試して気づいた話ですが、CancellationTokenSource.Cancel メソッドを実行した際、CancellationToken を受け取る非同期メソッドの Task.Status が Canceled に変わるタイミングが .NET Framework 4.8.1 と .NET 8.0 / 9.0 では異なるということがあったので、備忘録として書いておきます。

サンプルコードはこの記事の下の方に載せておきます。ターゲットフレームワークを .NET 8.0 または .NET 9.0 とした場合と、.NET Framework 4.8.1 とした場合のサンプルコードの動きの違いは、ダウンロードの途中で Enter キーを押してキャンセルをかけた時、後者では Main メソッドの中の try - catch ブロックが実行されないことです。

その原因は、タスク cancelTask の中の s_cts.Cancel(); が実行された時、CancellationToken を受け取る非同期メソッドの Task.Status が Canceled に変わるタイミングが異なるからです。

.NET Framework 4.8.1 の場合、Cancel メソッドの実行で即 Task.Status が WaitingForActivation から Canceled に変わります。

一方、.NET 8.0 / 9.0 の場合、Cancel メソッドの実行では Task.Status は WaitingForActivation のまま変わりません。Task を await して初めて Status が Canceled に変わります。

以下にサンプルコードを検証した際の詳細を書いておきます。

.NET Framework 4.8.1

下の画像は、ターゲットフレームワークを .NET Framework 4.8.1 としたプロジェクトを Visual Studio 2022 でデバッグ実行した時のものです。

.NET Framework 4.8.1 の場合

ダウンロード途中で(すなわち、SumPageSizesAsync メソッド内の foreach ループが回っているとき)Enter キーを押した結果、タスク cancelTask の中の s_cts.Cancel(); が実行され、ProcessUrlAsync メソッド内の非同期メソッドの実行がキャンセルされ、Task.WhenAny メソッドでその引数に設定されたタスク cancelTask と sumPageSizesTask の内 sumPageSizesTask が先に完了と判断され、await による待機を抜け、次の行の if 文に制御が移ったところです。

その結果、if 文の条件 finishedTask == cancelTask は false となるので try - catch ブロックは実行されません。その際に気を付けなければならないのは、await sumPageSizesTask; は実行されないので OperationCanceledException はスローされないということです。

なので、下に載せたサンプルコードのように、OperationCanceledException を catch して何らかの処置を行いたい場合、そのためのコードを追加する必要があります。

ただし、ダウンロードが完了してから(すなわち SumPageSizesAsync メソッド内の foreach ループを抜けて CancellationToken を引数に取る非同期メソッドがすべて完了してから)タスク cancelTask の中の s_cts.Cancel(); が実行された場合は様子が違ってきます。タスク cancelTask の方が先に完了したと判断され、try - catch ブロックは実行されます。(実際にそれを試すには、foreach ループの後に 1 行 await Task.Delay(3000); を追加して、そこで止まっているときに Enter キーを押してみてください)

.NET 8.0 / 9.0

ターゲットフレームワークを .NET 8.0 または 9.0 としたプロジェクトで上と同様にデバッグを行うと以下の画像のようになります。

.NET 8.0 の場合

ダウンロード途中で Enter キーを押すとタスク cancelTask 内の s_cts.Cancel(); が実行されるのですが、その時点では ProcessUrlAsync メソッド内の非同期メソッドの実行がキャンセルされないのか、タスク sumPageSizesTask の Status は WaitingForActivation になっています。一方、タスク cancelTask の Status は RanToCompletion となり、Task.WhenAny メソッドでその引数に設定されたタスク cancelTask が先に完了したと判断され、await による待機を抜け、次の行の if 文に制御が移っています。

この場合、if 分の条件 finishedTask == cancelTask は true となるので try - catch ブロックが実行されます。try 句の中の await sumPageSizesTask; により OperationCanceledException がスローされ、catch 句の中のコードが実行されます。

タスク sumPageSizesTask の Status が WaitingForActivation から Canceled に変わるのは try 句の中の await sumPageSizesTask; で await による待機が終わった時点になります。

以下の画像を見てください。上の画像の if 文でブレークポイントで止まったところからステップ実行して、await sumPageSizesTask; でスローされた OperationCanceledException を catch したところです。sumPageSizesTask の Status が Canceled に変わっています。

.NET 8.0 の場合

try 句の中の await sumPageSizesTask; の行で例外がスローされるので、その下の Console.WriteLine("Download task completed before cancel request was processed."); はスキップされます。このメッセージは「Enter キーが押されたがキャンセルが間に合わずダウンロードが完了してしまった」という意味なのですが、手動で Enter キーを押してこのメッセージを表示するのは無理があるので注意してください。

このメッセージが表示されることを試すには、SumPageSizesAsync メソッド内の foreach ループの後に 1 行 await Task.Delay(3000); を追加して、そこで止まっているときに Enter キーを押してみてください。その時点では CancellationToken を渡された非同期メソッドの実行は終わっているので、タスク cancelTask の中の s_cts.Cancel(); の実行では OperationCanceledException 例外はスローされません。結果、await sumPageSizesTask; の下の行の Console.WriteLine("Download task completed before cancel request was processed."); が実行されます。


.NET Framework 4.8.1 の場合の s_cts.Cancel(); の実行で CancellationToken を渡された非同期メソッドの実行が即キャンセルされるという動きの方が自分としては納得できるのですが、何らかの理由で .NET 8.0(もっと前からかも)で変更したのでしょうか?

そのあたりの説明がある Microsoft のドキュメントは見つけられませんでしたが、とにかく違いがあるということは覚えておいた方が良さそうと思って、備忘録として残しておくことにした次第です。


以下に、上に述べた検証に用いた Microsoft ドキュメントのサンプルコードを載せておきます。

using System.Diagnostics;

class Program
{
    static readonly CancellationTokenSource s_cts = new CancellationTokenSource();

    static readonly HttpClient s_client = new HttpClient
    {
        MaxResponseContentBufferSize = 1_000_000
    };

    static readonly IEnumerable<string> s_urlList = new string[]
    {
            "https://learn.microsoft.com",
            "https://learn.microsoft.com/aspnet/core",
            "https://learn.microsoft.com/azure",
            "https://learn.microsoft.com/azure/devops",
            "https://learn.microsoft.com/dotnet",
            "https://learn.microsoft.com/dynamics365",
            "https://learn.microsoft.com/education",
            "https://learn.microsoft.com/enterprise-mobility-security",
            "https://learn.microsoft.com/gaming",
            "https://learn.microsoft.com/graph",
            "https://learn.microsoft.com/microsoft-365",
            "https://learn.microsoft.com/office",
            "https://learn.microsoft.com/powershell",
            "https://learn.microsoft.com/sql",
            "https://learn.microsoft.com/surface",
            "https://learn.microsoft.com/system-center",
            "https://learn.microsoft.com/visualstudio",
            "https://learn.microsoft.com/windows",
            // "https://learn.microsoft.com/maui" 404 エラーになるのでコメントアウト
    };

    static async Task Main()
    {
        Console.WriteLine("Application started.");
        Console.WriteLine("Press the ENTER key to cancel...\n");

        Task cancelTask = Task.Run(() =>
        {
            while (Console.ReadKey().Key != ConsoleKey.Enter)
            {
                Console.WriteLine("Press the ENTER key to cancel...");
            }

            Console.WriteLine("\nENTER key pressed: cancelling downloads.\n");
            s_cts.Cancel();
        });

        Task sumPageSizesTask = SumPageSizesAsync();

        Task finishedTask = await Task.WhenAny(new[] { cancelTask, sumPageSizesTask });
        if (finishedTask == cancelTask)
        {
            // wait for the cancellation to take place:
            try
            {
                await sumPageSizesTask;
                Console.WriteLine("Download task completed before cancel request was processed.");
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine("Download task has been cancelled.");
            }
        }

        Console.WriteLine("Application ending.");
    }

    static async Task SumPageSizesAsync()
    {
        var stopwatch = Stopwatch.StartNew();

        int total = 0;
        foreach (string url in s_urlList)
        {
            int contentLength = await ProcessUrlAsync(url, s_client, s_cts.Token);
            total += contentLength;
        }

        stopwatch.Stop();

        Console.WriteLine($"\nTotal bytes returned:  {total:#,#}");
        Console.WriteLine($"Elapsed time:          {stopwatch.Elapsed}\n");
    }

    static async Task<int> ProcessUrlAsync(string url, 
                                                 HttpClient client, 
                                                 CancellationToken token)
    {
        HttpResponseMessage response = await client.GetAsync(url, token);

        // .NET Framework 4.8.1 の ReadAsByteArrayAsync には
        // CancellationToken を引数に取るオーバーロードはない
        // ので下の引数 token は削除すること
        byte[] content = await response.Content.ReadAsByteArrayAsync(token);

        Console.WriteLine($"{url,-60} {content.Length,10:#,#}");

        return content.Length;
    }
}

Tags: , , , ,

.NET Framework

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

by WebSurfer 17. July 2021 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 13. July 2021 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

About this blog

2010年5月にこのブログを立ち上げました。主に ASP.NET Web アプリ関係の記事です。ブログ2はそれ以外の日々の出来事などのトピックスになっています。

Calendar

<<  November 2025  >>
MoTuWeThFrSaSu
272829303112
3456789
10111213141516
17181920212223
24252627282930
1234567

View posts in large calendar