別スレッドとのデータの受け渡し
ThreadPool
クラスや、非同期デリゲートを使ってメソッドを別のスレッドで処理する方法では、スレッドにデータを渡し、かつ結果を取得する方法が用意されています。しかし、Thread.Start
メソッドでスレッドを開始した場合は、これが容易ではありません。なぜなら、Thread.Start
メソッドに必要なThreadStart
デリゲートには引数も戻り値もないからです。そこでこの場合は別の方法を考える必要があります。
ThreadStart
デリゲートだけでなく、引数を指定できるデリゲートも登場するようです。これに関してMSDNライブラリの「マルチスレッド プロシージャのパラメータと戻り値」では、別スレッドとして実行するメソッドはクラスにラップし、引数として機能するフィールドをそのクラスに定義するのが「最善の方法」であると述べられています。ここでは、この方法を紹介します。サンプル「parameter_01」でその具体例を示します。
'マルチスレッドメソッドのためのクラス Class ThreadInfo 'スレッドが返す値 Public ReturnValue As Integer 'スレッドに渡す値 Public sleepTime As Integer 'インストラクタ Public Sub New(ByVal stime As Integer) Me.sleepTime = stime End Sub '別スレッドで実行するメソッド Public Sub MyThreadMethod() 'sleepTimeミリ秒停止する System.Threading.Thread.Sleep(Me.sleepTime) 'TickCountを返す Me.ReturnValue = System.Environment.TickCount End Sub End Class Class MainClass 'エントリポイント Public Shared Sub Main() 'ThreadInfoオブジェクトの作成 'スレッドに渡すデータを設定 Dim info As New ThreadInfo(1000) 'Threadオブジェクトを作成する Dim t As New System.Threading.Thread( _ New System.Threading.ThreadStart( _ AddressOf info.MyThreadMethod)) 'スレッドを開始する t.Start() 'スレッドtが終わるまで待機する t.Join() '結果を表示する Console.WriteLine(info.ReturnValue) Console.ReadLine() End Sub End Class
//マルチスレッドメソッドのためのクラス class ThreadInfo { //スレッドが返す値 public int ReturnValue; //スレッドに渡す値 public int sleepTime; //インストラクタ public ThreadInfo(int stime) { this.sleepTime = stime; } //別スレッドで実行するメソッド public void MyThreadMethod() { //sleepTimeミリ秒停止する System.Threading.Thread.Sleep(this.SleepTime); //TickCountを返す this.ReturnValue = System.Environment.TickCount; } } class MainClass { //エントリポイント public static void Main() { //ThreadInfoオブジェクトの作成 //スレッドに渡すデータを設定 ThreadInfo info = new ThreadInfo(1000); //Threadオブジェクトを作成する System.Threading.Thread t = new System.Threading.Thread( new System.Threading.ThreadStart(info.MyThreadMethod)); //スレッドを開始する t.Start(); //スレッドtが終わるまで待機する t.Join(); //結果を表示する Console.WriteLine(info.ReturnValue); Console.ReadLine(); } }
このサンプルでは、まずThreadInfo
クラスを作成し、別スレッドで実行するメソッドとしてMyThreadMethod
メソッドを定義します。またMyThreadMethod
メソッドへの引数として機能するフィールドとしてsleepTime
を、戻り値として機能するフィールドにReturnValue
を定義しています。
スレッド開始前にThreadInfo
オブジェクトのsleepTime
フィールドに引数となる値を設定します。結果の取得は、スレッドが確実に終了したところで、ReturnValue
フィールドから取得します。
コールバックデリゲートの使用
上記の方法ではJoin
メソッドでスレッドの終了を待機していますが、コールバックメソッドを使うことにより、その必要がなくなります。また、戻り値をコールバックメソッドの引数として受け取ることができます。
サンプル「parameter_02」は、サンプル「parameter_01」を書き換え、コールバックメソッドを使うようにしています。
Class ThreadInfo 'スレッドが結果を返すためのコールバックデリゲート Delegate Sub MyThreadCallback(ByVal returnValue As Integer) Private callbackDelegate As MyThreadCallback 'スレッドに渡す値 Public SleepTime As Integer 'インストラクタ Public Sub New(ByVal stime As Integer, _ ByVal callback As MyThreadCallback) Me.SleepTime = stime Me.callbackDelegate = callback End Sub '別スレッドで実行するメソッド Public Sub MyThreadMethod() 'sleepTimeミリ秒停止する System.Threading.Thread.Sleep(Me.SleepTime) 'コールバックデリゲートを実行して結果を返す If Not (Me.callbackDelegate Is Nothing) Then Me.callbackDelegate(System.Environment.TickCount) End If End Sub End Class Class MainClass 'エントリポイント Public Shared Sub Main() 'ThreadInfoオブジェクトの作成 'スレッドにデータを渡す Dim info As New ThreadInfo(1000, _ New ThreadInfo.MyThreadCallback( _ AddressOf GetCallbackResult)) 'Threadオブジェクトを作成する Dim t As New System.Threading.Thread( _ New System.Threading.ThreadStart( _ AddressOf info.MyThreadMethod)) 'スレッドを開始する t.Start() Console.ReadLine() End Sub 'スレッド終了時に呼び出されるコールバックメソッド Private Shared Sub GetCallbackResult(ByVal returnValue As Integer) '結果を表示する Console.WriteLine(returnValue) End Sub End Class
class ThreadInfo { //スレッドが結果を返すためのコールバックデリゲート public delegate void MyThreadCallback(int returnValue); private MyThreadCallback callbackDelegate; //スレッドに渡す値 public int SleepTime; //インストラクタ public ThreadInfo(int stime, MyThreadCallback callback) { this.SleepTime = stime; this.callbackDelegate = callback; } //別スレッドで実行するメソッド public void MyThreadMethod() { //sleepTimeミリ秒停止する System.Threading.Thread.Sleep(this.SleepTime); //コールバックデリゲートを実行して結果を返す if (this.callbackDelegate != null) { this.callbackDelegate(System.Environment.TickCount); } } } class MainClass { //エントリポイント public static void Main() { //ThreadInfoオブジェクトの作成 //スレッドにデータを渡す ThreadInfo info = new ThreadInfo(1000, new ThreadInfo.MyThreadCallback(GetCallbackResult)); //Threadオブジェクトを作成する System.Threading.Thread t = new System.Threading.Thread( new System.Threading.ThreadStart(info.MyThreadMethod)); //スレッドを開始する t.Start(); Console.ReadLine(); } //スレッド終了時に呼び出されるコールバックメソッド private static void GetCallbackResult(int returnValue) { //結果を表示する Console.WriteLine(returnValue); } }
この例では、MyThreadMethod
メソッドの最後で指定されたコールバックデリゲートを実行することにより、結果を返しています。なおコールバックメソッド(ここではGetCallbackResult
メソッド)は、MyThreadMethod
メソッドを実行したスレッドで実行されることに注意してください。
Interlockedクラス
「スレッドの同期」で、競合状態について説明しました。そこで示した例は非常に分かりやすいものでしたが、整数のインクリメント「i++
」のように一見他のスレッドが間に入り込む余地がなく、競合状態など起こらないだろうと思われる操作でさえも、競合状態は発生します。
「i++
」という操作は実際には、
「変数iの値を取得する」→「1を足す」→「結果を変数i
に格納する」
という複数の操作で行われています。よって、現在i
の値が0のときに、あるスレッドがi
に1を足してi
に格納する間に、別のスレッドがi
に1を足すというケースも考えられます。この場合、「i++」が2回呼び出されたわけですから、i
は2となるべきです。しかし実際には、はじめのスレッドで1つ足した結果をi
に格納する前に別のスレッドがi
を取得していますので、i
は1にしかなりません。
つまり、「i++
」という操作でも次のような同期が必要です。
'インクリメントする SyncLock syncCount i += 1 End SyncLock
//インクリメントする lock (syncObject) { i++; }
このように一見一回の操作で行われているように見える操作も、実は何回かの操作によって行われていることがあります。しかし、分割不可能な操作として行われることが保障され、競合状態が発生する隙を与えない操作もあります。このように操作が分割されないことが保障されていることを「アトミック(atomic)」と呼びます。
このようなアトミックな操作を行うメソッドが、Interlocked
クラスに用意されています。Interlocked
クラスのIncrement
メソッドでは整数のインクリメント、Decrement
メソッドでは整数のデクリメント、Exchange
メソッドでは変数への値の設定(変数の値の交換)、CompareExchange
メソッドでは変数の比較と値の設定を行います。
つまり上記のコードは次のように書き換えることができます。
'インクリメントする
System.Threading.Interlocked.Increment(i)
//インクリメントする System.Threading.Interlocked.Increment(ref i);
このようなInterlocked
クラスのメソッドを使用する方が、lock
を使用するよりも、安全面、パフォーマンス面で優れています。もし可能ならば、Interlocked
の使用を積極的に検討してください。
スレッドの同期を行わずに複数のスレッドから同じフィールドにアクセスする
「スレッドの同期」でマルチスレッドプログラミングの難しさを実感していただけたと思いますが、ここではこれよりもさらに難解な問題を紹介しなければなりません。
まずはサンプル「volatile_01」をご覧ください。
Class Class1 Private Shared _canceled As Boolean = False 'エントリポイント Public Shared Sub Main() 'DoSomethingメソッドを別のスレッドで実行する 'Threadオブジェクトを作成する Dim t As New System.Threading.Thread( _ New System.Threading.ThreadStart(AddressOf DoSomething)) 'スレッドを開始する t.Start() Console.WriteLine("Enterキーを押してください") Console.ReadLine() '_canceledをtrueにする _canceled = True Console.WriteLine("終わりました") Console.ReadLine() End Sub 'メソッド Private Shared Sub DoSomething() '_canceledがtrueになるまでループする While Not _canceled '何らかの作業があるものとする System.Threading.Thread.Sleep(100) End While End Sub End Class
class Class1 { private static bool _canceled = false; //エントリポイント public static void Main() { //DoSomethingメソッドを別のスレッドで実行する //Threadオブジェクトを作成する System.Threading.Thread t = new System.Threading.Thread( new System.Threading.ThreadStart(DoSomething)); //スレッドを開始する t.Start(); Console.WriteLine("Enterキーを押してください"); Console.ReadLine(); //_canceledをtrueにする _canceled = true; Console.WriteLine("終わりました"); Console.ReadLine(); } //メソッド private static void DoSomething() { //_canceledがtrueになるまでループする while (!_canceled) { //何らかの作業があるものとする System.Threading.Thread.Sleep(100); } } }
このプログラムは、_canceled
フィールドというフラッグを使って、別スレッドのループ処理を終了させるものです。普通ならば、メインスレッドで_canceled
がtrue
となったときにDoSomething
メソッドではループが終了するはずです。ところがそうはならずに、ループが永遠に回り続ける可能性があるのです。
これは簡単に言うと、命令を並べ替える最適化が行われると、フィールドへの書き込みと読み取りの順番が変わってしまう可能性があるためですが、ここでは詳しくは論じません(詳しくは、下記のリンク先や参考資料などをご覧ください)。
- Jon's Homepage! 『Volatility, Atomicity and Interlocking』
- microsoft.public.dotnet.framework.compactframeworkの記事
- パート4のシングルトンの解説
とにかく、このような問題はスレッドの同期を行わずに複数のスレッドから同じフィールドにアクセスする場合に起こりえます。この問題を解決するには、volatile
修飾子を使うか、lock
のような同期を行います。
volatile
と同様のことが、.NET Framework 1.1から追加されたThread.VolatileRead
、VolatileWrite
メソッドでもできるようです。また同じく1.1から追加されたThread.MemoryBarrier
を使う方法も考えられます。 volatile
修飾子を使用して書き換えると、サンプル「volatile_02」のようになります(VB.NETのサンプルはありません)。
class Class1 { private static volatile bool _canceled = false; //エントリポイント public static void Main() { //DoSomethingメソッドを別のスレッドで実行する //Threadオブジェクトを作成する System.Threading.Thread t = new System.Threading.Thread( new System.Threading.ThreadStart(DoSomething)); //スレッドを開始する t.Start(); Console.WriteLine("Enterキーを押してください"); Console.ReadLine(); //_canceledをtrueにする _canceled = true; Console.WriteLine("終わりました"); Console.ReadLine(); } //メソッド private static void DoSomething() { //_canceledがtrueになるまでループする while (!_canceled) { //何らかの作業があるものとする System.Threading.Thread.Sleep(100); } } }
_canceled
フィールドを宣言するときにvolatile
修飾子を付けるだけですから、簡単です。このようにvolatile
を付けると、このフィールドの最適化が制限されます(詳しくは、MSDNライブラリを参照)。
volatile
修飾子は残念ながらVB.NETには用意されていません。また、volatile
を付けられるフィールドは、次のようなものに限られています。
- 参照型
- ポインタ型(
unsafe
コンテキスト内) sbyte
、byte
、short
、ushort
、int
、uint
、char
、float
、bool
の各型- 列挙型と
byte
、sbyte
、short
、ushort
、int
、またはuint
の列挙基本型
volatile
修飾子は.NET Compact Frameworkでもサポートされていません。ただし、こちらのニュースグループの記事によると、.NET CF 1でビルドし、CFデバイスのみを対象とした場合は、すべてのフィールドはvolatile
として扱われるとのことです。また、.NET CF 2では、volatile
がサポートされるようです。.NET CFでvolatile
を指定したときにエラーを出さない方法は、Neil Cowburnの記事で紹介されています。 volatile
を使わずにlock
を使ったサンプルが「volatile_03」です。
Class Class1 Private Shared _canceled As Boolean = False Private Shared _canceledSync As New Object 'プロパティ Private Shared Property Canceled() As Boolean Get SyncLock _canceledSync Return _canceled End SyncLock End Get Set(ByVal Value As Boolean) SyncLock _canceledSync _canceled = Value End SyncLock End Set End Property 'エントリポイント Public Shared Sub Main() 'DoSomethingメソッドを別のスレッドで実行する 'Threadオブジェクトを作成する Dim t As New System.Threading.Thread( _ New System.Threading.ThreadStart(AddressOf DoSomething)) 'スレッドを開始する t.Start() Console.WriteLine("Enterキーを押してください") Console.ReadLine() '_canceledをtrueにする Canceled = True Console.WriteLine("終わりました") Console.ReadLine() End Sub 'メソッド Private Shared Sub DoSomething() '_canceledがtrueになるまでループする While Not Canceled '何らかの作業があるものとする System.Threading.Thread.Sleep(100) End While End Sub End Class
class Class1 { private static bool _canceled = false; private static object _canceledSync = new object(); //プロパティ private static bool Canceled { get { lock (_canceledSync) { return _canceled; } } set { lock (_canceledSync) { _canceled = value; } } } //エントリポイント public static void Main() { //DoSomethingメソッドを別のスレッドで実行する //Threadオブジェクトを作成する System.Threading.Thread t = new System.Threading.Thread( new System.Threading.ThreadStart(DoSomething)); //スレッドを開始する t.Start(); Console.WriteLine("Enterキーを押してください"); Console.ReadLine(); //_canceledをtrueにする Canceled = true; Console.WriteLine("終わりました"); Console.ReadLine(); } //メソッド private static void DoSomething() { //_canceledがtrueになるまでループする while (!Canceled) { //何らかの作業があるものとする System.Threading.Thread.Sleep(100); } } }
ここでは、_canceled
フィールドにCanceled
プロパティを通してアクセスするようにして、Canceled
プロパティ内でロックを行っています。
volatile
と比べlock
を使用した方がパフォーマンス的には劣ります。しかし、「複数のスレッドから共有リソースへアクセスする場合は、lock
を使用する」という考え方は非常にシンプルで、しかも確実です。
参考資料8によると、volatile
の使用は処理速度を落とすため、制限しろということです。しかし、volatile
が必要な場面とそうでない場面との違いをはっきりと説明したドキュメントが見つからないため、volatile
の厳密な使用法は非常に分かりにくいです(例えば、「スレッドプールや、非同期デリゲートなどを使った場合でもvolatile
は必要か」など)。筆者自身この点に関して適切なアドバイスができないことをお許しください。
まとめ
以上が、.NET Frameworkにおけるマルチスレッドプログラミングで必ず知っておくべきと、筆者が判断した事柄です。しかし、これ以降(パート3、パート4)で紹介する事柄も基本的なものばかりですので、少なくとも頭の隅には入れておいてください。
参考資料
- Jon's Homepage! 『Multi-threading in .NET』
- MSDNライブラリ 『.NET Framework開発者ガイド-スレッド処理』
- MSDNライブラリ 『Visual Basic言語の概念-Visual Basic .NETにおけるマルチスレッド』
- MSDNライブラリ 『.NET Framework開発者ガイド-非同期呼び出しの組み込み』
- MSDNライブラリ 『コンポーネントのマルチスレッド』
- MSDNライブラリ 『スレッド処理のデザイン ガイドライン』
- MSDNライブラリ 『非同期プログラミングのガイドライン』
- MSDNライブラリ 『Chapter 4 - Architecture and Design Review of a .NET Application for Performance and Scalability』