Part 1. Windows フォームのマルチスレッド処理の基礎
さて、Windows フォームは、Windows OS が持つ様々なウィンドウ制御の仕組みに基づいて開発されている UI 技術です。このため、Windows フォームのマルチスレッド処理を理解するためには、まず Windows OS がどのようにして Windows フォームアプリケーションを動作させているのかについて理解する必要があります。その中でも特に重要なのが、メッセージキューとメッセージループです。これらを理解することで、なぜ UI が固まるのか、また固まることを防ぐにはどうしたらよいのか、といったことが理解できるようになります。これについて解説します。
- メッセージキューとメッセージループ
- UI フリーズの発生理由
- Windows フォーム上でのマルチスレッド処理の基本ルール
- BeginInvoke() 命令
- 最も簡単なマルチスレッドアプリケーションの例
- Windows フォームにおけるスレッドの種類
なお、今回のサンプルは以下の通りです。ご活用ください。
[メッセージキューとメッセージループ]
Windows フォームは、メッセージループと呼ばれる仕組みを使うことにより、イベント駆動型のプログラミングモデルを実現しています。まず、概略図を以下に示します。
エンドユーザがマウスやキーボードによって Windows フォームのアプリケーションを操作した際に Button_Click などのイベントが発生するのは、以下のようなメカニズムによります。
- マウスやキーボードからの入力は、まず Windows OS が受け取る。
- Windows OS は、その操作内容(キーが押された、マウスが動いた、マウスのボタンがクリックされた、etc)を、メッセージ構造体(MSG 構造体) に固め、それを各アプリケーション用のメッセージキューに放り込む。
- 各アプリケーション内部では、メッセージループと呼ばれる処理が走っている。
- メッセージループは、自分用のメッセージキューからメッセージ構造体をひとつずつ取り出し、そのデータを解析し、イベントハンドラ呼び出し(Button_Click 呼び出しなど)を行う。
※ なお、ここでいうメッセージキューとは、MSMQ (Microsoft Message Queue)のことではありません。Windows OS が持っている、GUI 処理のための特殊なキューです。
C# で Windows フォームのアプリケーションを記述した方であれば、Main 関数の中に、以下のような Application.Run() 命令を記述したことがあると思います。この命令は、メインスレッド上でメッセージループを起動するためのものです。(VB だと内部的にこの処理が隠ぺいされるためこのコードが見えませんが、内部的には同じ処理が行われています。)
このメッセージループには、以下のような特徴があることを覚えておいてください。
- メッセージループは、一種の無限ループです。つまり、メッセージキューからメッセージを取り出して処理し、次のメッセージを取り出して処理し、...をひたすら繰り返します。メッセージがなくなると、次のメッセージが届くまで待機しますが、いずれにしてもこのメッセージループは終了しません。メッセージループのコードは .NET Framework 内部に実装されているため見ることができませんが、コードイメージとして、以下のような処理が行われていると思っていただけるとわかりやすいでしょう。
while (true)
{
メッセージを取り出す処理(); // (取りだせなかった場合は待機する)
メッセージの内容を解析する処理();
メッセージの内容に基づいて、イベントハンドラを呼び出したりする処理();
} - メッセージループから、(開発者が記述した)イベントハンドラが呼び出されるまでの流れは、スタックトレースを見てみるとわかります。
さて、このメッセージループによるメッセージの取り出しにおいて重要なことは、メッセージの取り出し作業がシングルスレッド処理である、という点です。つまり、
- ひとつのメッセージを取り出して、イベントハンドラ処理(Button_Click 処理など)を行っている最中は、次のメッセージが取り出されることはありません。
ということになります。実はこれが、UI フリーズが発生する主な原因になります。
[UI フリーズの発生理由]
では次に、UI のフリーズ(UI が固まって操作できなくなる現象)がなぜ発生するのかについて解説します。先ほど、メッセージキューに OS が投入する代表的なメッセージとして、以下のようなものを挙げました。
- キーが押された
- マウスが動いた
- マウスのボタンがクリックされた
しかし、実は OS が投入するメッセージには、これ以外にも次のようなものがあります。
- UI を描画しなさい
例えば画面上で、最小化されていたフォームがタスクバーからクリックされ、非アクティブだったフォームがアクティブ化されたとします。この場合、OS は、当該アプリに対して「UI を描画しなさい」という命令(メッセージ)を、(メッセージキューを介して)送ります。これを受け取った Windows フォームアプリは自分を描画することで、フォームを表示することになります。
つまり、メッセージキューに投入されるメッセージの中には、Windows OS からの再描画要求やサイズ変更要求などもあります。こうしたメッセージをすぐに処理できないと、UI が固まったり、正しくウィンドウが表示されなくなったりするように見える、ということになるわけです。
ところが先ほど述べたように、メッセージループによるメッセージキューからのメッセージの取り出しは、ひとつずつ順次行われます。このため、メッセージループを持つスレッド上で時間のかかる処理を行ってしまうと、再描画処理が即座に行われず、UI がフリーズします。
例えば、以下のようなコードを書いたとします。
1: private void button1_Click(object sender, EventArgs e)
2: {
3: System.Threading.Thread.Sleep(5000);
4: }
このようなコードを書いて実行すると、ボタン押下中はウィンドウがうんともすんとも言わなくなり(=ウィンドウを移動することなどができなくなり)、UI がフリーズします。理由は簡単です。
- メッセージループを動作させているメインスレッドが、button1_click() を処理している最中は、次のメッセージを処理できない。
- このため、OS からウィンドウの移動や再描画要求メッセージが送られても、このアプリケーションはそれに反応できない。
つまり、
- Windows フォームのイベントハンドラ(Button_Click や TextBox_TextChanged など)は、メッセージループから同期的に呼び出される。
- これらの中で時間のかかる処理を行ってしまうと、簡単に UI がフリーズする。
ということになります。言い換えれば、
- UI を全くフリーズさせないためには、メッセージループから呼び出されるイベントハンドラで、時間のかかる処理(具体的には 0.1 sec 以上かかる処理)を行わないようにすればよい。
ということになります。
なお、イベントハンドラ内では何秒程度までの処理なら認められるのか? については、なんとも言い難いものがあります。一般的に、人間の視覚速度は 30fps (秒間 30 フレーム、1 フレームあたり約 30msec)と言われており、これ以下であればスムーズに動作しているように見える、と言われています。とはいえ 30msec はかなり厳しい制限です。現実的には、各イベントハンドラ内の処理を 3 フレーム程度、つまり 100msec 程度以内に収まるように設計・実装すれば、ほとんどフリーズが感じられない、応答性の高いアプリになります。(もっとも、これはアプリの特性などによっても変わりますので、一概に言える数字ではありませんが。)
逆に言えば、次のようなことがいえます。
- UI がフリーズしないアプリを作るためには、時間のかかる処理(具体的には 0.1sec 以上かかる処理)を別スレッドに切り離して実行しなければならない。
業務アプリケーションには、多かれ少なかれ、こうした「時間のかかる処理」があります。典型的なものとしてはネットワークアクセス、具体的にはデータベースアクセスや XML Web サービスへのアクセスがあります。こうした時間のかかる処理をうかつにイベントハンドラに記述してしまうと、メッセージループをブロックし、UI がフリーズすることになります。今回のエントリで解説するのは、このような処理をいかにして別スレッドに切り出すのか、についてです。
では引き続き、別スレッドへの処理の切り出し方の具体的な説明を....と言いたいところですが、その前にもうひとつ説明すべきことがあります。それは、Windows フォーム上てのマルチスレッド処理に関する基本ルールです。
[Windows フォーム上でのマルチスレッド処理の基本ルール]
マルチスレッド処理を行う Windows フォームアプリケーションを開発する際には、必ず以下のルールを守る必要があります。
親子関係を持つコントロールは、必ず同一スレッドに所属させること。
Windows フォームのコントロール(UI 部品)は、インスタンス生成時に、それが生成されたスレッドに自動的に紐付けられるように設計されています。このため、フォーム上にテキストボックスやラベルなどがある場合、それらはすべてフォームのインスタンスを生成したスレッドと同一スレッド上で生成しなければなりません。
通常は、すべての UI 部品をメインスレッド上で生成し、ここでメッセージループを起動します。 (このため、メインスレッドは **UI スレッド**とも呼ばれます。以降の解説は、すべてこの前提条件に基づいて解説します。)
コントロールを生成したスレッド以外から、コントロールを操作しないこと。
Windows フォームのコントロールは、スレッドセーフではありません。このため、当該コントロールを作成したスレッド(通常は UI スレッド)以外から直接プロパティなどを操作してはいけません。
特に後者は非常に重要です。Windows フォーム内でバックグラウンドタスクを実行するために背後のスレッドを起動した場合、そこから UI 上に進捗状況を表示したり、処理結果を表示したりしたいことが多々あります。しかし、このような際に、背後のスレッドから直接 label1.Text = “…(処理結果)…”; といった具合に直接コントロールを操作すると、最悪の場合、アプリケーションがクラッシュします。
この問題を解決するために用意されているのが、BeginInvoke() 命令です。
[BeginInvoke() 命令]
先に述べたように、UI 部品はそれを作成したスレッド、通常は UI スレッド(=メッセージループを動作させているスレッド)から操作しなければなりません。では、上図のように独自に起動した処理スレッドから UI を更新したい場合にはどのようにすればよいかというと、BeginInvoke() 命令を利用します。この命令は、すべての Windows フォームコントロールが備えているメソッドで、簡単にいうと、 「特定のメソッドを呼び出せ」という命令を、メッセージ構造体としてメッセージキューに投入するためのものです。
具体例を出しながら解説した方がわかりやすいと思いますので、一例として、以下のような画面(バックグラウンドで処理を進めている際に、進捗状況を ProgressBar に表示する)を考えてみます。
まず、バックグラウンドスレッドから、直接 ProgressBar を操作するのは厳禁です。つまり、progressBar1.Value = 87; といった具合に、ProgressBar コントロールを UI 以外のスレッドから直接操作する(上図の青いスレッドから操作する)ことは厳禁です。これを避けるために、以下の作業を行います。
- まず、画面更新用のメソッドをコードビハインド中に作成します。
private void UpdateProgressBar(int val)
{
progressBar1.Value = val;
}
- 次に、このメソッドのポインタ情報をラップするための、デリゲートクラスを定義します。(UpdateProgressBar メソッドのすぐ上に、UpdateProgressBarDelegate などの名前で配置するとわかりやすいです。なおデリゲートの詳細は、Part 2 にて解説します。)
private delegate void UpdateProgressBarDelegate(int val);
private void UpdateProgressBar(int val)
{
progressBar1.Value = val;
}
- 最後に、背後で動作するバックグラウンドタスクのスレッドから、BeginInvoke() 命令を利用してメッセージを投入します。(コードの全体像は後で示します。なお、通常はすべてのコントロールが UI スレッドに所属しているため、どのコントロールの BeginInvoke() 命令を利用しても同じ結果となります。通常は Form クラスの BeginInvoke() 命令を叩くとよいでしょう。)
// 進捗状態として画面を更新
this.BeginInvoke(new UpdateProgressBarDelegate(UpdateProgressBar),
new object[] {i});
このようにすると、バックグラウンドタスクのスレッドから、メッセージキューに UpdateProgressBar() メソッドを呼び出すメッセージが投入され、これが処理されると画面が更新されます。BeginInvoke() 経由で投入されたメッセージに含まれるメソッド呼び出しは、UI スレッド上で処理されることになるので、このメソッドでは安全に UI を更新することができます。
[最も簡単なマルチスレッドアプリケーションの例]
では上記のコードサンプルを利用して、以下のような「進捗表示アプリケーション」を作ってみることにしましょう。
具体的な作業手順は以下の通りです。まず、フォーム上にボタンとプログレスバーを貼り付けます。
次に、ボタンのクリックイベントハンドラに以下のようなコードを書き、実行してみてください。
1: private void button1_Click(object sender, EventArgs e)
2: {
3: button1.Enabled = false; // いったんボタンを無効化
4: for (int i = 0; i < 100; i++)
5: {
6: // 何らかのタスクを実施...
7: Thread.Sleep(100);
8: // 進捗状態として画面を更新
9: progressBar1.Value = i;
10: }
11: button1.Enabled = true; // 再びボタンを有効化
12: }
実際に実行してみると、(プログレスバーの表示は一部きちんと動いてくれるものの)ウィンドウを動かそうとすると UI がフリーズしてしまったりします。これは、この button1_Click() メソッドが UI スレッド(メッセージループの処理)を占有してしまっており、OS からの再描画要求に応答できなくなってしまっているためです。
このような形になるのを避けるためには、この処理をバックグラウンドのスレッドに切り離す必要があります。バックグラウンドのスレッドとしては、プールスレッドとマニュアルスレッドの 2 種類がありますが、比較的長時間を要する処理(ここに示したような数秒以上かかるような処理)については、マニュアルスレッドを使う方がよいでしょう。まず、上記の for ループ処理をバックグラウンド処理に切り離します。
1: private void button1_Click(object sender, EventArgs e)
2: {
3: button1.Enabled = false; // いったんボタンを無効化
4: Thread t = new Thread(new ThreadStart(LongTask));
5: t.IsBackground = true; // バックグラウンド化してから起動
6: t.Start();
7: }
8:
9: private void LongTask()
10: {
11: for (int i = 0; i <= 100; i++)
12: {
13: // 何らかのタスクを実施...
14: System.Threading.Thread.Sleep(100);
15:
16: // 進捗状態として画面を更新
17: progressBar1.Value = i;
18: }
19: button1.Enabled = true; // 再びボタンを有効化
20: }
上記のコードの 17 , 19 行目に着目してください。
- button1_Click() は、メッセージループから呼び出されます。つまり UI スレッド上で動作します。
- しかし、上記に示した LongTask() は、新規に作成されたマニュアルスレッド上で動作します。
つまり、この 17 行目や 19 行目のコードは、UI スレッド以外からコントロールを操作しているので、アプリケーションをクラッシュさせる危険性があります。この問題を避けるためには、17 行目や 19 行目の処理を UI スレッド上で動作させるように、BeginInvoke() 命令を使う必要があります。
具体的には、まず UI 更新を行うためのメソッドと、そのメソッド呼び出しをラッピングするためのデリゲートを定義します。
1: private delegate void UpdateProgressBarDelegate(int val);
2: private void UpdateProgressBar(int val)
3: {
4: progressBar1.Value = val;
5: if (val == 100) button1.Enabled = true;
6: }
次に、LongTask() メソッド内の UI 更新処理を、BeginInvoke() によるメッセージ投入処理に切り替えます。
1: private void LongTask()
2: {
3: for (int i = 0; i <= 100; i++)
4: {
5: // 何らかのタスクを実施...
6: System.Threading.Thread.Sleep(100);
7:
8: // 進捗状態として画面を更新
9: this.BeginInvoke(
10: new UpdateProgressBarDelegate(UpdateProgressBar),
11: new object[] { i });
12: }
13: }
以上により、フリーズしない UI を持った進捗状況表示画面が作成されます。
完成したソースコードと、それぞれのメソッドがどこで動作するのかを下図に示します。
このようにすることで、時間のかかる処理を背後のスレッドに分離し、フリーズしない UI を作成することができます。
[Windows フォームにおけるスレッドの種類]
さて、上記では処理の切り離しにマニュアルスレッド(新規に作成したスレッド)を使いましたが、スレッドプールを使って処理を別スレッド化することもあります。(※ スレッドプールがどのようなものであるかについては、以前に記述したエントリを参照してください。)
つまり、Windows フォームでは、UI スレッド(メインスレッド)から処理を切り離す方法として、マニュアルスレッドとプールスレッドの 2 種類がある、ということになります。結果として、Windows フォームアプリケーション内部では、主に以下のようなスレッドが利用されることになります。
メインスレッド (UI スレッド)
当該プロセス内に最初に作られ、Main() メソッドを呼び出すスレッド。このスレッド上で UI コントロールを作成し、Application.Run() メソッドを呼び出し、メッセージループを動作させる。メッセージキュー内のメッセージ処理や、各種のイベントハンドラ呼び出しはこのスレッド上で発生する。マニュアルスレッド
非同期処理(バックグラウンドタスク)を行うために、自力で Thread オブジェクトを生成することにより作ったスレッド。プールスレッド
非同期処理(バックグラウンドタスク)を行う際、CLR の機能であるスレッドプール機能を用いる場合に利用されるスレッド。その他のスレッド
ファイナライザスレッドやアンマネージスレッドなどが動作するスレッド。通常は気にしなくて OK。
まとめると、下図のようになります。注意すべき点は、マニュアルスレッドやプールスレッドから UI コントロールを直接更新してはならないという点です。UI を更新したい場合には、BeginInvoke() 命令を使って、メッセージキューにメソッド呼び出し要求を投入してください。
[今回のエントリのまとめ]
というわけで、今回のエントリでは Windows フォームのマルチスレッド処理の基礎について解説してきました。キーポイントは以下の通りです。
- Windows OS は、メッセージキューにメッセージ(MSG 構造体)を投入することによって、マウスの移動やキーボードの押下を通知している。
- Windows フォームアプリケーションは、内部でメッセージループを使い、メッセージキューから一件ずつ、逐次でメッセージを取り出していくことで処理を進める。
- UI フリーズが発生する原因は、メッセージループ上(UI スレッド上)で長時間処理を行ってしまうことである。イベントハンドラなどに長時間処理を記述すると、OS からの再描画要求が実行されなくなり、UI がフリーズする。
- 通常、UI コントロールはすべてメインスレッド上でインスタンス化し、このスレッド上から操作する。このメインスレッドのことを、別名で UI スレッドと呼ぶ。
- マニュアルスレッドやプールスレッドといった、メイン以外のスレッドから UI コントロールを操作してはならない。このような場合には、BeginInvoke() 命令を使って、UI スレッド上で画面描画更新処理を動作させる。
以上が基本的な Windows フォームのマルチスレッド処理の大原則です。しかしこれだけではまだマルチスレッド動作する Windows フォームアプリケーションを開発するにはやや知識が足りません。次回のエントリでは、上記の大原則に従った、より詳細なマルチスレッドアプリの開発方法について解説してきます。
Comments
- Anonymous
March 30, 2009
The comment has been removed - Anonymous
March 31, 2009
The comment has been removed