SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

DeveloperZine(デベロッパージン)- エンジニアの意思決定を支える技術情報メディア ProductZine

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

.NET Frameworkにおけるマルチスレッドプログラミングの基本

.NETマルチスレッドプログラミング 1:スレッドの実行と同期

.NET Frameworkにおけるマルチスレッドプログラミングの基本


スレッドの同期

 ここで紹介する事柄は、マルチスレッドプログラミングの初心者には、非常に分かりにくいかもしれません。しかし、マルチスレッドプログラミングには、絶対に必要な重要な知識であるということをご理解ください。

競合状態

 まずは、次のコードを見てください(サンプル「lock_01」)。ここで注目すべきなのは、複数のスレッドから同時に同じオブジェクト(ここでは「count」)にアクセスしている点です。

「VB.NET/lock_01/Class1.vb」:修正前 (VB.NET)
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
「C#/lock_01/Class1.cs」:修正前 (C#)
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回以上実行されることになります。

サンプル「lock_01」で「11」が出力される仕組み
サンプル「lock_01」で「11」が出力される仕組み

 この様子は、「count++」の前にThread.Sleepメソッドを入れてスレッドを短時間待機させることにより、はっきりと分かります。Thread.Sleepは、指定された時間(ミリ秒)だけ現在のスレッドを待機するメソッドです。サンプル「lock_01」では、そのようにしてあります。このコードを実行すると、まず間違いなく11以上の数字が出力されます。

「VB.NET/lock_01/Class1.vb」:修正部分 (VB.NET)
'別スレッドで実行するスレッド
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
「C#/lock_01/Class1.cs」:修正部分 (C#)
//別スレッドで実行するスレッド
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つずつ出力され、このコードが正しく動作することが理解できると思います。

「VB.NET/lock_02/Class1.vb」 (VB.NET)
'別スレッドで実行するスレッド
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
「C#/lock_02/Class1.cs」 (C#)
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.EnterMonitor.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.EnterMonitor.Exitで囲まれた部分は一つのスレッドでしか実行できなくなり、2つ目以降のスレッドがアクセスしようとすると待機させられます。

サンプル「lock_02」で競合状態が防がれる仕組み
サンプル「lock_02」で競合状態が防がれる仕組み

 このようなスレッドの同期は、複数のスレッドから共有リソースへアクセスする時に必要となるケースが多いです。例えば、あるクラスのインスタンスに複数のスレッドからアクセスする場合、そのクラスがスレッドセーフ(複数のスレッドから同時に利用されても、その結果が保障されている)であるかは非常に重要です。もしそのクラスがスレッドセーフでなければ、そのメンバの呼び出しには同期が必要です。.NET Frameworkクラスライブラリ内のクラスの場合、MSDNライブラリに、そのクラスがスレッドセーフであるか明記されています。ほとんどのクラスでは、パブリックな静的メンバの使用はスレッドセーフですが、インスタンスメンバの場合はスレッドセーフが保障されていません。

lock・SyncLockステートメント

 実は、上記の例には問題があります。Monitor.Enterでロックし、Monitor.Exitで開放する前にエラーが発生した場合、ロックが開放されなくなってしまいます。つまり、次のようにtry-finally文を使うべきです。

(VB.NET)
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
(C#)
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」)。

「VB.NET/lock_03/Class1.vb」 (VB.NET)
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
「C#/lock_03/Class1.cs」 (C#)
public void WriteNumber()
{
    //ロックする
    lock (this)
    {
        //countが10より小さい時は1増やす
        if (count < 10)
        {
            count++;

            //countを出力する
            Console.WriteLine(count);
        }
    }
}

静的メソッドの同期

 MSDNによると、静的メソッド内ではロックするオブジェクトに、通常Typeオブジェクトを使用するとあります(サンプル「lock_04」)。

「VB.NET/lock_04/Class1.vb」 (VB.NET)
'静的フィールド
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
「C#/lock_04/Class1.cs」 (C#)
//静的フィールド
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# プログラマーズ リファレンス」などで推奨されている方法です。ところが現在では、この方法が良いと考える人はまずいません。

 thisTypeオブジェクトはクラス外からでも取得できますので、クラスの設計者が意図しないところでこれらのオブジェクトがロックに使用される可能性があります。これは、パフォーマンスを低下させるだけでなく、後述するデッドロックの原因にもなりかねません。

 ただし、ICollection.SyncRootプロパティのように、ロックに使用しているオブジェクトを公開しなければならないケースもあります。

 よって、ロックに使用するオブジェクトは、私的フィールドとしてロック専用に作成し、読み取り専用にする(これは、参考資料1のアイデアです)のが最もよい方法です。

 サンプル「lock_05」として、ロック専用のオブジェクトを用いた例を示します。

