.NETプログラミング研究 第21号

.NET Tips

.NETのマルチスレッドプログラミング その3

注意

この記事の最新版は「.NETのマルチスレッドプログラミング」で公開しています。

スレッドの同期

マルチスレッドプログラミングでの難所はスレッドの同期の問題でしょう。「あるスレッドで実行される処理が終わらなければ別のスレッドの処理を実行できない」というような場合にスレッドの同期が必要になります。

さらにその中でも同期が必要なケースとして重要でありながら、初心者には分かり難いのが、複数のスレッドが同じオブジェクトにアクセスする時に必要となる同期です。ここではその、複数のスレッドが共有オブジェクトにアクセスする時の同期の方法について、基本を説明します。

はじめに、複数のスレッドが同じオブジェクトにアクセスする時になぜ同期が必要になるのか説明します。まずは下のコードをご覧ください。

  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
'Imports System.Threading
'が宣言されているものとする
 
Class [MyClass]
    Private count As Integer = 0
 
    Public Sub Increment()
        'countが10より小さい時は1増やす
        If count < 10 Then
            count += 1
            '(何らかの処理)
            '確実に10回以下しか実行されないか?
        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
//using System.Threading;
//が宣言されているものとする
 
class MyClass
{
    private int count = 0;
 
    public void Increment()
    {
        //countが10より小さい時は1増やす
        if (count < 10)
        {
            count++;
            //(何らかの処理)
            //確実に10回以下しか実行されないか?
        }
    }
}
 
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
'Imports System.Threading
'が宣言されているものとする
 
Class [MyClass]
    Private count As Integer = 0
 
    Public Sub Increment()
        'ロックを取得
        Monitor.Enter(Me)
 
        'countが10より小さい時は1増やす
        If count < 10 Then
            count += 1
            '(何らかの処理)
            '確実に10回以下しか実行されない
        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
//using System.Threading;
//が宣言されているものとする
 
class MyClass
{
    private int count = 0;
 
    public void Increment()
    {
        //ロックを取得
        Monitor.Enter(this);
 
        //countが10より小さい時は1増やす
        if (count < 10)
        {
            count++;
            //(何らかの処理)
            //確実に10回以下しか実行されない
        }
 
        //ロックを開放
        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
            'countが10より小さい時は1増やす
            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
//using System.Threading;
//が宣言されているものとする
 
class MyClass
{
    private int count = 0;
 
    public void Increment()
    {
        lock(this)
        {
            //countが10より小さい時は1増やす
            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
'Imports System.Threading
'が宣言されているものとする
 
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)
            'countが10より小さい時は1増やす
            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
//using System.Threading;
//が宣言されているものとする
 
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))
        {
            //countが10より小さい時は1増やす
            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
'Imports System.Threading
'Imports System.Runtime.CompilerServices
'が宣言されているものとする
 
Class [MyClass]
    Private count As Integer = 0
 
    <MethodImpl(MethodImplOptions.Synchronized)> _
    Public Sub Increment()
        'countが10より小さい時は1増やす
        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
//using System.Threading;
//using System.Runtime.CompilerServices;
//が宣言されているものとする
 
class MyClass
{
    private int count = 0;
    
    [MethodImpl(MethodImplOptions.Synchronized)]
    public void Increment()
    {
        //countが10より小さい時は1増やす
        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
'Imports System.Threading
'が宣言されているものとする
 
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()
 
        'o1をロックする
        SyncLock o1
            Console.WriteLine("メインスレッドでo1をロック")
            'しばらく待機
            Thread.Sleep(2000)
 
            'o2をロックする
            'デッドロックとなる
            SyncLock o2
                Console.WriteLine("メインスレッドでo2をロック")
            End SyncLock
        End SyncLock
 
        Console.WriteLine("メインスレッド終了")
    End Sub
 
    Private Shared Sub MyMethod()
        'しばらく待機
        Thread.Sleep(1000)
 
        'o2をロックする
        SyncLock o2
            Console.WriteLine("スレッド1でo2をロック")
 
            'o1をロックする
            'デッドロックとなる
            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();
    
    //o1をロックする
    lock(o1)
    {
        Console.WriteLine("メインスレッドでo1をロック");
        //しばらく待機
        Thread.Sleep(2000);
 
        //o2をロックする
        //デッドロックとなる
        lock(o2)
        {
            Console.WriteLine("メインスレッドでo2をロック");
        }
    }
 
    Console.WriteLine("メインスレッド終了");
}
 
private static void MyMethod()
{
    //しばらく待機
    Thread.Sleep(1000);
 
    //o2をロックする
    lock(o2)
    {
        Console.WriteLine("スレッド1でo2をロック");
 
        //o1をロックする
        //デッドロックとなる
        lock(o1)
        {
            Console.WriteLine("スレッド1でo1をロック");
        }
    }
 
    Console.WriteLine("スレッド1終了");
}

上のコードを実行させると、まずメインスレッドでオブジェクト"o1"をロックし、スレッド"1"でオブジェクト"o2"をロックします。その後、スレッド"1"は"o1"のロックを要求しますが、すでにメインスレッドでロックされているため、メインスレッドでロックが解放されるまでブロックされます。片やメインスレッドでも"o2"のロックを要求しますが、"o2"はスレッド"1"ですでにロックされているため、やはり解放されるまでブロックされます。結局双方のスレッドがお互いをブロックし、全く身動きができなくなってしまいます。

デッドロックを避けるためには、むやみに同期機構を使わない、入念に計画する、Monitor.TryEnterでタイムアウトするようにする、などの対策が考えられます。

コメント



ページ情報
  • カテゴリ : .NET
  • 作成日 : 2003-11-18 (火) 06:00:00
  • 作成者 : DOBON!
  • 最終編集日 : 2010-03-21 (日) 01:06:50
  • 最終編集者 : DOBON!
[ トップ ]   [ 編集 | 凍結 | 差分 | バックアップ | 添付 | 複製 | 名前変更 | リロード ]   [ 新規 | 子ページ作成 | 一覧 | 単語検索 | 最終更新 | ヘルプ ]