Windows Forms アプリでのデリゲートを利用した非同期メソッドの実装について備忘録を書いておきます。.NET Framework 4.5 で async / await / Task.Run が利用できる今はデリゲートを使うことはこの先もうないのかもしれませんが。
非同期メソッド実装の変遷については @IT の記事「第1回 .NET開発における非同期処理の基礎と歴史」にまとめられています。デリゲートを使う方法とはその記事の中の「Asynchronous Programming Model(非同期プログラミング・モデル)」です。
上の記事では概要しか書いてなくて分かり難いと思います。詳しく知りたい方は、かなり古い記事ですが同じく @IT の「第2回 .NETにおけるマルチスレッドの実装方法の総括 (1/4)」を見ていただいた方が良く理解できると思います。
上の画像は、以下の同期メソッドを、(1) そのまま同期呼び出し、(2) delegate を利用した非同期呼び出し、(3) async / await / Task.Run を利用した非同期呼び出しを行う windows forms アプリを実行したものです。
// テスト用の時間がかかるメソッド
private string TimeCosumingMethod(string s)
{
if (string.IsNullOrEmpty(s))
{
throw new ArgumentException("引数が無い");
}
Thread.Sleep(3000);
return s + " + ManagedThreadId: " +
Thread.CurrentThread.ManagedThreadId;
}
delegate を利用した非同期呼び出しの場合、(a) EndInvoke を行う対象となるデリゲートの取得、(b) UI スレッドに戻り値を渡す、(c) 例外の捕捉が課題になると思います。
具体例は以下のコードを見てください。上の課題 (a) ~ (c) についてはコールバックメソッド MyCallBack 内で行っています。詳しくはコード内にコメントで書きましたのでそれを見てください。(手抜きでスミマセン)
using System;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Threading;
using System.Runtime.Remoting.Messaging;
namespace WindowsFormsAsyncTest
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
this.textBox1.Text = "呼び出されたメソッド内の";
}
// テスト用の時間がかかるメソッド
private string TimeCosumingMethod(string s)
{
if (string.IsNullOrEmpty(s))
{
throw new ArgumentException("引数が無い");
}
Thread.Sleep(3000);
return s + " ManagedThreadId: " +
Thread.CurrentThread.ManagedThreadId;
}
// 同期呼び出し
private void button1_Click(object sender, EventArgs e)
{
this.label1.Text =
"Button.Click イベントハンドラ内の ManagedThreadId: "
+ Thread.CurrentThread.ManagedThreadId + " (IN) / ";
this.label2.Text = "";
// Application.DoEvents で上の文字列を即ラベルに反映
Application.DoEvents();
try
{
this.label2.Text = TimeCosumingMethod(this.textBox1.Text);
}
catch (ArgumentException ex)
{
MessageBox.Show(ex.Message);
throw;
}
this.label1.Text +=
Thread.CurrentThread.ManagedThreadId + " (OUT)";
}
// delegate を利用した非同期呼び出し
// 別スレッドとして処理したいメソッドをデリゲート宣言
delegate string MyDelegate(string s);
// 別スレッドによる処理が終了したことをトリガーとして自動的に
// 呼び出されるコールバックメソッド。
// スレッドは p.BeginInvoke によって TimeCosumingMethod メソ
// ッドが実行されるスレッドと同じになる
private void MyCallBack(IAsyncResult ar)
{
// 課題 (a) コールバックで EndInvoke を行う対象となる
// デリゲートの取得
MyDelegate p = (MyDelegate)((AsyncResult)ar).AsyncDelegate;
string result = "";
// 課題 (c) 例外の捕捉
// コールバックの中でしか例外は捕捉できないので注意
try
{
result = p.EndInvoke(ar);
}
catch (ArgumentException ex)
{
// UIスレッドで MessageBox を表示
this.Invoke((Action)(() => MessageBox.Show(ex.Message)));
throw;
}
// コールバックのスレッドを確認(TimeCosumingMethod の
// スレッドと同じになる)
result += " / " +
Thread.CurrentThread.ManagedThreadId + " (CallBack)";
// 課題 (b) UI スレッドに戻り値を渡す
// Invokeメソッドで UIスレッドの Label に文字列を設定
this.Invoke((Action)(() => this.label2.Text = result));
}
private void button2_Click(object sender, EventArgs e)
{
this.label1.Text =
"Button.Click イベントハンドラ内の ManagedThreadId: "
+ Thread.CurrentThread.ManagedThreadId + " (IN) / ";
this.label2.Text = "";
// デリゲートのインスタンスを作成
MyDelegate p = new MyDelegate(TimeCosumingMethod);
// デリゲートによるスレッド処理呼び出し
p.BeginInvoke(this.textBox1.Text, this.MyCallBack, null);
// TimeCosumingMethod メソッドの終了は待たず即実行される
this.label1.Text +=
Thread.CurrentThread.ManagedThreadId + " (OUT)";
}
// async/await/Task を使った非同期呼び出し
private async void button3_Click(object sender, EventArgs e)
{
this.label1.Text =
"Button.Click イベントハンドラ内の ManagedThreadId: "
+ Thread.CurrentThread.ManagedThreadId + " (IN) / ";
this.label2.Text = "";
try
{
this.label2.Text = await Task.Run(() =>
TimeCosumingMethod(this.textBox1.Text));
}
catch (ArgumentException ex)
{
MessageBox.Show(ex.Message);
throw;
}
// await キーワードにより TimeCosumingMethod メソッドが
// 終了するのを待って実行される
this.label1.Text +=
Thread.CurrentThread.ManagedThreadId + " (OUT)";
}
}
}
async/await を利用した場合と delegate を利用した場合の大きな違いは、前者は await キーワードによって TimeCosumingMethod メソッドが終了するのを待つのに対して(ただし、呼び出し側の UI スレッドのメッセージループは処理されるのでフリーズはしない)、後者は待たない(TimeCosumingMethod メソッドの結果の UI スレッドへの反映は コールバック で行わざるを得ない)・・・ということです。
上のコードの button2_Click, button3_Click メソッド内の最後のコードを見てください。コメントに書いた通り、async/await を利用した場合は TimeCosumingMethod メソッドが終了するのを待って Label に追記され、delegate を利用した場合は即追記されます。
集約的例外ハンドラによる例外処置の話も以下に備忘録として書いておきます。
delegete を利用した非同期呼び出しの例外の処置については、上のコードのコールバックメソッド MyCallBack 内で完了できれば良いのですが、処理しないで集約的例外ハンドラで捕捉してからアプリケーションを終了させるというケースも多々あると思います。
それをどうするかについては以下の Main メソッドのコードを見てください。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Threading;
namespace WindowsFormsClient
{
static class Program
{
[STAThread]
static void Main()
{
// 捕捉されなかった例外を処理する集約的例外ハンドラを実装
// 同期呼出と async/await を使った非同期呼び出しの例外は
// Application.ThreadException イベントで捕捉
Application.ThreadException +=
new ThreadExceptionEventHandler(ThreadException);
// delegate を使った非同期呼び出しの例外の捕捉
// Application.ThreadException では UI スレッドの例外しか
// 捕捉できないので AppDomain.UnhandledException で捕捉
AppDomain currentDomain = AppDomain.CurrentDomain;
currentDomain.UnhandledException += UnhandledException;
// だだし、上記では同期呼出と async/await を使った非同期呼
// び出しの例外の処理が期待通りにならない。デバッグ実行す
// ると捕捉できているように見えるが MessageBox が出ない。
// 理由不明。同期、delegate、async/await 全部に対応するに
// は両方のハンドラを生かしておく必要がある。
// アプリケーション構成ファイルの設定を無視し、UI スレッド
// の例外は常に ThreadException ハンドラに送る
Application.SetUnhandledExceptionMode(
UnhandledExceptionMode.CatchException);
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form2());
}
// Application.ThreadException のハンドラ
private static void UnhandledException(object sender,
UnhandledExceptionEventArgs e)
{
Exception ex = (Exception)e.ExceptionObject;
MessageBox.Show("AppDomain のハンドラ\n" + ex.Message);
Application.Exit();
}
// AppDomain.UnhandledException のハンドラ
static void ThreadException(object sender,
ThreadExceptionEventArgs e)
{
Exception ex = e.Exception;
MessageBox.Show("Application のハンドラ\n" + ex.Message);
// Environment.Exit を使わないとダイアログが出るとの記事が
// あったが、このサンプルでは Application.Exit で問題なし
Application.Exit();
}
}
}
Microsoft のドキュメント Control.BeginInvoke Method に "Exceptions within the delegate method are considered untrapped and will be sent to the application's untrapped exception handler." と書いてあります。
デリゲートメソッド内で発生した例外を捕捉するには AppDomain.UnhandledException イベントのハンドラを利用します。Application.ThreadException イベントは UI スレッドで発生した例外しか捕捉できません。
なので、(1) 同期、(2) delegate、(3) async / await の全てのケースで集約的例外処理を行うには、(1) と (3) は Application.ThreadException イベントのハンドラで、(2) は AppDomain.UnhandledException イベントのハンドラで処理できるように両方のハンドラを設定するのが正解のようです。