「VB.NET/lock_05/Class1.vb」 (VB.NET)
'ロックに使用するオブジェクト
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
「C#/lock_05/Class1.cs」 (C#)
//ロックに使用するオブジェクト
private readonly object syncObject = new object();

//別スレッドで実行するスレッド
public void WriteNumber()
{
    //ロックする
    lock (syncObject)
    {
        //countが10より小さい時は1増やす
        if (count < 10)
        {
            count++;

            //countを出力する
            Console.WriteLine(count);
        }
    }
}

 なおロックするオブジェクトとして値型は使えませんし、文字列も使うべきではありません。

MethodImplOptions.Synchronized

 あまりお勧めできませんが、メソッド全体をクリティカルセクションとするには、MethodImplAttributeMethodImplOptions.Synchronizedを使用することもできます。つまり、上記のWriteNumberメソッドは次のようにも書けます(サンプル「lock_06」)。このような方法よりも、できることならlockを使うべきです。

「VB.NET/lock_06/Class1.vb」 (VB.NET)
'別スレッドで実行するスレッド
<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
「C#/lock_06/Class1.cs」 (C#)
[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」にその典型的な例を示します。

「VB.NET/lock_07/Class1.vb」 (VB.NET)
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
「C#/lock_07/Class1.cs」 (C#)
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は別スレッドですでにロックされているため、やはり解放されるまでブロックされます。結局双方のスレッドがお互いをブロックし、全く身動きができなくなってしまいます。

サンプル「lock_07」でデッドロックとなる仕組み
サンプル「lock_07」でデッドロックとなる仕組み

 デッドロックを避けるためには、できるだけクリティカルセクション内で別のオブジェクトをロックしないようにすべきです。クリティカルセクション内でメソッドを呼び出すときは、そのメソッドでロックをしていないか注意してください。

 別の回避法としては、Monitor.Enterの代わりにMonitor.TryEnterメソッドを使うという対策も考えられます。TryEnterメソッドはロックの取得を試みる時間を指定し、それ以上かかるとタイムアウトしますので、永遠にロックされる心配はありません。

 このようにロックの使用には非常に大きなリスクがあることを承知しておいてください。よって、安全面やパフォーマンス面から、このようなスレッドの同期はしないに越したことはありません。競合状態が発生しても大丈夫な方法を考えたり、Interlockedクラスを使用する方法をまずは検討してください。どうしても同期が必要な場合でも、クリティカルセクションはできるだけ短くするべきです。また、共有リソースへの読み取りが多く、書き込みが少ない場合は、ReaderWriterLockクラスの使用を検討してください。

まとめ

 パート1では、コンソールアプリケーションのサンプルを作成しながら、スレッドの実行から同期までの基礎的な内容を学びました。次回(パート2)は、スレッドでフォームやコントロールを扱う方法や、本稿で軽く触れた「スレッドプール」「非同期デリゲート」などの事柄について解説します。

参考資料

  1. Jon's Homepage! 『Multi-threading in .NET』
  2. MSDNライブラリ 『.NET Framework開発者ガイド-スレッド処理』
  3. MSDNライブラリ 『Visual Basic言語の概念-Visual Basic .NETにおけるマルチスレッド』
  4. MSDNライブラリ 『.NET Framework開発者ガイド-非同期呼び出しの組み込み』
  5. MSDNライブラリ 『コンポーネントのマルチスレッド』
  6. MSDNライブラリ 『スレッド処理のデザイン ガイドライン』
  7. MSDNライブラリ 『非同期プログラミングのガイドライン』
  8. MSDNライブラリ 『Chapter 4 - Architecture and Design Review of a .NET Application for Performance and Scalability』
修正履歴

この記事は参考になりましたか?

連載通知を行うには会員登録(無料)が必要です。
既に会員の方はを行ってください。
.NET Frameworkにおけるマルチスレッドプログラミングの基本連載記事一覧

もっと読む

この記事の著者

どぼん!(ドボン!)

DOBON.NET内で.NET Frameworkの機能を紹介したWebサイト.NET Tipsやメールマガジン「.NETプログラミング研究」の発行人。

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

CodeZine(コードジン)
https://codezine.jp/article/detail/135 2006/07/11 14:37

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー