by WebSurfer
27. September 2020 16:38
.NET Framework 4.5 以降で利用できる async / await を使って非同期で実行されるタスクをキャンセルする方法についていろいろ調べたので備忘録として書いておきます。

基本は Microsoft のドキュメント「マネージド スレッドのキャンセル」に書いてあります。が、それを読んだだけでは自分の頭では理解できなかったので、実際に自分の手を動かしてコードを書いて試してみました。
Microsoft のドキュメントに書いてありますが、キャンセル処理を実装するための一般的なパターンは以下の通りだそうです。
-
CancellationTokenSource クラスのインスタンスを作成する。
-
CancellationTokenSource.Token プロパティで CancellationToken を取得し、キャンセルをリッスンするタスクに渡す。
-
タスクにはキャンセル通知を適切に処置するコードを実装しておく。
-
CancellationTokenSource.Cancel メソッドを呼び出し、リッスンしているタスクにキャンセルを通知する。
-
キャンセル通知を受けたタスクは、あらかじめ実装されているコードに従ってキャンセル処置を行う。
タスクがキャンセルの通知を受けとるには、(1) ポーリング、(2) コールバックの登録、(3) 待機ハンドルの待機という方法があるそうです。
参考にした Microsoft のドキュメントには (1) ~ (3) のコード例の紹介がありますが、コンソールアプリのものでちょっとピンとこなかったので、Windows Forms アプリで実装してみました。
ベースは @IT の記事「WPF/Windowsフォーム:時間のかかる処理をバックグラウンドで実行するには?」のコードです。それに手を加えて[キャンセル]ボタンクリックでキャンセルできるようにしています。
コードは以下の通りです。この記事の上に貼った画像を表示したものです。
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace WindowsFormsAsyncTest
{
public partial class Form4 : Form
{
private CancellationTokenSource cts = null;
public Form4()
{
InitializeComponent();
EableButtons();
toolStripStatusLabel1.Text = "";
toolStripProgressBar1.Value = 0;
}
// (1) ポーリングによるリッスン。進捗表示なし
private async void button1_Click(object sender, EventArgs e)
{
DisableButtons();
toolStripStatusLabel1.Text = "処理中…";
toolStripProgressBar1.Value = 0;
string result = "";
bool isCanceled = false;
using (this.cts = new CancellationTokenSource())
{
CancellationToken token = this.cts.Token;
try
{
result = await DoWorkAsync(100, token);
}
catch (OperationCanceledException)
{
isCanceled = true;
}
}
// 処理結果の表示
if (isCanceled)
{
toolStripStatusLabel1.Text = "キャンセル";
toolStripProgressBar1.Value = 0;
}
else
{
toolStripStatusLabel1.Text = result;
toolStripProgressBar1.Value = 100;
}
EableButtons();
}
// 時間のかかる処理を行うメソッド
// ポーリングによるリッスン用。進捗表示なし
private async Task<string> DoWorkAsync(
int n,
CancellationToken token)
{
for (int i = 1; i <= n; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(100);
}
return "全て完了";
}
// (2) ポーリングによるリッスン。進捗表示あり
private async void button2_Click(object sender, EventArgs e)
{
DisableButtons();
var p = new Progress<int>(ShowProgress);
using (this.cts = new CancellationTokenSource())
{
CancellationToken token = this.cts.Token;
try
{
await DoWorkAsync(p, 100, token);
}
catch (OperationCanceledException)
{
// 必要なら何らかの処置
}
}
EableButtons();
}
// 時間のかかる処理を行うメソッド
// ポーリングによるリッスン用。進捗表示あり
private async Task<string> DoWorkAsync(
IProgress<int> progress,
int n,
CancellationToken token)
{
for (int i = 1; i <= n; i++)
{
token.ThrowIfCancellationRequested();
await Task.Delay(100);
int percentage = i * 100 / n; // 進捗率
progress.Report(percentage);
}
return "全て完了";
}
// 進捗をプログレスバーとラベルに表示するコールバック
// UIスレッドで呼び出される
private void ShowProgress(int percent)
{
toolStripStatusLabel1.Text = percent + "%完了";
toolStripProgressBar1.Value = percent;
}
// キャンセルボタン
private void button3_Click(object sender, EventArgs e)
{
if (this.cts == null) return;
cts.Cancel();
}
// (3) コールバックの登録によるリッスン
// Microsoft のドキュメントをまねて Web アクセスをシミュ
// レートする自作クラス MyWebClient を定義して使う。
// MyWebClient の定義はこのサンプルコードの下の方にあり
private MyWebClient client;
private async void button4_Click(object sender, EventArgs e)
{
DisableButtons();
// Timer(デザイン画面で Form にドラッグ&ドロップした
// もの)を利用して定期的に進捗状況を取得して表示。
//
// System.Windows.Forms.Timer を利用するにはメッセージ
// ループの実行が必要なので注意。メッセージループは UI
// スレッドで実行される。await は UI スレッドをブロック
// しない。従い、await で待機中でも Timer は動く
this.timer1.Interval = 100;
using (this.cts = new CancellationTokenSource())
{
CancellationToken token = this.cts.Token;
this.client = new MyWebClient();
token.Register(async () => {
await this.client.CancelAsync();
this.timer1.Stop(); }) ;
this.timer1.Start();
string result = await this.client.GetStringAsync(100);
// キャンセルせず完了する場合 Timer はここで止めること。
// 止めると、プログレスバーの表示も 100 になる前に止ま
// ってしまうので、ここで設定が必要。
if (this.client.Progress >= 100)
{
this.timer1.Stop();
toolStripStatusLabel1.Text = this.client.Progress + "%完了";
toolStripProgressBar1.Value = this.client.Progress;
}
}
EableButtons();
}
// 進捗を表示するためのイベントハンドラ
private void timer1_Tick(object sender, EventArgs e)
{
// ここは UI スレッドで実行される
toolStripStatusLabel1.Text = this.client.Progress + "%完了";
toolStripProgressBar1.Value = this.client.Progress;
}
// (4) 待機ハンドルを使用したリッスン
private async void button5_Click(object sender, EventArgs e)
{
DisableButtons();
var p = new Progress<int>(ShowProgress);
using (this.cts = new CancellationTokenSource())
{
CancellationToken token = this.cts.Token;
try
{
await DoWorkAsync3(p, 100, token);
}
catch (OperationCanceledException)
{
// 必要なら何らかの処置
}
}
EableButtons();
}
// 待機ハンドルを使用したリッスン用
static ManualResetEventSlim mres = new ManualResetEventSlim(false);
// 時間のかかる処理を行うメソッド
private async Task<string> DoWorkAsync3(
IProgress<int> progress,
int n,
CancellationToken token)
{
// 時間のかかる処理
for (int i = 1; i <= n; i++)
{
token.ThrowIfCancellationRequested();
try
{
// Microsoft のドキュメントをまねて mres.Wait(token); と
// するとここでフリーズしてしまう。「CancellationToken
// が信号を受信するまで、現在のスレッドをブロックします」
// ということだから当たり前か・・・
// mres.Wait(10, token); にすると 10 ミリ秒待って動くよう
// になるが、それでは意味がなさそう
mres.Wait(10, token);
}
catch (OperationCanceledException)
{
// 必要なら何らかの処置
throw;
}
await Task.Delay(100);
int percentage = i * 100 / n;
progress.Report(percentage);
}
return "全て完了";
}
// ボタンの Enable / Disable を切り替えるヘルパメソッド
private void DisableButtons()
{
button1.Enabled = false;
button2.Enabled = false;
button3.Enabled = true;
button4.Enabled = false;
button5.Enabled = false;
}
private void EableButtons()
{
button1.Enabled = true;
button2.Enabled = true;
button3.Enabled = false;
button4.Enabled = true;
button5.Enabled = true;
}
}
// Microsoft のドキュメントをまねて Web にアクセスする
// WebClient をシミュレートする自作クラス
public class MyWebClient
{
private bool isCanceled = false;
private int progress = 0;
public async Task<string> GetStringAsync(int n)
{
for (int i = 1; i <= n; i++)
{
if (isCanceled) break;
await Task.Delay(100);
progress = i * 100 / n;
}
if (isCanceled)
{
return "キャンセル";
}
else
{
return "全て完了";
}
}
public async Task CancelAsync()
{
// 通信・処理に要する時間
await Task.Delay(100);
isCanceled = true;
}
public int Progress
{
get { return progress; }
}
}
}
待機ハンドルを使用する方法については、そもそも使い方が間違っている(上のような Windows Forms アプリで使うものではない?)ような気がしますが、せっかくいろいろ考えたので、とりあえず書いておきました。
上のコードのコメントに書きましたが、Microsoft のドキュメントをまねて mres.Wait(token); を使うと、そこでブロックされてフリーズしてしまいます。mres.Wait(10, token); にすると 10 ミリ秒待って一応は動くようになるのですが、それは意味がなさそうです。今後の検討課題ということで・・・