スレッドの同期
ここで紹介する事柄は、マルチスレッドプログラミングの初心者には、非常に分かりにくいかもしれません。しかし、マルチスレッドプログラミングには、絶対に必要な重要な知識であるということをご理解ください。
競合状態
まずは、次のコードを見てください(サンプル「lock_01」)。ここで注目すべきなのは、複数のスレッドから同時に同じオブジェクト(ここでは「count」)にアクセスしている点です。
Class Class1 'フィールド Private count As Integer = 0 'エントリポイント Shared Sub Main() 'TestClassクラスのRunメソッドを実行する Dim cls As New Class1 'スレッドを100個作る Dim i As Integer For i = 0 To 99 'WriteNumberメソッドを別スレッドで実行 Dim t As New System.Threading.Thread( _ New System.Threading.ThreadStart( _ AddressOf cls.WriteNumber)) t.Start() Next i Console.ReadLine() End Sub '別スレッドで実行するスレッド Private Sub WriteNumber() 'countが10より小さい時は1増やして、出力 If count < 10 Then 'countを1つ増やす count += 1 'countを出力する Console.WriteLine(count) End If End Sub End Class
class Class1 { //フィールド private int count = 0; //エントリポイント static void Main() { //TestClassクラスのRunメソッドを実行する Class1 cls = new Class1(); //スレッドを100個作る for (int i = 0; i < 100; i++) { //WriteNumberメソッドを別スレッドで実行 System.Threading.Thread t = new System.Threading.Thread( new System.Threading.ThreadStart(cls.WriteNumber)); t.Start(); } Console.ReadLine(); } //別スレッドで実行するスレッド private void WriteNumber() { //countが10より小さい時は1増やして、出力 if (count < 10) { //countを1つ増やす count++; //countを出力する Console.WriteLine(count); } } }
このコードは、スレッドを100個作成し、WriteNumberメソッドを実行しているだけです。WriteNumberメソッドでは、countフィールドの値が10未満のときにcountをインクリメントし、その値を出力しています。このコードを実行すると、順番はともかく、1から10までの数字が1つずつ出力されるはずです。
ところが、実際には必ずしもそうなるとは限りません。実は、11以上の数が出力される可能性があるのです。
なぜそのようなことが起こりうるのか、説明します。countフィールドが9のときに、あるスレッドがWriteNumberメソッドを実行したとしましょう。このスレッドではif文の「count < 10」という条件をパスし、if文のブロック内に処理が進みます。しかしこのスレッドで「count++」を実行する前、つまりcountを一つ増やす前に、別のスレッドがWriteNumberメソッドの「count < 10」を評価したらどうなるでしょうか? この時countはまだ9のままですから、このスレッドでもif文のブロック内に進入できてしまいます。つまり、「count++」と「Console.WriteLine(count)」が10回以上実行されることになります。
この様子は、「count++」の前にThread.Sleepメソッドを入れてスレッドを短時間待機させることにより、はっきりと分かります。Thread.Sleepは、指定された時間(ミリ秒)だけ現在のスレッドを待機するメソッドです。サンプル「lock_01」では、そのようにしてあります。このコードを実行すると、まず間違いなく11以上の数字が出力されます。
'別スレッドで実行するスレッド Private Sub WriteNumber() 'countが10より小さい時は1増やして、出力 If count < 10 Then '意図的に競合状態を起こす System.Threading.Thread.Sleep(100) 'countを1つ増やす count += 1 'countを出力する Console.WriteLine(count) End If End Sub
//別スレッドで実行するスレッド private void WriteNumber() { //countが10より小さい時は1増やして、出力 if (count < 10) { //意図的に競合状態を起こす System.Threading.Thread.Sleep(100); //countを1つ増やす count++; //countを出力する Console.WriteLine(count); } }
このようにタイミングの違いにより結果が変わってしまうという問題は、複数のスレッドから共有リソースへアクセスする場合に起こりうる典型的な現象で、「競合状態(Race Condition)」と呼ばれています。
count++」を実行して「Console.WriteLine(count)」する前に、別のスレッドが「count++」を実行してしまったら、数字が1つ飛んで、同じ数字が連続して表示されてしまいます。また、「
count++」でも競合状態が起こります。これに関しては、「Interlocked」クラスで説明します。さらに、競合状態以外の理由でこのコードがうまくいかない可能性もあります。これに関しては、「
volatile」で説明します。Monitor.Enter・Monitor.Exitメソッド
このような競合状態を回避するためには、スレッドの同期が必要です。そのためには、WriteNumberメソッドを次のように書き換えます。サンプル「lock_02」を実行すれば、1から10までの数字が1つずつ出力され、このコードが正しく動作することが理解できると思います。
'別スレッドで実行するスレッド Public Sub WriteNumber() 'ロックを取得 System.Threading.Monitor.Enter(Me) 'countが10より小さい時は1増やす If count < 10 Then 'countを1つ増やす count += 1 'countを出力する Console.WriteLine(count) End If 'ロックを開放 System.Threading.Monitor.Exit(Me) End Sub
public void WriteNumber() { //ロックを取得 System.Threading.Monitor.Enter(this); //countが10より小さい時は1増やす if (count < 10) { count++; //countを出力する Console.WriteLine(count); } //ロックを開放 System.Threading.Monitor.Exit(this); }
変更点は、if文をMonitorクラスのEnterメソッドとExitメソッドで囲んだだけです。このようにすることにより、Monitor.EnterとMonitor.Exitで囲まれた部分には、1つのスレッドしか入れなくなります。つまり、先に示した例のように、あるスレッドが「count < 10」を評価して「count++」を実行する前に、別のスレッドが「count < 10」を評価する、ということがなくなります。このように複数のスレッドが同時に実行しないことが保証された領域のことを「クリティカルセクション」と呼びます。
Monitor.EnterメソッドとMonitor.Exitメソッドで囲まれた部分がクリティカルセクションとなる仕組みは次のようなものです。Monitor.Enterメソッドは指定されたオブジェクトに対する相互排他ロックを取得し、Monitor.Exitメソッドは相互排他ロックを解放します。あるスレッド(A)がMonitor.Enterにより、あるオブジェクト(ここではthis)をロックすると、他のスレッド(B)はMonitor.Enterで同じオブジェクトのロックを取得することができず、ブロックされます。スレッド(A)がMonitor.Exitを呼び出してロックを解放すると、スレッド(B)はロックを取得できるようになり、ブロックが解除されます。つまり、Monitor.EnterとMonitor.Exitで囲まれた部分は一つのスレッドでしか実行できなくなり、2つ目以降のスレッドがアクセスしようとすると待機させられます。
このようなスレッドの同期は、複数のスレッドから共有リソースへアクセスする時に必要となるケースが多いです。例えば、あるクラスのインスタンスに複数のスレッドからアクセスする場合、そのクラスがスレッドセーフ(複数のスレッドから同時に利用されても、その結果が保障されている)であるかは非常に重要です。もしそのクラスがスレッドセーフでなければ、そのメンバの呼び出しには同期が必要です。.NET Frameworkクラスライブラリ内のクラスの場合、MSDNライブラリに、そのクラスがスレッドセーフであるか明記されています。ほとんどのクラスでは、パブリックな静的メンバの使用はスレッドセーフですが、インスタンスメンバの場合はスレッドセーフが保障されていません。
lock・SyncLockステートメント
実は、上記の例には問題があります。Monitor.Enterでロックし、Monitor.Exitで開放する前にエラーが発生した場合、ロックが開放されなくなってしまいます。つまり、次のようにtry-finally文を使うべきです。
Public Sub WriteNumber() 'ロックを取得 System.Threading.Monitor.Enter(Me) Try 'countが10より小さい時は1増やす If count < 10 Then count += 1 'countを出力する Console.WriteLine(count) End If Finally 'ロックを開放 System.Threading.Monitor.Exit(Me) End Try End Sub
public void WriteNumber() { //ロックを取得 System.Threading.Monitor.Enter(this); try { //countが10より小さい時は1増やす if (count < 10) { count++; //countを出力する Console.WriteLine(count); } } finally { //ロックを開放 System.Threading.Monitor.Exit(this); } }
これと全く同じことが、lockステートメント(VB.NETでは、SyncLockステートメント)を使ってできます。よって通常は、lockステートメントを使うべきです。WriteNumberメソッドをlockを使って書き換えると、次のようになります(サンプル「lock_03」)。
Private Sub WriteNumber() 'ロックする SyncLock Me 'countが10より小さい時は1増やして、出力 If count < 10 Then 'countを1つ増やす count += 1 'countを出力する Console.WriteLine(count) End If End SyncLock End Sub
public void WriteNumber() { //ロックする lock (this) { //countが10より小さい時は1増やす if (count < 10) { count++; //countを出力する Console.WriteLine(count); } } }
静的メソッドの同期
MSDNによると、静的メソッド内ではロックするオブジェクトに、通常Typeオブジェクトを使用するとあります(サンプル「lock_04」)。
'静的フィールド Private Shared staticCount As Integer = 0 '別スレッドで実行する静的スレッド Private Shared Sub StaticWriteNumber() 'ロックする SyncLock GetType(Class1) 'staticCountが10より小さい時は1増やして、出力 If staticCount < 10 Then 'staticCountを1つ増やす staticCount += 1 'staticCountを出力する Console.WriteLine(staticCount) End If End SyncLock End Sub
//静的フィールド private static int staticCount = 0; //別スレッドで実行する静的スレッド private static void StaticWriteNumber() { //ロックする lock (typeof(Class1)) { //staticCountが10より小さい時は1増やして、出力 if (staticCount < 10) { //staticCountを1つ増やす staticCount++; //staticCountを出力する Console.WriteLine(staticCount); } } }
ロック専用のオブジェクト
このようにインスタンスメソッドのロックにはthis、静的メソッドのロックにはTypeオブジェクトを使用するというのが、MSDNの「C# プログラマーズ リファレンス」などで推奨されている方法です。ところが現在では、この方法が良いと考える人はまずいません。
thisやTypeオブジェクトはクラス外からでも取得できますので、クラスの設計者が意図しないところでこれらのオブジェクトがロックに使用される可能性があります。これは、パフォーマンスを低下させるだけでなく、後述するデッドロックの原因にもなりかねません。
ICollection.SyncRootプロパティのように、ロックに使用しているオブジェクトを公開しなければならないケースもあります。よって、ロックに使用するオブジェクトは、私的フィールドとしてロック専用に作成し、読み取り専用にする(これは、参考資料1のアイデアです)のが最もよい方法です。
サンプル「lock_05」として、ロック専用のオブジェクトを用いた例を示します。
'ロックに使用するオブジェクト Private ReadOnly syncObject As New Object '別スレッドで実行するスレッド Private Sub WriteNumber() 'ロックする SyncLock syncObject 'countが10より小さい時は1増やして、出力 If count < 10 Then 'countを1つ増やす count += 1 'countを出力する Console.WriteLine(count) End If End SyncLock End Sub
//ロックに使用するオブジェクト private readonly object syncObject = new object(); //別スレッドで実行するスレッド public void WriteNumber() { //ロックする lock (syncObject) { //countが10より小さい時は1増やす if (count < 10) { count++; //countを出力する Console.WriteLine(count); } } }
なおロックするオブジェクトとして値型は使えませんし、文字列も使うべきではありません。
MethodImplOptions.Synchronized
あまりお勧めできませんが、メソッド全体をクリティカルセクションとするには、MethodImplAttributeとMethodImplOptions.Synchronizedを使用することもできます。つまり、上記のWriteNumberメソッドは次のようにも書けます(サンプル「lock_06」)。このような方法よりも、できることならlockを使うべきです。
'別スレッドで実行するスレッド <System.Runtime.CompilerServices.MethodImpl( _ System.Runtime.CompilerServices.MethodImplOptions.Synchronized)> _ Private Sub WriteNumber() 'countが10より小さい時は1増やして、出力 If count < 10 Then 'countを1つ増やす count += 1 'countを出力する Console.WriteLine(count) End If
[System.Runtime.CompilerServices.MethodImpl(
System.Runtime.CompilerServices.MethodImplOptions.Synchronized)]
private void WriteNumber()
{
//countが10より小さい時は1増やして、出力
if (count < 10)
{
count++;
//countを出力する
Console.WriteLine(count);
}
}
デッドロック
最後に、マルチスレッドプログラミングで「競合状態」とともに注意すべき問題である「デッドロック(deadlock)」について説明します。デッドロックとは、複数のスレッドがお互いにブロックし合い、身動きができなくなる状況のことをいいます。サンプル「lock_07」にその典型的な例を示します。
Class Class1 Private Shared o1 As New Object Private Shared o2 As New Object 'エントリポイント Public Shared Sub Main() '別スレッドを作成し、開始する Dim t As New System.Threading.Thread( _ New System.Threading.ThreadStart( _ AddressOf MyMethod)) t.Start() 'o1をロックする SyncLock o1 Console.WriteLine("メインスレッドでo1をロック") 'しばらく待機 System.Threading.Thread.Sleep(2000) 'o2をロックする 'デッドロックとなる SyncLock o2 Console.WriteLine("メインスレッドでo2をロック") End SyncLock End SyncLock Console.WriteLine("メインスレッド終了") End Sub Private Shared Sub MyMethod() 'しばらく待機 System.Threading.Thread.Sleep(1000) 'o2をロックする SyncLock o2 Console.WriteLine("別スレッドでo2をロック") 'o1をロックする 'デッドロックとなる SyncLock o1 Console.WriteLine("別スレッドでo1をロック") End SyncLock End SyncLock Console.WriteLine("別スレッド終了") End Sub End Class
class Class1 { private static object o1 = new object(); private static object o2 = new object(); //エントリポイント public static void Main() { //別スレッドを作成し、開始する System.Threading.Thread t = new System.Threading.Thread( new System.Threading.ThreadStart(MyMethod)); t.Start(); //o1をロックする lock(o1) { Console.WriteLine("メインスレッドでo1をロック"); //しばらく待機 System.Threading.Thread.Sleep(2000); //o2をロックする //デッドロックとなる lock(o2) { Console.WriteLine("メインスレッドでo2をロック"); } } Console.WriteLine("メインスレッド終了"); } private static void MyMethod() { //しばらく待機 System.Threading.Thread.Sleep(1000); //o2をロックする lock(o2) { Console.WriteLine("別スレッドでo2をロック"); //o1をロックする //デッドロックとなる lock(o1) { Console.WriteLine("別スレッドでo1をロック"); } } Console.WriteLine("別スレッド終了"); } }
上のコードを実行させると、まずメインスレッドでオブジェクトo1をロックし、別スレッドでオブジェクトo2をロックします。その後、別スレッドはo1のロックを要求しますが、すでにメインスレッドでロックされているため、メインスレッドでロックが解放されるまでブロックされます。片やメインスレッドでもo2のロックを要求しますが、o2は別スレッドですでにロックされているため、やはり解放されるまでブロックされます。結局双方のスレッドがお互いをブロックし、全く身動きができなくなってしまいます。
デッドロックを避けるためには、できるだけクリティカルセクション内で別のオブジェクトをロックしないようにすべきです。クリティカルセクション内でメソッドを呼び出すときは、そのメソッドでロックをしていないか注意してください。
別の回避法としては、Monitor.Enterの代わりにMonitor.TryEnterメソッドを使うという対策も考えられます。TryEnterメソッドはロックの取得を試みる時間を指定し、それ以上かかるとタイムアウトしますので、永遠にロックされる心配はありません。
このようにロックの使用には非常に大きなリスクがあることを承知しておいてください。よって、安全面やパフォーマンス面から、このようなスレッドの同期はしないに越したことはありません。競合状態が発生しても大丈夫な方法を考えたり、Interlockedクラスを使用する方法をまずは検討してください。どうしても同期が必要な場合でも、クリティカルセクションはできるだけ短くするべきです。また、共有リソースへの読み取りが多く、書き込みが少ない場合は、ReaderWriterLockクラスの使用を検討してください。
まとめ
パート1では、コンソールアプリケーションのサンプルを作成しながら、スレッドの実行から同期までの基礎的な内容を学びました。次回(パート2)は、スレッドでフォームやコントロールを扱う方法や、本稿で軽く触れた「スレッドプール」「非同期デリゲート」などの事柄について解説します。
参考資料
- 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』



