Part 3. タスクスレッドと UI の協調動作
さて、前回の Part 2. のエントリでは、タスクスレッド(UI の背後で動作させる処理を動作させるスレッド、すなわちマニュアルスレッドやプールスレッドの総称)の様々な起動方法について解説しました。主な方法として、以下の 4 つの方法がありました。
- マニュアルスレッドの新規作成
- プールスレッドの利用
- 非同期デリゲートの利用
- タイマの利用
さて、いずれの方法を利用する場合であっても守る必要のあるルールとして、UI スレッド以外から UI コントロールの読み書きをしてはならない、というものがありました。
ここまで実際にプログラミングをしてみた方は感じられていると思うのですが、実はこの制限はかなり厄介です。例えば、以下のような処理を簡単に書くことはできません。
- 背後で行っているタスク処理の進捗状況や完了結果を、UI 上に表示する。
(これはすでに解説済み。.BeginInvoke() 処理が必要。) - 背後で行っているタスク処理から、UI 上のコントロールのプロパティを読み出す。
(タスクスレッドから、textBox1.Text プロパティを読み出すことは NG だがどうすればよい?) - 背後で行っているタスク処理を、UI 上のキャンセルボタン押下により中断させる。
(どうやって UI のイベントハンドラから背後のスレッドに通知を行えばよいのか?) - 背後で行っているタスクスレッド上で発生した例外の情報を、UI 上に表示する。
(どうやってタスクスレッドで発生した未処理例外を UI に通知すればよいのか?)
マルチスレッド処理を行う Windows フォームアプリケーションを書く上では、上記のような問題が発生したときの対処方法を知っておく必要があります。本エントリでは、これらについて解説します。
- タスクスレッドからの UI 画面の更新方法
- タスクスレッドからの UI 画面上のデータの読み取り方法
- UI 画面からのタスクスレッドの制御方法
- タスクスレッド上で発生した未処理例外の取り扱い方法
本エントリのサンプルコードはこちらになります。
では、以下に順に解説していきます。
[タスクスレッドからの UI 画面の更新方法]
さて、まずはおさらいです。タスクスレッド(マニュアルスレッドやプールスレッド)から UI 画面を更新する際には、タスクスレッドからコントロールのプロパティを直接いじってはいけません。このような場合には、BeginInvoke() 命令を利用し、メッセージキューに処理要求を投入する必要があります。具体的な作業手順は以下の通りです。
- UI 画面更新用の private メソッドを作成する。
画面更新に必要なデータをパラメータとして受け取るように書いておきます。(注意点は後述) - それにあわせた形で private デリゲートを定義します。
名称は任意ですが、××Delegate といった名前で定義しておくと便利です。 - タスクスレッドから BeginInvoke() 命令でメソッド呼び出しを実施する。
パラメータは object[] 配列として引き渡します。
なお、.BeginInvoke() 命令はどのコントロールに対して発行しても OK ですが、通常は親フォームオブジェクト(= this)に対して発行するのが便利です。
なお、関連する注意点として、以下についても知っておくと便利です。
① 現在のスレッドが UI スレッドかどうかは、InvokeRequired プロパティでチェックすることができる。
例えば上記の LongTask() メソッドは、マニュアルスレッド上だけでしか動作させられないかというとそんなことはなく、書き方や呼び方次第では、プールスレッド上や UI スレッド上で動作させることもできます。このため、あるメソッドが UI スレッド上で呼び出されることも、マニュアルスレッドやプールスレッド上で呼び出されることも、どちらもありうるような場合には、.InvokeRequired プロパティを確認することで、UI スレッド上で動作しているか否かを確認することができます。
ただし実際には、そもそも上記のようなチェックが必要とならないようにすることが望ましいです。ここまで取り扱ってきたサンプルは、必ず、あるメソッドはマニュアル/プール/UI のどれかの上でしか動作しないように設計してきました。実際、UI スレッドで行うべき処理とタスクスレッドで行うべき処理は異なっているのが当然で、UI スレッド/タスクスレッド両用になるようなメソッドというのは、(ユーティリティ的な処理を除けば)あまりないはずです。
UI フォームを設計・実装する際には、どのメソッドがどのスレッド上で動くのかを明確に意識しながら作業することが非常に重要です。InvokeRequired プロパティを使わなければならなくなった場合には、そもそもアプリケーションの設計としての是否をきちんと確認するようにしてください。
② 基本データ型及びイミュータブルオブジェクト以外を引き渡す場合には、オブジェクトの同期制御が必要になる。
UI スレッドからタスクスレッドを起こす場合でも、またタスクスレッドから UI スレッドへ制御を戻す場合でも、どちらでも共通する内容ですが、一般に、オブジェクトを引数として渡す場合には、参照渡しが行われます。例えば、下図のように、タスクスレッドから UI スレッドに StringBuilder の変数を引き渡すと、同一インスタンスがマニュアルスレッドと UI スレッドの両方から操作されることになります。
当然ですが、このような処理を行うと、同一インスタンスを同時に複数スレッドから操作することになり、データが破損することになります。よって、このようなコードは書いてはいけません。StringBuilder のインスタンスに対して同期制御を行うか、インスタンスをコピー(ディープコピー)して渡す必要があります。
このことからわかるように、UI スレッドとタスクスレッド間で、引数として基本データ型やイミュータブルオブジェクト以外のデータを引き渡す場合には、オブジェクトの同期制御が必要になります。(データ変数の同期制御に関しては、こちらとこちらのエントリに詳しく書いてありますので、よくわからないという方は確認してみてください。)
では引き続き、タスクスレッドからの UI 画面上のデータの読み取り方法について解説します。
[タスクスレッドからの UI 画面上のデータの読み取り方法]
上記では、タスクスレッドから UI 画面上へのデータ表示について考えたわけですが、実は、タスクスレッドから UI 画面上のデータを読み取ることも NG です。
UI コントロールを操作するためには、UI スレッドからの操作が必要になり、そのためにはメッセージキューへのメッセージ投入が必要....なのですが、ここまで解説してきた BeginInvoke() 命令では、メッセージを投入した後「やりっぱなし」の状態になってしまい、コントロールから値を読み取るなどした後の結果値を受け取ることができません。このような場合には、.Invoke() 命令を利用します。これを利用すると、UI スレッド上で行われた処理の結果を、タスクスレッド側で受け取ることができます。具体的な実装例を以下に示します。
なお、この .Invoke() 命令による、「UI スレッド上での処理結果の受け取り」はむやみに利用しないようにしてください。.BeginInvoke() 命令と異なり、.Invoke() 命令は、投入したメッセージが UI スレッド上で処理されるのを同期的に待つ(=タスクスレッド側は UI スレッド上での処理の終了を待機する)形になります。このため、以下のようなコードを書くとハングアップする危険性があります。
- 例1. Invoke() 命令で呼び出したメソッド内から、さらに別のスレッドを起こす ⇒ スレッドプールの枯渇や無限ループの発生
- 例2. 共有データに対して lock を取得している最中に Invoke() 命令を利用し、呼び出し先で当該データを操作 ⇒ デッドロックの発生
このようなことから、以下のルールを守ることをおすすめします。
- .Invoke() 命令は極力使わないようにする。
- 使う場合であっても、UI 上のプロパティを読み取ったらすぐに終了する、といった極力単純なコードのみを書くようにする。
- ロックを取得している最中に .Invoke() を呼び出さない。
では引き続き、UI 画面からのタスクスレッドの制御方法について解説します。
[UI 画面からのタスクスレッドの制御方法]
タスクスレッド上で時間を要する処理を起動した場合、UI からそのタスクを制御したいと思うことがよくあります。例えば、
- キャンセルボタンを押したら、背後で行っている処理を中断したい。
- チェックボックスやラジオボタンなどを押すと、背後で行っている処理が変更されるようにしたい。
といったことはしばしばあります。しかし、このような処理を作り込むのは意外に厄介です。なぜなら、UI からタスクスレッドで行われている処理に対して、割り込みをかけるような形で強制通知を行うことはできないからです。
例えば、以下のような画面を作る場合を考えてみます。
タスクスレッドでの処理を止めるため、キャンセルボタンを押したら、背後で行っている処理が中断されるように、Thread.Abort() 命令を使ったとします。この機能を使うと、タスクスレッドの処理を強制的に中断することができますが、そのスレッドがどんな状態であるかを全く無視して強制中断してしまうため、場合によっては共有データ変数の破損などの問題を引き起こすリスクがあります。このため、タスクスレッドへ強制的に割り込みをかけるような形で処理をキャンセルさせることはできないのです。
このような場合には、以下のような解決が必要になります。
- 状態を保存しておく共有データ変数を用意しておく。
- UI からタスクスレッドへ通知したい場合、共有データ変数に格納する。
- タスクスレッドが定期的に(=タスクの節目節目で)共有データ変数をチェックし、それに従った動作をする。
具体的な内部設計図を下図に示します。
実装コードを以下に示します。
コード上、特に注目していただきたいのが、共有データ領域の使い方です。UI スレッドから、強制的にタスクスレッドの処理に割り込みをかけることはできません。このため、button2_Click() メソッドでは、フラグ情報を立てておくだけにとどめます。一方、タスクスレッド側は、なるべく頻繁にこのフラグをチェックし、そのフラグに応じて処理をキャンセルしたり挙動を変更したりします。
このように、UI からタスクスレッドへ何かしらの通知を行う、というのは意外に実装が厄介なものである、ということを覚えておいてください。再度ポイントをまとめると、以下のようになります。
- 状態を保存しておく共有データ変数を用意しておく。
- UI からタスクスレッドへ通知したい場合、共有データ変数に格納する。
- タスクスレッドが定期的に(=タスクの節目節目で)共有データ変数をチェックし、それに従った動作をする。
なお、UI からタスクスレッドへの通知に関してもう一点注意していただきたいのが、Windows フォームのクローズ(アプリケーションの終了)に伴う動作です。Windows フォームの代表的なプロセス終了命令には、以下の 2 種類があり、それぞれ内部動作が異なります。
- Application.Exit() 命令
すべてのメッセージループを停止し、アプリケーションウィンドウを閉じ、メインスレッドの Application.Run() 命令を終了する。この際、フォアグラウンドスレッドが残留しているとプロセスが終了しない。 - Environment.Exit() 命令
強制的にプロセスを切り落とす。この際、フォアグラウンドスレッド、バックグラウンドスレッドの残留状態を問わず、プロセスが強制終了する。
後者の Environment.Exit() 命令はアプリケーションの強制終了に相当するものですから、通常、Windows フォームのアプリケーションを穏やかに終了させるには、前者の Application.Exit() 命令を利用します(この動作はフォームのクローズ時も同様です)。
しかし、Application.Exit() 命令でアプリケーションを終了させる際に問題になるのが、以下の 2 つです。
- フォアグラウンドスレッドが残っていると、プロセスが終了しない。
- バックグラウンドスレッドについては、強制終了となる。
ここまで、フォアグラウンドスレッドとバックグラウンドスレッドについての解説をしてきませんでしたので、まずこれについて解説します。.NET アプリケーションで取り扱う各スレッドには、フォアグラウンドスレッドか、バックグラウンドスレッドかのマーキングを行うプロパティ .IsBackground が存在します。そして、
- フォアグラウンドとマークされたすべてのスレッドが終了すると、CLR はバックグラウンドスレッドが残っていても当該プロセスを終了します。
- フォアグラウンドとマークされたスレッドが一つでも残っていると、Main() メソッドが終了しても CLR は当該プロセスを終了しません。
という挙動をします。例えば、下記のコンソールアプリケーションのサンプルを見てください。
このサンプルにおいて、
- バックグラウンド化した上でマニュアルスレッドを起動すると、Main() 関数が終了した直後にプロセスが終了します。
- しかし、バックグラウンド化せずに(t.IsBackground = false; として)マニュアルスレッドを起動すると、Main() 関数が終了しても、プロセスが終了しません。
両者の動作を以下に示します。
バックグラウンド化した場合
フォアグラウンドで動作させた場合
デフォルト状態での各スレッドの状態は、以下の通りです。
- Main() 関数を動作させるスレッド(メインスレッド) → フォアグラウンド
- マニュアルスレッド → フォアグラウンド
- プールスレッド → バックグラウンド
つまり、Windows フォームアプリケーションでマルチスレッドアプリを作成する場合、うかつにマニュアルスレッドを使うと、Application.Exit() 命令ではプロセスが終了しなくなります。
例えば、以下のようなアプリケーションを作ってみます。
※ ここでは、UI がなくなってもプロセスが残留することを簡単に示すために、DebugView を使います。このツールの詳細はここでは解説しませんが、簡単にいうと、System.Diagnostics.Debug.WriteLine() 命令によって出力したデータを、外部で簡単に参照できるようにしたツールです。Web アプリケーションなどの UI を持たないツールにおいて、内部動作を簡単に表示・モニタできるというメリットがあって便利です。ここからダウンロードできます。
1: private void button1_Click(object sender, EventArgs e)
2: {
3: Thread t = new Thread(new ThreadStart(Task));
4: t.Start(); // バックグラウンド化せずに起動する
5: }
6:
7: private void Task()
8: {
9: while (true)
10: {
11: Thread.Sleep(300);
12: System.Diagnostics.Debug.WriteLine("タスク実行中...");
13: }
14: }
15:
16: private void button2_Click(object sender, EventArgs e)
17: {
18: Application.Exit();
19: }
フォアグラウンドでマニュアルスレッドを起動しておくと、フォームを閉じてもアプリケーションプロセスは終了せず、デバッガに情報を出力し続けます。(タスクマネージャを見ると、フォーム画面は消えているのにフォームの .exe プロセスが残っていることが確認できます。)
つまり、Windows フォームを適切に終了させるためには、残留しているフォアグラウンドスレッドをすべて終了させなければならない、ということになります。このことから、Windows フォームアプリケーションの開発に関する推奨事項として、次のようなことが言えます。
- 基本的に、タスクスレッドは「いつ強制終了されても困らない」ような防御的プログラミングをしておいた上で、さらにバッグラウンド化(.IsBackground = true)しておくことが望ましい。
「防御的プログラミング」というのは、「強制終了などのトラブルが起こったとしても、問題が起こらないようにプログラミングしておく」ことを指します。具体的には、以下のようなプログラミングの工夫を指します。
- ローカルキャッシュファイルが破損していた場合には、自動的に最新データをサーバから取り寄せる。
- ローカル設定ファイルが破損していた場合は、デフォルト値を利用する。
※ なお、このような防御的プログラミングの考え方は、Windows フォームアプリケーションでは特に重要です。というのも、Windows フォームアプリケーションは、「閉じる」ボタンなどで簡単に終了できるのはもちろんのこと、タスクマネージャからの強制終了や Windows OS シャットダウンなど、「完全な後片付け」ができないまま強制的に切り落とされるケースが多数考えられるからです。このため、Windows フォームアプリケーションでは『強制的に切り落とされても問題なく再起動ができる』形で実装することが望ましいといえます。タスクスレッド上の処理に限らず、「再起動時に何らかのローカルキャッシュデータファイルなどが破損していても自動復旧できる」ような設計をしておくことは非常に重要である、ということを覚えておいてください、とつぶやいておく^^。
では最後に、タスクスレッド上で発生した未処理例外の取り扱い方法について解説します。
[タスクスレッド上で発生した未処理例外の取り扱い方法]
一般的に、Windows フォーム上で発生した未処理例外に関しては、集約例外ハンドラを利用した後処理を行います。Windows フォームにおける集約例外ハンドラの書き方についてはこちらのエントリに記述しましたが、Application.ThreadException イベントハンドラで捕捉される例外は、メッセージループが捕捉した例外、すなわち UI スレッド上で発生した例外に限定されます。このため、マニュアルスレッドやタスクスレッド上で発生した未処理例外については、適切なハンドリングが必要です。これを怠ると、以下のような問題が発生します。
- タスクスレッドが例外によって終了してしまったにもかかわらず、タスクスレッドの正常終了を UI 側が永遠に待ち続けてしまう。
- タスクスレッド上で発生した例外情報がロギングされなかったため、障害解析ができなくなってしまう。
これらの問題を解決するため、以下のような対処を行います。
- AppDomain に対して集約例外ハンドラを仕掛けておく。
- タスクスレッド上で発生した例外を UI スレッド上でリスローする。
これらについて解説します。
AppDomain に対して集約例外ハンドラを仕掛けておく
CLR 上でアプリケーションを動作させる場合、プロセス内にはアプリケーションドメイン(AppDomain)と呼ばれる論理区画領域が作成されます。この AppDomain の UnhandledException イベントをフックすると、マニュアルスレッド上の未捕捉例外をすべて捕捉することができます。通常は、Application.ThreadException イベントハンドラも利用しますので、以下のようなコードを書くことになります。
ただし、この方法には以下の 2 つの難点があります。
- UI 通知ができない。 (タスクスレッド ≠ UI スレッドであるため)
- プールスレッド上で発生した例外を補足できない。
後者は特に大きな問題です。このため、実際には以下に述べる例外のリスローを使って、問題を解決することが望ましいです。これについて解説します。
タスクスレッド上で発生した例外を UI スレッド上でリスローする
そもそも UI 表示は UI スレッド上でしか行えない、という前提条件を考えると、タスクスレッドで発生した例外を UI 上で通知するためには、例外オブジェクトを UI スレッド側に伝搬する必要があります。そこで、タスクスレッド上で例外が発生した場合には、この例外オブジェクトを .BeginInvoke() 命令で UI スレッド側に伝搬し、UI スレッド上でそれをリスローします。
もともと UI スレッド上で発生する例外に関しては、Application.ThreadException イベントハンドラの集約例外ハンドラで捕捉することができますので、この方法を利用すれば、すべての例外を UI 側の集約例外ハンドラで補足することができます。
例えば、XML Web サービスの呼び出し処理をタスクスレッドに切り出す場合について考えてみます。XML Web サービス呼び出しに失敗すると、例外が発生します。このような例外をキャッチし、UI スレッドに伝搬すれば、集約例外ハンドラ(Application.ThreadException イベントハンドラ)でまとめて処理することができます。
# ちなみに、この場合の例外処理(try-catch)は、タスクスレッド上で発生したすべての例外をことごとく捕捉する必要がありますので、全体を大きく囲むことになります。この try-catch は、「例外→業務エラーの変換のための try-catch」でも「リソース解放のための try-finally」でもないので、注意してください。
[今回のエントリのまとめ]
というわけで、今回のエントリのキーポイントをまとめると、以下のようになります。
- タスクスレッドから UI 画面を更新したい場合には、.BeginInvoke() 命令を使って、メッセージキューにメッセージを投入する。
- タスクスレッドから UI 画面上のデータを読み取りたい場合には、データを読み取って返すメソッドを作成しておき、これを .Invoke() 命令でタスクスレッドから同期的に呼び出す。ただし気をつけてプログラミングしないと、スレッドプールの枯渇やデッドロック問題を引き起こすので注意が必要。
- UI 画面からタスクスレッドを直接操作することはできない。共有データ変数領域を用意しておき、① UI スレッドからはこの共有データ変数領域にフラグを立てる。② タスクスレッド側ではこの共有データ変数領域を頻繁にチェックする。という方法で間接的に制御を行う。
- タスクスレッド上で発生した未処理例外を取り扱うため、タスクスレッド全体を try-catch で囲む必要がある。捕捉した例外は、.BeginInvoke() 命令で UI スレッドに搬送し、リスローする。
さて、ここまで読んでみていただいて、
ちょーめんどい;。
と思われた方も多いと思います。はい、私もそう思います;;;。だったら説明するなー!と言われそうですが;、実際、マルチスレッドアプリケーションというのは、思っているよりも遥かに実装が厄介なものです。例えばボタンが押されたら XML Web サービスを呼び出すアプリケーションを作ろう、と思った場合、
同期型で実装する場合には、たったの 4 行で話が済みます。
しかし、これをタスクスレッドに切り出す場合、以下のようなポイントに対する考慮が必要になります。
- ボタンの二重押しの防止
- ステータスの表示
- タスクスレッドから UI スレッドへの書き戻し
- 集約例外処理
結果、以下のような膨大なコードになります。
なので、やはり個人的におすすめしたいのは、
マルチスレッドアプリケーションはなるべく作らない。
これに尽きます。って、まるでここまでの解説すべてを放り出すような発言ですが;;;、ここまでの延々とした説明をきちんと理解した上でコーディングをしようと思うと、相当大変であることは容易にご理解いただけるのではないでしょうか。(というか正直言ってこのエントリ書いてる自分もサクサク書けません、とても;。)
Visual Studio 2008 を利用すると、こうした問題を多少緩和するために、いくつかの機能が利用できるようになるのですが、そうはいってもここまでの解説をきちんと理解した上でないと、やはりそうしたウィザード類の利用もやはり危険です。Part 4. では Visual Studio 2008 の機能について解説しますが、Part 3. までの内容をよく理解した上で利用していただくことをお勧めします。