タスク並列ライブラリ (TPL) の Parallel.For メソッドの複数の並列処理をキャンセルするコードを書いているときにハマって悩んだので、今後そういうことがないよう備忘録を書いておきます。
ちなみにコードはこの記事の下の方に記載したもので、Microsoft のドキュメント「方法: Parallel.For または ForEach ループを取り消す」を参考にキャンセル処置を実装しました。Windows Forms アプリなので UI スレッドをブロックしないようにしている点が違いますが、基本的には同じです。
で、何にハマったかと言うと、Visual Studio から[デバッグ(D)]⇒[デバッグの開始(S)]でアプリを実行すると try - catch で OperationCanceledException を捕捉できないということです。
下の画像を見てください。try - catch 構文で try 句内で発生した OperationCanceledException を catch 句があるにもかかわらず捕捉できていません。(実はそこは思い違いだったのですが。詳細後述)
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 の両方で試しました。