スレッドの概念
C#ではマルチスレッドによる並列処理がサポートされています。1つのスレッドは他から独立した実行パスで
プログラムが実行され、他のスレッドと同時に実行が可能です。
C#のクライアントプログラム(コンソール、WindowsForm、WPF)ではOS及びCLRによってアプリケーションの開始時
に1つのスレッドが作られます。この最初に作られるスレッドのことをメインスレッドと呼びます。
また自分でスレッドを作成することでマルチスレッドアプリにすることも可能です。
下にスレッドを複数起動するサンプルコードを示します。
class ThreadTest
{
static void Main()
{
// 新しいスレッドの作成(OSレベルではまだ作成されていない)
Thread subThread = new Thread (WriteY);
// スレッドの開始
subThread.Start();
// メインスレッドでxを出力.
for (int i = 0; i < 1000; i++)
{
Console.Write("x");
}
Console.ReadLine();
}
static void WriteY()
{
// 作成したスレッドでxを出力.
for (int i = 0; i < 1000; i++)
{
Console.Write("y");
}
}
}
出力は以下のようになります。
メインスレッドがスレッドsubThreadを作成してそのスレッドを開始します。subThreadはyという文字をコンソールに
出力します。同時にメインスレッドはxという文字をコンソールに出力します。
スレッドが一度スタートするとスレッドが終了するまでThreadオブジェクトのIsAliveプロパティがtrueになります。
スレッドの生成時にコンストラクタに引数で渡されたメソッドが終了するとそのスレッドは終了します。
一度終了したスレッドは2度と再開させることはできません。
CLRはそれぞれのスレッドに対してスタック領域に独立したメモリを割り当てます。この仕様によってローカル変数の
領域はそれぞれのスレッドで分離されることが保証されます。次のサンプルではメソッド内でローカル変数を定義し
このメソッドをメインスレッドと新しく作成したスレッドの両方で実行しています。
static void Main()
{
var subThread = new Thread(AddCyclesLocalVariable);
subThread.Start();
AddCyclesLocalVariable();
}
static void AddCyclesLocalVariable()
{
for (int cycles = 0; cycles < 5; cycles++)
{
Console.Write('?');
}
}
それぞれのスレッドでcycles変数が作成され、それぞれのスレッドのスタックメモリ上で値が加算されます。
なので出力される?の数は予想されるとおり10個ということになります。
スレッドは同じインスタンスへの参照を持つ場合、データを共有することになります。
以下に例となるサンプルコードを示します。
class ThreadTest
{
bool done;
static void Main()
{
ThreadTest tt = new ThreadTest();//2つのスレッドで共通で使用するインスタンスを生成
var subThread = new Thread(tt.Go);
subThread.Start();
tt.Go();
}
void Go()
{
if (!done)
{
done = true;
Console.WriteLine ("Done");
}
}
}
両方のスレッドがGoメソッドを呼び出していますが同じttインスタンスを参照しています。
2つのスレッドでdoneプロパティを共有しているためDoneと出力されるのは1回のみとなり2回出力される
ことはありません。
別の方法としてstatic変数を使用してスレッド間でデータの共有が可能です。同様のサンプルを以下に示します。
class ThreadTest
{
static bool done; //Static変数は全てのスレッドでデータが共有されます。
static void Main()
{
var subThread = new Thread (Go);
subThread.Start();
Go();
}
static void Go()
{
if (!done)
{
done = true;
Console.WriteLine ("Done");
}
}
}
これらのサンプルはスレッドにおける
スレッドセーフという概念が非常に
重要であることを示しています。言い換えるとスレッドセーフティの概念が不足したコードのサンプルとも言えるでしょう。
出力結果は予測が不可能です。Doneを2回出力させることも可能です。驚くべきことに命令文の順序を逆にすると
Doneが2回出力されます。
static void Go()
{
if (!done)
{
Console.WriteLine ("Done");
done = true;
}
}
問題の本質は他のスレッドがWriteLineメソッドを実行しdoneをtrueにセットする前にもう1つのスレッドが
if文を評価することが可能なことです。
この問題を解決するための方法はExclusiveLockを取得して読み取りから書込みまでの間の命令文を実行することです。
C#ではこの処理を実行するためにlock構文が用意されています。
class ThreadSafe
{
static bool done;
static readonly object locker = new object();
static void Main()
{
var subThread = new Thread(Go);;
subThread.Start();
Go();
}
static void Go()
{
lock (locker)
{
if (!done)
{
Console.WriteLine ("Done");
done = true;
}
}
}
}
2つのスレッドが同時にロックを取得しようとした場合(この例ではlocker)1つのスレッドだけがlockerで囲まれた命令文を
実行でき、もう1つのスレッドは実行中のスレッドがlockerで囲まれた部分を実行し終わるまで待たされます。
このlockerで囲まれた部分を
critical sectionといいます。
critical sectionでは実行されるスレッドは1つだけであることが保証され
結果としてDoneが出力されるのは1回だけとなります。
このように複数のスレッドが実行されている環境においても処理の結果が予測可能であるプログラムのことを
スレッドセーフであるといいます。
マルチスレッドでの複雑で分かりづらい不具合の主な原因はスレッド間での共有データであることが多いです。
他の多くの状況でも言えますができる限りシンプルなコードを作ることに注意を払うことが大事になってきます。
このlock句でブロックされたスレッドはCPUリソースを消費しません。
JoinとSleep
Joinメソッドを使用すると他のスレッドが終了するまで待機することができます。サンプルコードは以下になります。
static void Main()
{
Thread t = new Thread(Go);
t.Start();
t.Join();
Console.WriteLine ("Thread t has ended!");
}
static void Go()
{
for (int i = 0; i < 1000; i++)
{
Console.Write ("y");
}
}
このコードを実行するとyが1000回出力された後、"Thread t has ended!"が出力されます。
Joinメソッドを呼び出すときにタイムアウトを指定することも可能です。ミリ秒をInt32で指定するかTimeSpanで
タイムアウトを指定します。Joinメソッドの戻り値がtrueの場合はスレッドが正常に終了したことを示し、false
の場合はスレッドがタイムアウトしたことを示します。
Thread.Sleepは現在のスレッドを指定した間だけ停止させます。
Thread.Sleep(TimeSpan.FromHours (1)); // 1時間停止
Thread.Sleep(500); // 500ミリ秒停止
SleepやJoinで待っている間はこのスレッドはブロックされている状態になります。なのでCPUリソースをを
消費することはありません。
Thread.Sleep(0)は残っている現在のスレッドのタイムスライスを全て放棄し、
自発的に他のスレッドにCPUタイムを譲ります。.NET4.0では新たにThread.Yield()メソッドが追加されており
同じことを行うことが可能です。Thread.Sleep(0)とThread.Yield()の唯一の違いはThread.Yield()の場合は
同じプロセッサで実行されているスレッドに対してのみCPUタイムを譲るという点が異なります。
Sleep(0)とYieldは製品コードでの高度なパフォーマンスの調整をする際に有用なときがあります。
また未発見のスレッドセーフでない不具合を発見するためのデバッグ用のツールとしても非常に有用です。
もしThread.Yield()をプログラムの様々なところに埋め込んでプログラムを実行したときにプログラムが
クラッシュするならばプログラムのどこかに不具合があることはほぼ間違いないと言っていいでしょう。
スレッドがどのように動作するか
マルチスレッドは内部的にはスレッドスケジューラによって管理されています。CRLは通常この機能を
OSの機能を利用する形で提供します。スレッドスケジューラは全てのスレッドが正しく実行時間を割り当てられるように
各スレッドを管理し、また待ち状態やブロックされているスレッド(ExclusiveLockやユーザーの入力待ちなど)
が無駄にCPUを消費しないようにしています。
プロセッサが1つしかないコンピュータの場合、スレッドスケジューラはタイムスライスを行うことになります。
タイムスライスとは高速に実行するスレッドを切り替えあたかも複数のアプリケーションが同時に実行しているように
見せかける仕組みです。Windowsではこのタイムスライスは通常数10ミリ秒くらいで切り替えます。
スレッドの切り替えにかかる時間(通常は数ミリ秒程度)よりも大きい値でタイムスライスをすることになります。
複数のプロセッサを搭載しているコンピュータの場合はマルチスレッドはタイムスライスと異なるスレッドが
異なるCPUでそれぞれ並列に実行されることによる見せ掛けの並列処理で実現されることになります。
しかしながらタイムスライスが行われていることはほぼ確実です。なぜなら他のアプリケーションと同様に
OS自身のスレッドも実行される必要があるためです。
タイムスライスなどの外部要因によりスレッドはいつでも実行時間を他のスレッドに横取りされてしまうとも言えます。
ほとんどの状況では横取りされてしまうのを防ぐための手段はなくいつどの部分で実行を止められて実行時間を
横取りされるかはコントロールできません。
スレッドvsプロセス
スレッドはOSのアプリケーションを動作させるプロセスと類似している部分があります。プロセスが
1つのコンピュータ上で並列に動作することができるのと同様に、スレッドも1つのCPU上で複数実行することが
可能です。プロセスは他のプロセスと完全に分離されていますが、スレッドも限定的ながらある程度の分離が
なされています。特にスレッドはヒープメモリを同じアプリケーションで実行中の他のスレッドと共有しています。
この部分がスレッドを有用な部分です。バックグラウンドスレッドでデータを取得しつつ、他のスレッドで
そのデータの取得処理の進捗状況を画面に表示することができます。
スレッドの使いどころと誤った使用例
マルチスレッドには非常に多くの使いどころがあります。
UIのフリーズを防ぐ
時間のかかる処理をバックグラウンドのワーカースレッドで行いつつ、UIスレッドはキーボードやマウスの入力を
処理可能な状態に保ちUIが反応するようにしておくことができる。
CPUのブロックを回避する
他のコンピュータからの応答やデバイスなどのハードウェアの応答を待つ際に有用です。
スレッドがブロックされている間、他のスレッドは空いているCPU時間を使うことができ処理を進めることができます。
並列処理
ある計算処理などがスレッドごとに独立して処理可能な場合、複数のCPUを使って集中的に計算を行い処理を
高速化することが可能です。
投機的実行(Speculative execution)
マルチコアマシンでは複数の処理を並列に実行し最初に処理が終わったものを利用することで処理時間を短縮
できる場合があります。例えばある計算処理を行う際に異なるアルゴリズムのプログラムを複数のスレッドで
並列に行い最も早く終了した計算結果を使用するようにすれば処理を高速化することが可能です。
これはどのアルゴリズムが早いかがわからないような場合に非常に有用な方法となります。
クライアンとからのリクエストを並列に処理する
サーバーではクライアントからのリクエストは同時に受信されるためそれらのリクエストを並列処理する必要が
あります。.NET FrameworkのASP.NET,WCT,WebService,Remotingを使用する場合は内部で自動的にスレッドを作成
しクライアントのリクエストを処理しています。並列化によりクライアントの待ち時間を減らすことができ非常に
有用です。
ASP.NETやWCFなどでは開発者はstatic変数などの共有変数を利用しないでさえいればマルチスレッドで処理が
行われていることに気づくことなくまたlockingやスレッドセーフティなどを意識する必要もなくこれらの
テクノロジーを利用することができます。
しかしながらスレッドはいいことばかりではなく制約付の技術でもあります。最も大きな問題はプログラムの
複雑性を大幅に増大させてしまうということです。複数のスレッドを実行させるということによりそのスレッド
以外のスレッドやそのスレッド自身の複雑性が増大します。複数のスレッド間でのデータのやりとり
(主に共有データ)がその原因です。そのデータのやりとりが意図的であろうとなかろうと、マルチスレッド
プログラムは稀に発生する再現が難しい不具合をしばしば発生させ開発期間の長期化を招きます。こういった理由
からマルチスレッドアプリケーションではスレッド間のやりとりを最小限にとどめ作りをシンプルにすることが
大事であり、信頼されたデザインパターンを可能な限り適用してプログラムを作成していくいくことが望まれます。
この記事ではこういった複雑性をどのように処理していくかを解説していく予定です。
解決策としてはマルチスレッド処理を再利用クラスにカプセル化し独立して実行及びテストが可能なように
クラスをデザインすることが大事です。.NET Frameworkでは高レベルでスレッドの仕組みを抽象化した
様々なクラスを提供しています。これらのクラスなどについては今後の章で解説していきます。
またマルチスレッドはスレッドのスケジュール管理や切り替え処理のコストによりCPU消費量の増大を招きます。
CPUの数よりもスレッドの数のほうが多い場合はCPU消費量が増大することになります。またスレッド自身の
作成処理や廃棄処理もCPUを消費します。マルチスレッドはアプリケーションを高速化するとは限りません。
むしろ使い方を誤るとより遅くなる可能性もありえます。例えば非常に重たいディスクのI/O処理がある時には
10個のスレッドで同時に処理するよりもワーカースレッドを作って1つずつ処理したほうがスピードは速くなります。
(
シグナルの仕組みの章ではこの問題をProducer/Consumerキューパターン
で解決する方法について解説しています)
スレッドの作成と開始
前の章で紹介したようにThreadクラスのコンストラクタにThreadStartデリゲートを渡すことでスレッドを作成
することが可能です。ThreadStartデリゲートには他のスレッドで実行する実行コードを指定します。
ThreadStartデリゲートは以下のように定義されています。
public delegate void ThreadStart();
Startメソッドを呼び出すことで実際にスレッドを実行することができます。実行を開始したスレッドは
メソッドの実行が終了するまで実行を続けます。下にサンプルコードを示します。
class ThreadTest
{
static void Main()
{
Thread t = new Thread (new ThreadStart (Go));
t.Start(); // 新しくスレッドを作成しGo()メソッドを実行します
Go(); // 同時にメインスレッドでもGo()メソッドを実行します。
}
static void Go()
{
Console.WriteLine ("hello!");
}
}
このサンプルではスレッド t はGo()メソッドを実行します。それとほぼ同時にメインスレッドでもGo()メソッド
を実行します。結果としてほぼ同時にHello!というメッセージがConsoleに表示されます。
スレッドはメソッドグループを指定してもっと簡潔に作成することも可能です。指定したメソッドグループは
C#の暗黙的型変換によってThreadStartデリゲートに変換されています。
Thread t = new Thread (Go); //ThreadStartを特に指定しないでも大丈夫です
他の方法としてはラムダ式もしくは匿名メソッドを使用する書き方もあります。
static void Main()
{
Thread t = new Thread ( () => Console.WriteLine ("Hello!") );
t.Start();
}
スレッドにデータを渡す
スレッドにデータを渡す最も簡単な方法は実行したいメソッドをラムダ式で囲みメソッドのパラメータに自分が
渡したい値を指定するという方法があります。
static void Main()
{
Thread t = new Thread ( () => Print ("Hello from t!") );
t.Start();
}
static void Print (string message)
{
Console.WriteLine (message);
}
この方法ではあらゆる種類の引数をメソッドに渡すことが可能です。実行したいコード全体をラムダ式に
ラップして実行することも可能です。
new Thread (() =>
{
Console.WriteLine ("I'm running on another thread!");
Console.WriteLine ("This is so easy!");
}).Start();
同じことをC#2.0の匿名メソッドを使用して行うこともできます。
new Thread (delegate()
{
Console.WriteLine ("I'm running on another thread!");
Console.WriteLine ("This is so easy!");
}).Start();
他の方法としてはThreadStartメソッドの引数に値を指定する方法もあります。
static void Main()
{
Thread t = new Thread (Print);
t.Start ("Hello from t!");
}
static void Print (object messageObj)
{
string message = (string) messageObj; //キャストする必要があります。
Console.WriteLine (message);
}
このコードが何故実行可能かというと実はThreadのコンストラクタがオーバーロードされていて2種類のデリゲート
を受け入れられるようになっているからです。
public delegate void ThreadStart();
public delegate void ParameterizedThreadStart (object obj);
ParameterizedThreadStartデリゲートの制約としてはパラメータが1つしか指定できないことです。
また型がobject型なのでキャストが必要になります。
ラムダ式を利用する場合は変数のキャプチャによる問題があります。詳細はこちらの
クロージャと変数のスコープで解説しているので
確認してみてください。
名前つきスレッド
デバッグを容易にするためにスレッドにはNameプロパティがあり名前を指定することができます。これは特に
VisualStudioを使用している場合に便利で、このスレッドの名前はスレッドウィンドウとツールバーのデバッグの
場所で表示されます。スレッド名は1度しか設定できません。一度設定した後に変更しようとすると例外が発生
します。
staticなThread.CurrentThreadプロパティを使用すると現在実行中のスレッドを取得できます。下のサンプル
ではメインスレッドの名前を設定しています。
class ThreadNaming
{
static void Main()
{
Thread.CurrentThread.Name = "main";
Thread worker = new Thread (Go);
worker.Name = "worker";
worker.Start();
Go();
}
static void Go()
{
Console.WriteLine ("Hello from " + Thread.CurrentThread.Name);
}
}
フォアグラウンドスレッドとバックグラウンドスレッド
既定では自分で作成したスレッドは全てフォアグラウンドスレッドになります。フォアグラウンドスレッドが
1つでも実行中の場合、アプリケーションが終了することはありません。逆にバックグラウンドスレッドの場合は
そうではありません。もしフォアグラウンドスレッドの実行が全て終了した場合はアプリケーションは終了し
バックグラウンドスレッドは強制的に終了させられます。
スレッドがフォアグラウンドかバックグラウンドかどうかはスレッドの優先度や実行時間の割り当てには
全く関係がありません。
スレッドがバックグラウンドで実行されているかどうかはIsBackgroundプロパティで取得または設定できます。
class PriorityTest
{
static void Main (string[] args)
{
Thread worker = new Thread ( () => Console.ReadLine() );
if (args.Length > 0) worker.IsBackground = true;
worker.Start();
}
}
このプログラムがコマンドライン引数なしで実行された場合、ワーカースレッドはフォアグラウンドスレッドで
実行されユーザーのEnterキーの入力を待つことになります。それと同時にメインスレッドは終了しますが
フォアグラウンドスレッドがまだ存在するためアプリケーションは終了しません。
逆に言えば引数ありで実行された場合はワーカースレッドはバックグラウンドスレッドで実行されることになり
メインスレッドの終了とともにプログラムは即座に終了することになります。
プロセスはこのルールに従ってスレッドを破棄するため、バックグラウンドスレッドで実行されているコードの
finallyブロックは実行されることなくスレッドが終了することがありえます。もし作成したプログラムが
finallyブロック(もしくはusingを使用している場合)でリソースの破棄や一時ファイルの削除を行っている
場合には問題が発生する場合があります。こういった問題を回避するためにバックグラウンドスレッドが終了する
まで明示的に待つことが可能です。具体的には2つの方法があります。
- 自分で作成したスレッドの場合はJoinメソッドでスレッドの終了を待ちます。
- スレッドプールのスレッドの場合はEventWaitHandle
を使用します。
どちらを使う場合であっても
タイムアウトを指定すべきです。
あなたの予想に反してそれらのスレッドが完了しない場合がありえるためです。もしタイムアウトを指定しない
場合アプリケーションが終了せずユーザーはタスクマネージャーから強制的にアプリケーションを終了すること
になります。
スレッドの優先度
スレッドの優先度によってそのスレッドが他のスレッドと比べて相対的にどのくらいのCPU時間を取得するのかが
決められます。スレッド優先度には以下の値のうちいずれかを指定します。
enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }
この値は複数のスレッドが同時に実行されている場合にのみ意味を持ちます。
スレッドの優先度を変更は慎重に行ってください。優先度の変更により他のスレッドの動作に悪影響を及ぼす
可能性があります。
スレッド優先度を上げてもリアルタイムの実行が可能になるわけではありません。なぜならスレッドは
アプリケーションのプロセスの優先度の中で動作しているからです。リアルタイム処理をするためには
スレッド優先度を上げると同時にプロセスの優先度を上げる必要があります。プロセスの優先度は
System.Diagnostics.Processクラスを使用して変更することが可能です。
using (Process p = Process.GetCurrentProcess())
{
p.PriorityClass = ProcessPriorityClass.High;
}
ProcessPriorityClass.Highはその通り最も優先度の高い値になります。プロセスの優先度をリアルタイムに指定
するということは他のプロセスに対して一切のCPU時間を割り当てないということをOSに指示することになります。
もしプログラムが誤って無限ループに陥ってしまった場合、OSさえもロックされて動かないという状態に陥り、
ボタンを押してプログラムを終了させることさえもできなくなってしまいます。これらのことからリアルタイム
でアプリケーションを動作させるには通常はHighに設定するのが最も良い方法ということになります。
リアルタイムアプリケーションがUIを持つ場合、プロセス優先度を高くした結果UIの過度な更新が発生し
CPU時間を大量に消費することになりマシン全体の動作が遅くなる場合があります(特にUIが複雑な場合は
それが顕著です)。プロセス優先度を上げるのと合わせてメインスレッドの優先度を下げることにより
リアルタイム実行をしているスレッドがUIの再描画を行うことを抑制可能ですが、この方法では他の
アプリケーションがCPU時間が足りなくなる問題を解決することはできません。なぜならこの方法でも
OSは概して過剰なリソースをアプリケーションに割り当てるからです。最も理想的な解決策はリアルタイム
で実行する部分とUIを分離して異なるプロセスのアプリケーションで実行し、それぞれのプロセス優先度を
設定するという方法が良いでしょう。2つのアプリケーション間の通信はRemotingかMemory-mapped files
を使用するという方法があります。Memory-mapped filesはこのような用途に対して最適化されています。
例外処理
try-catch-finallyでは他のスレッドで発生した例外をキャッチすることはできません。
public static void Main()
{
try
{
new Thread (Go).Start();
}
catch (Exception ex)
{
// キャッチできない!
Console.WriteLine ("Exception!");
}
}
static void Go()
{
throw null; // NullReferenceExceptionを発生させる
}
この例でのtry-catchブロックは全く意味を成しません。それぞれのスレッドはそれぞれ独立した実行パスで
実行されることを思い出してもらえばこの振る舞いを理解できると思います。
解決策としてはGoメソッドの内部に例外のキャッチ句を移すことです。
public static void Main()
{
new Thread (Go).Start();
}
static void Go()
{
try
{
throw null;
}
catch (Exception ex)
{
//例外をキャッチしてログの記録や他のスレッドに例外を伝達する
}
}
製品化されたアプリケーションではちょうどメインスレッドに対して行うのと同じように、
作成したスレッドの開始メソッド全てに対して例外処理を適切に実装する必要が
あります。キャッチされなかった例外はアプリケーションの強制終了を引き起こします。
こういった例外処理を書く場合にごく稀に例外を無視してアプリケーションの実行を継続するということも
あり得ます。
通常は例外の詳細をログに記録し、サーバーへ例外の内容を送るかどうかを尋ねるダイアログを表示する
ことができたりします。そのあとアプリケーションは強制終了させることになります。なぜならばこの例外が
プログラムの状態を破壊しかねないからです。
しかしながらこの仕様はユーザーが作業中の資料のデータを失うことになるので慎重に考える必要があります。
アプリケーション全体の例外のハンドルをするイベントとしてWPFやWindowsFormでは
- Application.DispatcherUnhandledException
- Application.ThreadException
があります。
これらのイベントはメインスレッドでの例外のみがハンドルできます。ワーカースレッドでの例外は
自分自身でハンドルして適切に処理をする必要があります。
AppDomain.CurrentDomain.UnhandledExceptionでは全てのスレッドで発生した例外をハンドルできます。
しかしながらこのイベントではアプリケーションのシャットダウンを防ぐための方法は提供していません。
しかしながらいくつかの場合では例外をハンドルする必要のない場合もあります。なぜなら.NET Frameworkが
暗黙的に処理をしてくれているからです。詳細はこの後の章で解説する予定です。
- 非同期デリゲート
- BackgroundWorker
- Task Parallel Library (conditions apply)
Thread Pooling
どのような状況であれスレッドをスタートするときにはそのスレッド専用のスタックメモリの確保などで数百秒
ほどのCPU時間が消費されます。既定ではスレッドは1MBのメモリをスタックに確保します。スレッドプールは
一度作成したスレッドを再利用して共有することでこのオーバーヘッドを回避します。マルチコアのプロセッサ
を活用して並列に処理を走らせる場合などにスレッドプールの仕組みは有効です。
スレッドプールは同時に実行するスレッドの総数が増えすぎないように調整する役目も持っています。同時実行
するスレッドの数が多くなりすぎるとOSのスレッド管理の負荷が高くなりすぎCPUキャッシュの動作が非効率的に
なってしまいます。スレッドプールでは同時実行されているスレッド数の限界に達すると新しい実行ジョブはキュー
に入れられ実行中のジョブのどれか1つが終了するまで待たされます。
スレッドプールは様々なところで内部的に使用されています。
- Task Parallel Libraryの内部
- ThreadPool.QueueUserWorkItemメソッドの呼び出し
- Asynchronous delegateの内部
- BackgroundWorkerの内部
以下の要素技術はスレッドプールを間接的に使用しています。
- WCF,Remoting,ASP.NET,ASMX Web Service
- System.Timers.Timer,System.Threading.Timer
- Asyncで終了するメソッド。例えばWebClientクラスのメソッド(Event-based asynchronous pattern)
- Beginで始まるメソッドのほとんど(Asynchronous programming pattern)
- PLINQ
Task Parallel LibraryやPLINQではスレッドプールを意識することなくマルチスレッドプログラミングを簡単に行える
ようになっていて非常に強力です。詳細は
高度なスレッド処理で解説します。
簡潔にTaskクラスについて解説をするとTaskクラスを使用するというのはスレッドプール上でdelegateを実行すると
いうのと同じことになります。
スレッドプールのスレッドを使用する際にいくつかの注意するべき点があるので列挙します。
- スレッドプールのスレッドには名前をセットすることができません。結果としてデバッグが難しくなる場合があります。
- スレッドは全てバックグラウンドスレッドとして実行されます。
- スレッドをブロックするとThreadPool.SetMinThreadsを呼ばない限りは早い段階で遅延が発生することになります。
スレッド優先度に関しては自由に変更することが可能です。スレッドの実行が終了しスレッドプールに戻るタイミングで
スレッド優先度は全てNormalに自動的に復元されます。
現在実行しているスレッドがスレッドプールのスレッドなのかどうかを確認するにはThread.CurrentThread.IsThreadPoolThread
の値を取得すると確認することができます。
Task Parallel Libaryを通じてスレッドプールを利用する
Taskクラスを使用することでスレッドプールの機能を簡単に利用できます。Taskクラスは.NET4.0から導入されたクラスです。
.NET4.0よりも前のバージョンでのマルチスレッドに詳しいならばTaskクラスはThreadPool.QueueUserWorkItemを使用するのと
同じ動作をし、Task<TResult>はasynchronous delegatesを同じ動作をします。新しいTaskの考え方を使用することでより
高速で便利で柔軟なことができるようになります。
Taskクラスを使用するためにはTask.Factory.StartNewメソッドにdelegateを渡すことで可能です。
static void Main() // TaskはSystem.Threading.Tasks名前空間に定義されています。
{
Task.Factory.StartNew (Go);
}
static void Go()
{
Console.WriteLine ("Hello from the thread pool!");
}
Task.Factory.StartNewメソッドはTaskオブジェクトを返します。このTaskオブジェクトを使って実行状況を監視することが
可能です。例えばWaitメソッドを呼び出せばタスクの実行が完了するまで待機することが可能です。
全てのキャッチされなかった例外はホストのスレッドでWaitメソッドが呼ばれたタイミングで再スローされます。
Waitメソッドを呼ばなかった場合は通常のスレッドと同様にアプリケーションが終了することになります。
Task<TResult>クラスはTaskクラスのサブクラスです。このクラスを使用することでタスクの実行終了後に戻り値を
受け取ることが可能になります。下のサンプルではTask<TResult>クラスを使用してWEBページをダウンロードしています。
static void Main()
{
Task<string> task = Task.Factory.StartNew<string>( () => DownloadString ("http://www.linqpad.net") );
//並列に別の処理を実行
RunSomeOtherMethod();
// Resultプロパティから実行結果の戻り値を取得できます。:
// 実行が完了していない場合、実行が完了するまで待たされます。
string result = task.Result;
}
static string DownloadString (string uri)
{
using (var wc = new System.Net.WebClient())
{
return wc.DownloadString (uri);
}
}
StartNewメソッドでstringを指定するとResultプロパティは暗黙的にstring型になります。全てのキャッチされなかった
例外はResultプロパティを読み取ったタイミングでAggregateExceptionにラップされて再スローされます。しかしながら
Resultプロパティの読み取りやWaitメソッドの呼び出しを行わなかった場合、アプリケーションは強制的に終了すること
になります。
Task Parallel Libraryは他にも多くの機能を提供しています。特にマルチコアの効果的な活用ができるように最適化
されています。詳細は
高度なスレッド処理で解説します
Task Parallel Libaryを使わずにスレッドプールを利用する
.NET4.0よりも前のバージョンを使用している場合、Task Parallel Libraryを利用できません。そのため別の方法でスレッド
プールを利用する必要があります。ThreadPool.QueueUserWorkItemかasynchronous delegatesのどちらかを使用することに
なります。二つの違いはasynchronous delegatesは戻り値を返せることと全ての例外を呼び出し元へ送ってくれる部分が
異なります。
QueueUserWorkItem
QueueUserWorkItemを使用するにはスレッドプールで実行したい処理をdelegateでこのメソッドに渡すことで可能です。
static void Main()
{
ThreadPool.QueueUserWorkItem (Go);
ThreadPool.QueueUserWorkItem (Go, 123);
Console.ReadLine();
}
static void Go (object data)
{
Console.WriteLine ("Hello from the thread pool! " + data);
}
WaitCallback delegateの定義に合わせるためにGoメソッドはdataというobject型の引数を1つ持つメソッドになってます。
これはメソッドにデータを渡すための便利な方法です。Taskとは違ってQueueUserWorkItemはその後の実行のためのオブジェクト
を返す方法がありません。また例外処理を適切に実装せず未処理の例外が発生した場合はプログラムが自動的に終了されます。
非同期デリゲート
QueueUserWorkItemは戻り値を取得する方法を提供しません。Asynchronous delegateはこの問題を解決します。様々な型の
複数の引数を渡したり戻り値を受け取ったりすることが可能です。さらに言えば未処理の例外を自動的に呼び出し元の
スレッドがEndInvokeを呼び出したタイミングで再スローします。ですので明示的に例外のキャッチをしなくても大丈夫です。
非同期デリゲートと非同期メソッドは違うものであることに注意してください。(非同期メソッドは
BeginもしくはEndで始まるメソッドです。例えばFile.BeginRead/File.ReadEndなどです)
非同期メソッドは外見上は非常に似た形ですが別の難解な問題を解決するために存在しています。
非同期デリゲートの開始の仕方は以下のようになります。
- 並列処理したいメソッドのdelegateをFuncクラスなどでインスタンス化します。
-
BeginInvokeを呼び出し戻り値のIAsyncResultを変数に保存します。BeginInvokeを呼び出すと直ぐに呼び出し元
に処理が戻ります。スレッドプールのスレッドが処理を実行している間、別の処理を実行することが可能です。
- 戻り値が必要な場合、生成したdelegateのEndInvokeに保存しておいたIASynchResultを渡してを呼び出します。
下の例ではstringを返す単純なメソッドを非同期デリゲートを使用してメインスレッドと並列に処理を実行しています。
static void Main()
{
Func<string, int> method = Work;
IAsyncResult cookie = method.BeginInvoke ("test", null, null);
//
// 並列に何かの処理を実行
//
int result = method.EndInvoke (cookie);
Console.WriteLine ("String length is: " + result);
}
static int Work (string s)
{
return s.Length;
}
EndInvokeは3つのことを行います。1つ目は非同期で実行したメソッドがまだ完了していない場合、完了するまで
待機することができます。2つ目は戻り値を取得して利用することができます。3つ目はワーカースレッドで発生した
例外を自動的に呼び出しもとのスレッドで再スローしてくれます。
非同期デリゲートが戻り値を持たない場合でもEndInvokeを呼ぶのを義務としてコードを作成したほうがよいでしょう。
EndInvokeを呼び出さなくてもコンパイラに何か怒られるわけではありません。
しかしEndInvokeを呼ばない場合、ワーカースレッドで例外が発生しても気づけないことがあるので注意が必要です。
BeginInvokeを呼び出すときにCallbackメソッドを指定することも可能です。指定するメソッドの引数はIAsyncResult
型になりここで指定したメソッドは処理が完了した後に呼ばれることになります。メインスレッドは呼び出した
非同期処理のその後のことを気にすることなく処理を実行しながらも、非同期処理の完了後にさらにいくつかの処理を
実行したいときなどにこの仕組みは活用できます。
static void Main()
{
Func<string, int> method = Work;
method.BeginInvoke ("test", Done, method);
// 呼び出した非同期処理のことは気にせずに
// 別の処理を行う
}
static int Work (string s) { return s.Length; }
static void Done (IAsyncResult cookie)
{
var target = (Func<string, int>) cookie.AsyncState;
int result = target.EndInvoke (cookie);
Console.WriteLine ("String length is: " + result);
}
BeginInvokeの最後の引数はユーザーの状態を示すオブジェクトを渡すことができこの値はIAsyncResultオブジェクトの
AsyncStateプロパティから取得ができます。型がobject型なのであらゆる値を格納することが可能です。この例では
非同期処理の完了時に実行するdelegateを渡しているのでEndInvokeを呼び出すことができるようになっています。
スレッドプールの最適化
スレッドプールは1つのスレッドを持った状態で初期化されます。タスクが登録されて指定された最大値を超えると
プールマネージャーが新しいスレッドを投入し並列に処理を実行できるようにスレッドの数が調整されます。
スレッドが使われなくなって十分な時間が経過するとプールマネージャーは処理効率を維持するために自動的に
いくつかのスレッドを破棄してプールのスレッドの数を調整します。
プールが新しいスレッドを作成する閾値となるスレッドの数をThreadPool.SetMaxThreadsで指定できます。既定値は
- .NET4.0(32bit) 1023
- .NET4.0(64bit) 32768
- .NET3.5 250/1Core
- .NET2.0 25/1Core
スレッドの数はハードウェアやOSによって様々な値に変化します。他のマシンのレスポンスを待っているスレッドが
多いような場合はスレッドプールのスレッドの数も多くなります。
同様にThreadPool.SetMinThreadsを使用して最小のスレッド数の閾値を設定することも可能です。この値の役割は
繊細です。この値を大きくすることでスレッドプールの中には最低でこの値以上のスレッドが用意されることになります。
実行中のスレッドの数がこの値以下ならばプールマネージャーが新しいスレッドを割り当てるときに遅延することなく
処理を開始させることが可能です。I/O待ちなどでブロックするスレッドが多数ある場合にこの値を大きくすると
並列処理の効率をアップさせることが可能です。
最小値の既定値は1つのプロセッサー当たり1つのスレッドが用意されます。これによりCPUをフルに活用して
処理を行うことができます。しかしながらサーバー環境の場合(ASP.NETやIISのような)この値はもっと大きく
50とかもっと大きい値にセットされることもあります。
スレッド数の最小値はどのように調整されるのか?
スレッド数の最小値をXに設定してもスレッドがX個すぐに作成されるわけではありません。スレッドは必要になった
タイミングで作成されます。正確に言うとプールマネージャーはスレッドの作成要求があったタイミングでスレッド
を作成することになります。スレッドプールがスレッドが必要になったタイミングでスレッドを作成しないと遅延
が起こるというのはどういうことでしょうか?
これは非常に短時間で終わる処理が一時的にたくさん発生した際に多くのスレッドが割り当てられて結果として
アプリケーションのメモリ使用量の増大が発生することを防ぐためです。より具体的に解説すると、4コアのマシン
でクライアントアプリケーションを実行し40個のタスクを一度に実行するとします。もしそれぞれのタスクが10ミリ秒
の計算を行うとすると、4つのコアに処理が割り振られると仮定すると全ての処理が完了するのに100ミリ秒かかること
になります。理想的にはこれらの40個のタスクが正確に4つのスレッドで実行されると最も良いでしょう。
- 4よりも少ないスレッド数の場合、コアを最大限使用していないことになります。
- 4よりも多いスレッド数の場合、メモリの無駄遣いと不要なスレッドの作成のためにCPU時間を浪費することになります。
この例がまさにスレッドプールがどのように動作するかを示しています。スレッドの処理が効率的に行われている限り
スレッドの数をコアの数と同じにしておくことでプログラムのメモリ使用量を小さく保ちパフォーマンスの劣化を招く
ことなくプログラムを走らせることができます。
しかしながらそれぞれのタスクが10ミリ秒ではなくインターネットからのレスポンス待ちで0.5秒ほどCPUをアイドル状態
にすると仮定すると状況は変わってきます。プールマネージャーのスレッド管理方法が適切ではなくなってしまいます。
この場合はスレッドをよりたくさん作成することでインターネットへのアクセスを同時に行うことができるようになります。
幸運なことにプールマネージャーはこの状況を解決するための手段を用意しています。もしそれぞれのタスクの処理が0.5秒
以上かかっている場合、プールマネージャーはスレッドを作成してくれます。なので0.5秒ごとにスレッドプールのスレッド
の数が増えていきより多くの処理を実行できるようになります。
この動作には2面性があります。一時的に短時間で終わる処理があった場合にメモリ量の増大を防ぐ一方で、データベース
へのクエリやWebClient.DownloadFileなどの実行によりスレッドプールのスレッドが不要な遅延を発生させるという側面も
あります。こういった理由からSetMinThreadsを呼び出すことで遅延を発生させずに処理を実行するということが可能に
なっています。
ThreadPool.SetMinThreads (50, 50);
メソッドの第2引数で指定しているのはI/O完了ポートで待ちうけ可能なスレッドの数を指定可能です。この値はAPMに
よって使用されます。既定値は1コアあたり1になります。