.NETプログラミング研究 第21号 †
.NET Tips †
.NETのマルチスレッドプログラミング その3 †
スレッドの同期 †
マルチスレッドプログラミングでの難所はスレッドの同期の問題でしょう。「あるスレッドで実行される処理が終わらなければ別のスレッドの処理を実行できない」というような場合にスレッドの同期が必要になります。
さらにその中でも同期が必要なケースとして重要でありながら、初心者には分かり難いのが、複数のスレッドが同じオブジェクトにアクセスする時に必要となる同期です。ここではその、複数のスレッドが共有オブジェクトにアクセスする時の同期の方法について、基本を説明します。
はじめに、複数のスレッドが同じオブジェクトにアクセスする時になぜ同期が必要になるのか説明します。まずは下のコードをご覧ください。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| |
Class [MyClass]
Private count As Integer = 0
Public Sub Increment()
If count < 10 Then
count += 1
End If
End Sub
End Class
Class MainClass
Public Shared Sub Main()
Dim cls As New [MyClass]
Dim i As Integer
For i = 0 To 99
Dim t As New Thread( _
New ThreadStart(AddressOf cls.Increment))
t.Start()
Next i
Console.ReadLine()
End Sub
End Class
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| |
class MyClass
{
private int count = 0;
public void Increment()
{
if (count < 10)
{
count++;
}
}
}
class MainClass
{
public static void Main()
{
MyClass cls = new MyClass();
for (int i = 0; i < 100; i++)
{
Thread t = new Thread(new ThreadStart(cls.Increment));
t.Start();
}
Console.ReadLine();
}
}
|
上記のプログラムでは、MyClass.Incrementメソッド内の「(何らかの処理)」の部分が多くても10回しか実行されないように、countフィールドでカウントし、制御しています。果たして、複数のスレッドからこのIncrementメソッドを呼び出したときでも、「(何らかの処理)」の部分が11回以上実行されることは絶対にないでしょうか?
実は、十分ありえます。例えばcountの値が9の時、あるスレッドが「if (count < 10)」の条件にパスし、「count++」(VB.NETでは、「count += 1」)を処理するまでの間に、別のスレッドが「if (count < 10)」を処理した場合、countの値はまだ9のままのためそのスレッドでも条件にパスしてしまい、結果として両方のスレッドで「(何らかの処理)」が実行されてしまいます。このようにタイミングの違いにより結果が変わってしまうという問題は、複数のスレッドから共有リソースへのアクセスにより起こりうる典型的な現象で、「競合状態(Race Condition)」と呼ばれています。
このような競合状態を回避するために、スレッドの同期が必要となります。そのためには、MyClass.Incrementメソッドを次のように書き換えます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| |
Class [MyClass]
Private count As Integer = 0
Public Sub Increment()
Monitor.Enter(Me)
If count < 10 Then
count += 1
End If
Monitor.Exit(Me)
End Sub
End Class
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| |
class MyClass
{
private int count = 0;
public void Increment()
{
Monitor.Enter(this);
if (count < 10)
{
count++;
}
Monitor.Exit(this);
}
}
|
MyClass.Incrementメソッドの先頭と末尾に
Monitor.Enter(this);
と
Monitor.Exit(this);
を挿入しただけですが、これでこの間のコードブロックには1つのスレッドしか入れなくなります。(このように複数のスレッドが同時に実行しないことが保証された領域のことを「クリティカルセクション」と呼びます。)
Monitor.Enterメソッドは指定されたオブジェクトに対する排他ロックを取得し、Monitor.Exitメソッドは排他ロックを解放します。あるスレッドがMonitor.Enterにより、あるオブジェクト(ここではthis)をロックすると、他のスレッドはMonitor.Enterで同じオブジェクトのロックを取得することができず、ロックを取得しているスレッドがMonitor.Exitを呼び出してロックを解放するまでブロックされます。つまり、Monitor.EnterとMonitor.Exitで囲まれた部分は一つのスレッドでしか実行できなくなり、2つ目以降のスレッドがアクセスしようとすると待機させられます。
Monitorはオブジェクト(参照型)をロックするために使われるため、Monitor.Enterメソッドに値型の変数を渡してはいけないということに注意してください。(詳しくはヘルプ「Monitor」をご覧ください。)
Monitor.Enterメソッドは複数回呼び出すこともできますが、このときは同じ数だけMonitor.Exitで解放します。
(補足:クリティカルセクションはできるだけ短くすべきです。上記のコードでも場合によってはメソッド全体をクリティカルセクションにする必要はないでしょう。)
上記のMonitor.EnterとMonitor.Exitで囲むだけの方法では多少問題があります。というのは、例えばMonitor.Enterが呼び出されてからMonitor.Exitが呼び出されるまでの間にエラーが発生した時、ロックが解除されないということになってしまいます。これを回避するためには、try...finally...のfinally句内にMonitor.Exitを書いておけばよいのですが、これと全く同じことがlock(VB.NETではSyncLock)ステートメントで出来てしまいます。よって通常はこちらを使うべきです。
先のコードをlockステートメントを使って書き直すと次のようになります。
1
2
3
4
5
6
7
8
9
10
11
12
13
| | Class [MyClass]
Private count As Integer = 0
Public Sub Increment()
SyncLock Me
If count < 10 Then
count += 1
End If
End SyncLock
End Sub
End Class
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| |
class MyClass
{
private int count = 0;
public void Increment()
{
lock(this)
{
if (count < 10)
{
count++;
}
}
}
}
|
静的メソッド内でクリティカルセクションを指定する時は、ロックするオブジェクトとして、Typeオブジェクトを使用するのが一般的です。次にその例を示します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| |
Class MainClass
Private Shared count As Integer = 0
Public Shared Sub Main()
Dim i As Integer
For i = 0 To 99
Dim t As New Thread(New ThreadStart(AddressOf MyThread))
t.Start()
Next i
Console.ReadLine()
End Sub
Public Shared Sub MyThread()
SyncLock GetType(MainClass)
If count < 10 Then
count += 1
Console.WriteLine(count)
End If
End SyncLock
End Sub
End Class
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| |
class MainClass
{
private static int count = 0;
public static void Main()
{
for (int i = 0; i < 100; i++)
{
Thread t = new Thread(new ThreadStart(MyThread));
t.Start();
}
Console.ReadLine();
}
public static void MyThread()
{
lock(typeof(MainClass))
{
if (count < 10)
{
count++;
Console.WriteLine(count);
}
}
}
}
|
メソッド全体をクリティカルセクションとするには、MethodImplAttributeとMethodImplOptions.Synchronizedを使用することもできます。例えば先のMyClass.Incrementメソッド全体をクリティカルセクションにするには次のようにします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| |
Class [MyClass]
Private count As Integer = 0
<MethodImpl(MethodImplOptions.Synchronized)> _
Public Sub Increment()
If count < 10 Then
count += 1
Console.WriteLine(count)
End If
End Sub
End Class
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| |
class MyClass
{
private int count = 0;
[MethodImpl(MethodImplOptions.Synchronized)]
public void Increment()
{
if (count < 10)
{
count++;
Console.WriteLine(count);
}
}
}
|
最後に、マルチスレッドプログラミングで「競合状態」とともに注意すべき問題である「デッドロック」について説明します。
デッドロックとは、複数のスレッドがお互いにブロックし合い、身動きができなくなる状況のことをいいます。次にその典型的な例を示しましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| |
Class MainClass
Private Shared o1 As New Object
Private Shared o2 As New Object
Public Shared Sub Main()
Dim t As New Thread(New ThreadStart(AddressOf MyMethod))
t.Name = "1"
t.Start()
SyncLock o1
Console.WriteLine("メインスレッドでo1をロック")
Thread.Sleep(2000)
SyncLock o2
Console.WriteLine("メインスレッドでo2をロック")
End SyncLock
End SyncLock
Console.WriteLine("メインスレッド終了")
End Sub
Private Shared Sub MyMethod()
Thread.Sleep(1000)
SyncLock o2
Console.WriteLine("スレッド1でo2をロック")
SyncLock o1
Console.WriteLine("スレッド1でo1をロック")
End SyncLock
End SyncLock
Console.WriteLine("スレッド1終了")
End Sub
End Class
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| | private static object o1 = new object();
private static object o2 = new object();
public static void Main()
{
Thread t = new Thread(new ThreadStart(MyMethod));
t.Name = "1";
t.Start();
lock(o1)
{
Console.WriteLine("メインスレッドでo1をロック");
Thread.Sleep(2000);
lock(o2)
{
Console.WriteLine("メインスレッドでo2をロック");
}
}
Console.WriteLine("メインスレッド終了");
}
private static void MyMethod()
{
Thread.Sleep(1000);
lock(o2)
{
Console.WriteLine("スレッド1でo2をロック");
lock(o1)
{
Console.WriteLine("スレッド1でo1をロック");
}
}
Console.WriteLine("スレッド1終了");
}
|
上のコードを実行させると、まずメインスレッドでオブジェクト"o1"をロックし、スレッド"1"でオブジェクト"o2"をロックします。その後、スレッド"1"は"o1"のロックを要求しますが、すでにメインスレッドでロックされているため、メインスレッドでロックが解放されるまでブロックされます。片やメインスレッドでも"o2"のロックを要求しますが、"o2"はスレッド"1"ですでにロックされているため、やはり解放されるまでブロックされます。結局双方のスレッドがお互いをブロックし、全く身動きができなくなってしまいます。
デッドロックを避けるためには、むやみに同期機構を使わない、入念に計画する、Monitor.TryEnterでタイムアウトするようにする、などの対策が考えられます。
コメント †