目次
- はじめに
- 対象読者
- 必要な環境
- 別スレッドからフォーム、コントロールを扱う
- 待機ハンドル
- スレッドプール
- 非同期デリゲート
- 非同期メソッド
- 別スレッドとのデータの受け渡し
- Interlockedクラス
- スレッドの同期を行わずに複数のスレッドから同じフィールドにアクセスする
- まとめ
- 参考資料
はじめに
前回のパート1では、コンソールアプリケーションのサンプルを作成しながら、.NET Frameworkにおけるスレッドの実行や同期の方法などを学びました。本稿では、スレッドでフォームやコントロールを扱う方法や、「スレッドプール」「非同期デリゲート」などの事柄について解説します。
対象読者
この記事はパート1の続きですので、パート1からお読みください。
必要な環境
サンプルはVisual Studio .NET 2003で作成し、.NET Framework 1.1で動作確認をしています。
別スレッドからフォーム、コントロールを扱う
パート1で幾つかのサンプルを紹介してきましたが、すべてコンソールアプリケーションでした。実はこれには深い訳があります。
Console
クラスはスレッドセーフであり、マルチスレッド操作に対して安全が保障されています。ところが、Windowsアプリケーションのコントロール(フォームを含む)は違います。Control
クラスでスレッドセーフが保障されているのは、Invoke
、BeginInvoke
、EndInvoke
、CreateGraphics
メソッドおよびInvokeRequired
プロパティのみです。さらにWindowsフォームはシングルスレッドアパートメント(STA)モデルを使用しており、コントロールのメソッド(あるいはプロパティ)はそのコントロールを作成したスレッド(UIスレッド)からしか呼び出すことができません。つまり、スレッドセーフが保障されているメソッドを除き、コントロールのメソッドを別スレッドから直接呼び出してはいけません。
これを解決するには、スレッドの境界を越えて実行を行う、マーシャリングが必要になります。.NET FrameworkのControl
クラスでは、マーシャリングを確実かつ効率よく行う方法として、Invoke
、BeginInvoke
、およびEndInvoke
メソッドが用意されています。
Invokeメソッド
サンプル「form_01」は、Invoke
メソッドを使用した簡単な例です。フォームに配置されたテキストボックスTextBox1
にメインスレッドとは別のスレッドから文字列を追加しています。以下にコードの一部を抜粋します。
'Invokeメソッドで使用するデリゲート Delegate Function WriteLineDelegate(ByVal str As String) As Integer Private Sub Button1_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles Button1.Click 'メインスレッドに名前をつける If System.Threading.Thread.CurrentThread.Name Is Nothing Then System.Threading.Thread.CurrentThread.Name = "メインスレッド" End If 'WriteLinesメソッドを別スレッドで呼び出す 'スレッドの作成 Dim t As New System.Threading.Thread( _ New System.Threading.ThreadStart( _ AddressOf WriteLines)) t.Name = "サブスレッド" 'スレッドを開始する t.Start() End Sub '別スレッドで実行するメソッド Private Sub WriteLines() '現在のスレッドの名前を取得 Dim threadName As String = _ System.Threading.Thread.CurrentThread.Name 'WriteLineDelegateの作成 Dim dlg As New WriteLineDelegate(AddressOf WriteLine) 'TextBox1に文字列を追加する Dim count As Integer = _ CInt(Me.Invoke(dlg, New Object() {threadName})) End Sub 'TextBox1に文字列を追加する Private Function WriteLine(ByVal str As String) As Integer '現在のスレッドの名前を取得 Dim threadName As String = _ System.Threading.Thread.CurrentThread.Name TextBox1.AppendText _ (("呼び出しもとのスレッドは[" + str + "]" + vbCrLf)) TextBox1.AppendText _ (("現在のスレッドは[" + threadName + "]" + vbCrLf)) Return TextBox1.Lines.Length End Function
//Invokeメソッドで使用するデリゲート private delegate int WriteLineDelegate(string str); //Button1のClickイベントハンドラ private void Button1_Click(object sender, System.EventArgs e) { //メインスレッドに名前をつける if (System.Threading.Thread.CurrentThread.Name == null) System.Threading.Thread.CurrentThread.Name = "メインスレッド"; //WriteLinesメソッドを別スレッドで呼び出す //スレッドの作成 System.Threading.Thread t = new System.Threading.Thread( new System.Threading.ThreadStart(WriteLines)); t.Name = "サブスレッド"; //スレッドを開始する t.Start(); } //別スレッドで実行するメソッド private void WriteLines() { //現在のスレッドの名前を取得 string threadName = System.Threading.Thread.CurrentThread.Name; //WriteLineDelegateの作成 WriteLineDelegate dlg = new WriteLineDelegate(WriteLine); //TextBox1に文字列を追加する int count = (int) this.Invoke(dlg, new object[] {threadName}); } //TextBox1に文字列を追加する private int WriteLine(string str) { //現在のスレッドの名前を取得 string threadName = System.Threading.Thread.CurrentThread.Name; TextBox1.AppendText("呼び出しもとのスレッドは[" + str + "]\r\n"); TextBox1.AppendText("現在のスレッドは[" + threadName + "]\r\n"); return TextBox1.Lines.Length; }
Thread.CurrentThread
は、現在のスレッドのThread
オブジェクトを取得するプロパティです。また、Thread.Name
は、スレッドの名前を取得、設定するプロパティです。
このコードでは、ボタンコントロールButton1
をクリックすることにより、別スレッドからTextBox1
に文字列を追加しています。TextBox1
を作成したスレッド(ここではメインスレッド)とは別のスレッドからTextBox1
のメソッドを直接呼び出すことはできませんので、TextBox1
を操作するWriteLine
メソッドを用意し、これをInvoke
メソッドで呼び出します。このようにすることにより、WriteLine
メソッドはメインスレッドで実行されるようになります。
Button1
をクリックした結果、TextBox1
には、
呼び出しもとのスレッドは[サブスレッド] 現在のスレッドは[メインスレッド]
と表示されます。Invoke
メソッドで呼び出されたメソッドはメインスレッドで実行されていることが分かると思います。
Invoke
メソッドを使ってUIスレッド以外のスレッドからコントロールを操作する手順をまとめると、次のようになります。
- コントロールのメソッドやプロパティを呼び出すためのメソッドを作成する。
- そのメソッドに合ったデリゲートを宣言する。
- デリゲートオブジェクトを作成する。
- コントロール、あるいはコントロールのあるフォームの
Invoke
メソッドをデリゲートオブジェクトを指定して呼び出す。
なお当たり前のことですが、Invoke
で呼び出すメソッドはなるべく短時間で終了するようにしてください。あまりに長いと、別スレッドで実行する意味がなくなってしまいます。
BeginInvoke・EndInvokeメソッド
Invoke
メソッドは同期的にデリゲートを実行するため、メソッドが終了するまでブロックします。これに対してBeginInvoke
メソッドは非同期的にデリゲートを実行するため、ブロックしません。
サンプル「form_02」がその例です。
'別スレッドで実行するメソッド Private Sub WriteLines() '現在のスレッドの名前を取得 Dim threadName As String = _ System.Threading.Thread.CurrentThread.Name 'WriteLineDelegateの作成 Dim dlg As New WriteLineDelegate(AddressOf WriteLine) 'Invokeを使ってTextBox1に文字列を追加する Dim ar As IAsyncResult = _ Me.BeginInvoke(dlg, New Object() {threadName}) '戻り値を取得する Dim count As Integer = CInt(TextBox1.EndInvoke(ar)) End Sub
//別スレッドで実行するメソッド private void WriteLines() { //現在のスレッドの名前を取得 string threadName = System.Threading.Thread.CurrentThread.Name; //WriteLineDelegateの作成 WriteLineDelegate dlg = new WriteLineDelegate(WriteLine); //Invokeを使ってTextBox1に文字列を追加する IAsyncResult ar = this.BeginInvoke(dlg, new object[] {threadName}); //戻り値を取得する int count = (int) TextBox1.EndInvoke(ar); }
EndInvoke
メソッドは、BeginInvoke
で呼び出したメソッドが終了するまでブロックします(よって上のコードは非同期としている意味がありません)。もし戻り値を取得する必要がなければ、EndInvoke
を呼び出す必要はありません。
Invoke
やEndInvoke
メソッドはスレッドをブロックするため、デッドロックの原因となる危険があります。戻り値を取得する必要がなければ、BeginInvoke
メソッドのみを呼び出すのがより安全な方法です。
InvokeRequiredプロパティ
あるコントロールのメソッドを呼び出すときに、Invoke
メソッドを使用する必要があるか調べる方法として、Control.InvokeRequired
プロパティが用意されています。そのコントロールを作成したスレッドがInvokeRequired
を呼び出したスレッドと異なる場合はtrue
、それ以外はfalse
を返します。
上記のWriteLines
メソッドを次のように書き換えることにより、riteLines
メソッドをメインスレッドから呼び出したときはWriteLine
メソッドを直接呼び出すようにできます(サンプル「form_03」)。
'別スレッドで実行するメソッド Private Sub WriteLines() Dim count As Integer '現在のスレッドの名前を取得 Dim threadName As String = _ System.Threading.Thread.CurrentThread.Name 'TextBox1に文字列を追加する 'Invokeが必要か調べる If Me.InvokeRequired Then 'WriteLineDelegateの作成 Dim dlg As New WriteLineDelegate(AddressOf WriteLine) 'InvokeでWriteLineメソッドを呼び出す count = CInt(Me.Invoke(dlg, New Object() {threadName})) Else 'WriteLineメソッドを直接呼び出す count = Me.WriteLine(threadName) End If End Sub
private void WriteLines() { int count; //現在のスレッドの名前を取得 string threadName = System.Threading.Thread.CurrentThread.Name; //TextBox1に文字列を追加する //Invokeが必要か調べる if (this.InvokeRequired) { //WriteLineDelegateの作成 WriteLineDelegate dlg = new WriteLineDelegate(WriteLine); //InvokeでWriteLineメソッドを呼び出す count = (int) this.Invoke(dlg, new object[] {threadName}); } else { //WriteLineメソッドを直接呼び出す count = this.WriteLine(threadName); } }
なおInvokeRequired
がfalse
のときにInvoke
でメソッドを呼び出しても特に問題はないようです。
MethodInvoker・EventHandlerデリゲート
今までの例ではInvoke
で実行するデリゲートを独自に定義していましたが、すでに用意されているMethodInvoker
またはEventHandler
デリゲートを使うこともできます。しかも、これらのデリゲートを使用した方がより高速で実行されるということです。
サンプル「form_04」では、EventHandler
デリゲートを使用しています。
'Button1のClickイベントハンドラ Private Sub Button1_Click(ByVal sender As System.Object, _ ByVal e As System.EventArgs) Handles Button1.Click 'メインスレッドに名前をつける If System.Threading.Thread.CurrentThread.Name Is Nothing Then System.Threading.Thread.CurrentThread.Name = "メインスレッド" End If 'WriteLinesメソッドを別スレッドで呼び出す 'スレッドの作成 Dim t As New System.Threading.Thread( _ New System.Threading.ThreadStart( _ AddressOf WriteLines)) t.Name = "サブスレッド" 'スレッドを開始する t.Start() End Sub '別スレッドで実行するメソッド Private Sub WriteLines() 'WriteLineDelegateの作成 Dim dlg As New EventHandler(AddressOf WriteLine) 'TextBox1に文字列を追加する Me.Invoke(dlg, New Object() {Nothing, Nothing}) End Sub 'TextBox1に文字列を追加する Private Sub WriteLine(ByVal sender As Object, _ ByVal e As System.EventArgs) '現在のスレッドの名前を取得 Dim threadName As String = _ System.Threading.Thread.CurrentThread.Name TextBox1.AppendText _ (("現在のスレッドは[" + threadName + "]" + vbCrLf)) End Sub
//Button1のClickイベントハンドラ private void Button1_Click(object sender, System.EventArgs e) { //メインスレッドに名前をつける if (System.Threading.Thread.CurrentThread.Name == null) System.Threading.Thread.CurrentThread.Name = "メインスレッド"; //WriteLinesメソッドを別スレッドで呼び出す //スレッドの作成 System.Threading.Thread t = new System.Threading.Thread( new System.Threading.ThreadStart(WriteLines)); t.Name = "サブスレッド"; //スレッドを開始する t.Start(); } //別スレッドで実行するメソッド private void WriteLines() { //WriteLineDelegateの作成 EventHandler dlg = new EventHandler(WriteLine); //TextBox1に文字列を追加する this.Invoke(dlg, new object[] {null, null}); } //TextBox1に文字列を追加する private void WriteLine(object sender, System.EventArgs e) { //現在のスレッドの名前を取得 string threadName = System.Threading.Thread.CurrentThread.Name; TextBox1.AppendText("現在のスレッドは[" + threadName + "]\r\n"); }
MethodInvoker
とEventHandler
デリゲートを使用したときは、メソッドに引数を渡したり、戻り値を受け取ることができません。EventHandler
には2つの引数がありますが、これらに何を指定してもsender
はInvoke
を呼び出したコントロール、e
はEventArgs.Empty
となります。
待機ハンドル
「待機ハンドル」と呼ばれるWaitHandle
オブジェクトは、スレッドの同期に使われます。このWaitHandle
は、後述するThreadPool.RegisterWaitForSingleObject
メソッドや非同期デリゲートで使用されますので、その前に説明しておきます。
待機ハンドルの状態には「シグナル状態」と「非シグナル状態」の2つがあり、待機ハンドルをどのスレッドも所有していなければ「シグナル状態」、所有していれば「非シグナル状態」です。WaitHandle.WaitOne
メソッドなどを使うことにより、待機ハンドルがシグナル状態になるまでスレッドをブロックすることができます。
.NET FrameworkではWaitHandle
クラスから派生したクラスとして、ManualResetEvent
、AutoResetEvent
およびMutex
クラスが用意されています。その内ここでは、ManualResetEvent
とAutoResetEvent
について説明し、Mutex
についてはパート3で説明します。ちなみにManualResetEvent
とAutoResetEvent
は、「同期イベント」または「イベントオブジェクト」と呼ばれています。
ManualResetEvent
サンプル「waithandle_01」は、ManualResetEvent
を使った非常に簡単な例です。
Class Class1 Private Shared manualEvent As System.Threading.ManualResetEvent 'エントリポイント Public Shared Sub Main() '非シグナル状態でManualResetEventオブジェクトを作成 manualEvent = New System.Threading.ManualResetEvent(False) 'スレッドを作成し、開始する Dim t As New System.Threading.Thread( _ New System.Threading.ThreadStart( _ AddressOf DoSomething)) t.Start() 'Enterキーが押されるまで待機 Console.WriteLine("Enterキーを押してください") Console.ReadLine() 'シグナル状態にする manualEvent.Set() Console.WriteLine("メインスレッド終了") Console.ReadLine() End Sub '別スレッドで実行するメソッド Private Shared Sub DoSomething() Console.WriteLine("別スレッド開始") 'シグナル状態になるまでスレッドをブロックする manualEvent.WaitOne() Console.WriteLine("別スレッド終了") End Sub End Class
class Class1 { private static System.Threading.ManualResetEvent manualEvent; //エントリポイント public static void Main() { //非シグナル状態でManualResetEventオブジェクトを作成 manualEvent = new System.Threading.ManualResetEvent(false); //スレッドを作成し、開始する System.Threading.Thread t = new System.Threading.Thread( new System.Threading.ThreadStart(DoSomething)); t.Start(); //Enterキーが押されるまで待機 Console.WriteLine("Enterキーを押してください"); Console.ReadLine(); //シグナル状態にする manualEvent.Set(); Console.WriteLine("メインスレッド終了"); Console.ReadLine(); } //別スレッドで実行するメソッド private static void DoSomething() { Console.WriteLine("別スレッド開始"); //シグナル状態になるまでスレッドをブロックする manualEvent.WaitOne(); Console.WriteLine("別スレッド終了"); } }
このアプリケーションでは、DoSomething
メソッドを別スレッドで実行しますが、[Enter]キーを押すまでこのスレッドは終了しません。
このコードでは、まず非シグナル状態でManualResetEvent
オブジェクト「manualEvent
」を作成します。メインスレッドではDoSomething
メソッドを実行する別スレッドを作成した後、[Enter]キーが押されるまで待機します。別スレッドではWaitHandle.WaitOne
メソッドを呼び出し、manualEvent
がシグナル状態になるまで待機します。メインスレッドで[Enter]キーが押されるとWaitHandle.Set
メソッドが呼び出され、manualEvent
がシグナル状態になります。すると、別スレッドのWaitOne
メソッドによる待機が解除され、DoSomething
メソッドが終了します。
AutoResetEvent
次に、ManualResetEvent
により、2つのスレッドを交互に実行する例を示します(サンプル「waithandle_02」)。
Class Class1 Private Shared manualEvent1 As System.Threading.ManualResetEvent Private Shared manualEvent2 As System.Threading.ManualResetEvent 'エントリポイント Public Shared Sub Main() '非シグナル状態でManualResetEventオブジェクトを作成 manualEvent1 = New System.Threading.ManualResetEvent(False) manualEvent2 = New System.Threading.ManualResetEvent(False) 'スレッドを2つ作成し、開始する Dim t1 As New System.Threading.Thread( _ New System.Threading.ThreadStart( _ AddressOf DoSomething1)) Dim t2 As New System.Threading.Thread( _ New System.Threading.ThreadStart( _ AddressOf DoSomething2)) t1.Start() t2.Start() 'manualEvent1をシグナル状態にする manualEvent1.Set() Console.ReadLine() End Sub '別スレッドで実行するメソッド1 Private Shared Sub DoSomething1() Dim i As Integer For i = 0 To 9 'manualEvent1がシグナル状態になるまでスレッドをブロック manualEvent1.WaitOne() 'manualEvent1を非シグナル状態にする manualEvent1.Reset() Console.WriteLine("1") 'manualEvent2をシグナル状態にする manualEvent2.Set() Next i End Sub '別スレッドで実行するメソッド2 Private Shared Sub DoSomething2() Dim i As Integer For i = 0 To 9 'manualEvent2がシグナル状態になるまでスレッドをブロック manualEvent2.WaitOne() 'manualEvent2を非シグナル状態にする manualEvent2.Reset() Console.WriteLine("2") 'manualEvent1をシグナル状態にする manualEvent1.Set() Next i End Sub 'DoSomething2 End Class 'Class1
class Class1 { private static System.Threading.ManualResetEvent manualEvent1; private static System.Threading.ManualResetEvent manualEvent2; //エントリポイント public static void Main() { //非シグナル状態でManualResetEventオブジェクトを作成 manualEvent1 = new System.Threading.ManualResetEvent(false); manualEvent2 = new System.Threading.ManualResetEvent(false); //スレッドを2つ作成し、開始する System.Threading.Thread t1 = new System.Threading.Thread( new System.Threading.ThreadStart(DoSomething1)); System.Threading.Thread t2 = new System.Threading.Thread( new System.Threading.ThreadStart(DoSomething2)); t1.Start(); t2.Start(); //manualEvent1をシグナル状態にする manualEvent1.Set(); Console.ReadLine(); } //別スレッドで実行するメソッド1 private static void DoSomething1() { for (int i = 0; i < 10; i++) { //manualEvent1がシグナル状態になるまでスレッドをブロック manualEvent1.WaitOne(); //manualEvent1を非シグナル状態にする manualEvent1.Reset(); Console.WriteLine("1"); //manualEvent2をシグナル状態にする manualEvent2.Set(); } } //別スレッドで実行するメソッド2 private static void DoSomething2() { for (int i = 0; i < 10; i++) { //manualEvent2がシグナル状態になるまでスレッドをブロック manualEvent2.WaitOne(); //manualEvent2を非シグナル状態にする manualEvent2.Reset(); Console.WriteLine("2"); //manualEvent1をシグナル状態にする manualEvent1.Set(); } } }
このプログラムを実行すると、「1」と「2」が交互に出力されます。
上記のコードではManualResetEvent.WaitOne
メソッドの直後でReset
メソッドを呼び出して非シグナル状態に戻していますが、ManualResetEvent
の代わりにAutoResetEvent
を使うことにより、この手間が省けます。AutoResetEvent
ではシグナルを待機中のスレッドがすべて解放されると、自動的に非シグナル状態にリセットされるのです(待機中のスレッドがない場合は、無限にシグナル状態のままとなります)。
上記のコードをAutoResetEvent
を使って書き換えると、次のようになります(サンプル「waithandle_03」)。
Class Class1 Private Shared autoEvent1 As System.Threading.AutoResetEvent Private Shared autoEvent2 As System.Threading.AutoResetEvent 'エントリポイント Public Shared Sub Main() '非シグナル状態でManualResetEventオブジェクトを作成 autoEvent1 = New System.Threading.AutoResetEvent(False) autoEvent2 = New System.Threading.AutoResetEvent(False) 'スレッドを2つ作成し、開始する Dim t1 As New System.Threading.Thread( _ New System.Threading.ThreadStart( _ AddressOf DoSomething1)) Dim t2 As New System.Threading.Thread( _ New System.Threading.ThreadStart( _ AddressOf DoSomething2)) t1.Start() t2.Start() 'manualEvent1をシグナル状態にする autoEvent1.Set() Console.ReadLine() End Sub '別スレッドで実行するメソッド1 Private Shared Sub DoSomething1() Dim i As Integer For i = 0 To 9 'manualEvent1がシグナル状態になるまでスレッドをブロック autoEvent1.WaitOne() Console.WriteLine("1") 'manualEvent2をシグナル状態にする autoEvent2.Set() Next i End Sub '別スレッドで実行するメソッド2 Private Shared Sub DoSomething2() Dim i As Integer For i = 0 To 9 'manualEvent2がシグナル状態になるまでスレッドをブロック autoEvent2.WaitOne() Console.WriteLine("2") 'manualEvent1をシグナル状態にする autoEvent1.Set() Next i End Sub End Class
class Class1 { private static System.Threading.AutoResetEvent autoEvent1; private static System.Threading.AutoResetEvent autoEvent2; //エントリポイント public static void Main() { //非シグナル状態でManualResetEventオブジェクトを作成 autoEvent1 = new System.Threading.AutoResetEvent(false); autoEvent2 = new System.Threading.AutoResetEvent(false); //スレッドを2つ作成し、開始する System.Threading.Thread t1 = new System.Threading.Thread( new System.Threading.ThreadStart(DoSomething1)); System.Threading.Thread t2 = new System.Threading.Thread( new System.Threading.ThreadStart(DoSomething2)); t1.Start(); t2.Start(); //manualEvent1をシグナル状態にする autoEvent1.Set(); Console.ReadLine(); } //別スレッドで実行するメソッド1 private static void DoSomething1() { for (int i = 0; i < 10; i++) { //manualEvent1がシグナル状態になるまでスレッドをブロック autoEvent1.WaitOne(); Console.WriteLine("1"); //manualEvent2をシグナル状態にする autoEvent2.Set(); } } //別スレッドで実行するメソッド2 private static void DoSomething2() { for (int i = 0; i < 10; i++) { //manualEvent2がシグナル状態になるまでスレッドをブロック autoEvent2.WaitOne(); Console.WriteLine("2"); //manualEvent1をシグナル状態にする autoEvent1.Set(); } } }
ManualResetEvent
、AutoResetEvent
およびMutex
クラスはWin32同期ハンドルをカプセル化したものですので、Monitor
クラスと比較すると移植性に劣りますし、パフォーマンスも悪いようです。できることなら、Monitor
クラスを使うべきです。