マルチスレッド利用時に注意すべきデッドロックのパターンとその回避法、スレッドの同期を取るための手法などを解説。
前回では、排他制御を導入することにより、複数スレッドからの同時アクセスによるデータの不整合を発生させないためのプログラミングについて紹介した。しかし、排他制御を行った場合には、今度は「デッドロック」という問題が起こり得る。
本連載の最終回となる今回は、このデッドロックについて、そして前回の冒頭で少し触れた、複数のスレッド間で同期を取りながら処理を進める「同期制御」のためのプログラミングについて解説する。
排他制御を行ううえで最も気を付けなくてはならないことがデッドロックである。デッドロックとは、アプリケーション内部で排他制御などによる競合が起こり、アプリケーションが止まってしまう(反応がなくなってしまう)状態である。
.NET Framework上でのプログラミングで気を付けるべきデッドロックには次の2つがある。
■リソースの持ち合いによるデッドロック
リソースの持ち合いによるデッドロックは、典型的なデッドロックのパターンだ。これは2つのスレッドがお互いに必要なリソースのロックを同時期に占有し、他方のリソースの解放を待つ状態で、どちらのスレッドもリソースを解放しないために前に進めなくなってしまうケースである。以下にそのような状況を引き起こしてしまうサンプル・プログラムを示す。
using System;
using System.Threading;
// リソースの競合
public class List1
{
private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void Main()
{
Thread threadA = new Thread(new ThreadStart(ThreadMethodA));
threadA.Start();
ThreadMethodB();
}
private static void ThreadMethodA()
{
Console.WriteLine("A waiting 1");
lock (resource1)
{
Console.WriteLine("A locking 1");
Thread.Sleep(10);
Console.WriteLine("A waiting 2");
lock (resource2)
{
Console.WriteLine("A locking 2");
}
}
}
private static void ThreadMethodB()
{
Console.WriteLine(" B waiting 2");
lock (resource2)
{
Console.WriteLine(" B locking 2");
Thread.Sleep(10);
Console.WriteLine(" B waiting 1");
lock (resource1)
{
Console.WriteLine(" B locking 1");
}
}
}
}
Imports System
Imports System.Threading
' リソースの競合
Public Class List1
Private Shared resource1 As New [Object]()
Private Shared resource2 As New [Object]()
Public Shared Sub Main()
Dim threadA As New Thread(New ThreadStart(AddressOf ThreadMethodA))
threadA.Start()
ThreadMethodB()
End Sub 'Main
Private Shared Sub ThreadMethodA()
Console.WriteLine("A waiting 1")
SyncLock resource1
Console.WriteLine("A locking 1")
Thread.Sleep(10)
Console.WriteLine("A waiting 2")
SyncLock resource2
Console.WriteLine("A locking 2")
End SyncLock
End SyncLock
End Sub 'ThreadMethodA
Private Shared Sub ThreadMethodB()
Console.WriteLine(" Bwaiting 2")
SyncLock resource2
Console.WriteLine(" B locking 2")
Thread.Sleep(10)
Console.WriteLine(" B waiting 1")
SyncLock resource1
Console.WriteLine(" B locking 1")
End SyncLock
End SyncLock
End Sub 'ThreadMethodB
End Class 'List1
このList1では、スレッドA(ThreadMethodA)とスレッドB(ThreadMethodB)という2つのスレッドがリソース1(resource1)とリソース2(resource2)という2つのリソースのロックを取得しようとする。
スレッドAはリソース1のロックを取得し、スレッドBはリソース2のロックを取得する。上記のプログラムでは、これはどちらも成功する。
次にスレッドAがリソース2のロックを、スレッドBがリソース1のロックを取得しようとするのだが、どちらのロックもお互いに相手のスレッドにすでに取られているため、どちらもロックが解放されるのを待つ。しかし、ロックをお互いに持ち合っているため、どちらのスレッドもロックの解放を待つ状態が永久に続いてしまうのである。これが典型的なリソースの持ち合いによるデッドロックである。
このようなデッドロックを起こさないようにする対策は、複数のロックを同時に取得しない設計にすることである。デッドロックは複数のリソースのロックを連続して取得しようとするときに起こる。つまり、1つのリソースのロックだけで済ませれば、いずれそのロックは解放されるものとして扱うことができる(ただしネットワークやプリンタなど、なかなか解放されない可能性が高いリソースの場合は、待機を中断するためのタイムアウトなどの処置を組み込む必要が出てくる)。
また、どうしても複数のリソースのロックを同時に取得しなくてはならない場合は、ロックを取得する順番をアプリケーション全体で統一すればよい。このようにすることでリソースの持ち合いによるデッドロックを回避することができる。
1つのリソースを1つのスレッドが非常に長い間占有するようなときも、デッドロックと似たように、ロックの解放を待っているスレッドはなかなか処理が進まなくなる。パフォーマンスに重大な影響を与える可能性があるので、複数のスレッドで共有するリソースをロックするときは、ロックをかけている時間を可能な限り短くするように十分注意する必要がある。
■スレッドプールによるデッドロック
スレッドプールを使用したときも、デッドロックが発生する可能性がある。
スレッドプールの仕組みは第2回のThreadPoolクラスによるマルチスレッドで解説したとおり、リクエストをキューにためておき、複数のワーカースレッドによって順次実行していくというものである。
このワーカースレッドによる処理が、スレッドプールの新たなワーカースレッドによる処理結果を必要とするとき、デッドロックが発生する可能性がある。以下にその例を示す。
using System;
using System.Threading;
// スレッドプールへの過剰な要求
public class List2
{
delegate void ThreadMethodDelegate(string state);
static ThreadMethodDelegate threadMethodA = new ThreadMethodDelegate(ThreadMethodA);
static ThreadMethodDelegate threadMethodB = new ThreadMethodDelegate(ThreadMethodB);
// 小さい数にするとデッドロックにならない
static int NumberOfWork = 50;
public static void Main()
{
for(int j=1; j <= NumberOfWork; j++)
{
threadMethodA.BeginInvoke(j.ToString(), null, null);
}
Console.ReadLine();
}
private static void ThreadMethodA(string state)
{
Console.WriteLine("A:{0}", state);
IAsyncResult ar = threadMethodB.BeginInvoke(state, null, null);
// ThreadMethodBの終了を待つが、
// ThreadMethodAでキューがいっぱいのためデッドロックとなる
threadMethodB.EndInvoke(ar);
}
private static void ThreadMethodB(string state)
{
Console.WriteLine("B:{0}", state);
}
}
Imports System
Imports System.Threading
' スレッドプールへの過剰な要求
Public Class List2
Delegate Sub ThreadMethodDelegate(state As String)
Private Shared threadMethodADelegate As New ThreadMethodDelegate(AddressOf ThreadMethodA)
Private Shared threadMethodBDelegate As New ThreadMethodDelegate(AddressOf ThreadMethodB)
' 小さい数にするとデッドロックにならない
Private Shared NumberOfWork As Integer = 50
Public Shared Sub Main()
Dim j As Integer
For j = 1 To NumberOfWork
threadMethodADelegate.BeginInvoke(j.ToString(), Nothing, Nothing)
Next j
Console.ReadLine()
End Sub 'Main
Private Shared Sub ThreadMethodA(state As String)
Console.WriteLine("A:{0}", state)
Dim ar As IAsyncResult = threadMethodBDelegate.BeginInvoke(state, Nothing, Nothing)
' ThreadMethodBの終了を待つが、
' ThreadMethodAでキューがいっぱいのためデッドロックとなる
threadMethodBDelegate.EndInvoke(ar)
End Sub 'ThreadMethodA
Private Shared Sub ThreadMethodB(state As String)
Console.WriteLine("B:{0}", state)
End Sub 'ThreadMethodB
End Class 'List2
List2は、ThreadMethodAメソッドとThreadMethodBメソッドが、それぞれ変数NumberOfWorksで設定した数(50)だけ、スレッドプールにより実行されて終了するように作成したコードである(第2回で解説しているように、デリゲートのBeginInvokeメソッドはスレッドプールを利用してメソッドを実行する)。
しかし、NumberOfWorksの値が大きいと、ThreadMethodAメソッドの処理でスレッドプールのスレッドを使い切ってしまい、ThreadMethodBメソッドが永久に実行待ちとなりデッドロックが発生する。
スレッドプールによるデッドロックを防ぐためには、スレッドプールで実行させるリクエストの中に、さらにスレッドプールを使用して実行されるようなコードを含めないことである。ここで忘れてはならないのが、デリゲートのBeginInvokeメソッドによるメソッド呼び出しや、タイマー(System.Threading名前空間のTimerクラス)もスレッドプールを使用しているということである。
つまり、タイマーを使用してBeginInvokeメソッドを呼び出したり、デリゲートのBeginInvokeメソッドで処理するメソッドの中から、さらにBeginInvokeメソッドを呼び出したりするようなことは極力避けた方がよい。
スレッドプールでもデッドロックに近いパフォーマンスの大幅な低下が起こることがある。処理に時間がかかるリクエストでスレッドプールがいっぱいになると、それ以降のリクエストはキューの中で長時間待たされてしまうことになる。この結果、BeginInvokeメソッドで呼び出した処理がなかなか実行されなかったり、タイマーの処理が一定時間ごとに行われなくなったりする。デリゲートやタイマーも含め、スレッドプールを利用する場合は、その各処理が比較的短時間で終了するように注意が必要だ。
Copyright© Digital Advantage Corp. All Rights Reserved.