第3回 マルチスレッドでデータの不整合を防ぐための排他制御 ― マルチスレッド・プログラミングにおける排他制御と同期制御(前編) ―連載.NETマルチスレッド・プログラミング入門(1/3 ページ)

複数の処理が並行動作する場面では、スレッド間の排他制御は不可欠。.NETで用意される排他制御の仕組みをまとめる。

» 2005年05月25日 00時00分 公開
連載.NETマルチスレッド・プログラミング入門
Insider.NET

 

「連載.NETマルチスレッド・プログラミング入門」のインデックス

連載目次

 前回では、.NETにおけるマルチスレッド・プログラミングの基本的な実装方法についてまとめた。今回および次回では、マルチスレッドを使いこなすうえで欠かせない、スレッドをコントロールするためのプログラミング手法について解説していこう。

排他制御と同期制御とは

 シングルスレッド・プログラムでは問題にはならないが、マルチスレッド・プログラムで注意をしなくてはならないのが、「排他制御」と「同期制御」である。これらはまとめて同期制御と呼ばれることも多いが、ここでは分かりやすく説明するために、2つに分けて考えることにしたい。

 排他制御とは、複数のスレッドから共通のリソース(データ)にほぼ同時にアクセスすることによって生じるデータの不整合を防ぐことである。一方、同期制御とは、複数のスレッドがタイミングを計りながらお互いに命令やデータのやりとりをすることを意味する。

 今回はまず、マルチスレッド・プログラムを作成するに当たって最も考慮しなくてはならない排他制御について説明する。

データの不整合を防ぐ「排他制御」

 複数のスレッドから1つの共有のリソースにアクセスすると、データの不整合が起こることがある。しかし厄介なことに、データの不整合は一見しただけでは分からないことが多く、発見が困難なバグとなってしまう。それを避けるために排他制御が必要となってくる。

■データの不整合が発生するケース

 例を挙げて見てみよう。次のList1はデータの不整合を起こしやすいサンプル・プログラムである。

using System;
using System.Threading;

public class List1
{
  // 2つのATMThreadクラスから1つのBankクラスにアクセスする。
  public static void Main()
  {
    Bank bank = new Bank();

    AtmThread atmA = new AtmThread("A", bank);
    atmA.Start();

    AtmThread atmB = new AtmThread("B", bank);
    atmB.Start();
  }
}

// 預金残高(balance)を保持するBankクラス
class Bank
{
  private int balance = 1000;

  public int Balance
  {
    get
    {
      return balance;
    }
    set
    {
      balance = value;
    }
  }
}

// 預金の出し入れを行うスレッドクラス
// スレッドを使用している。
class AtmThread
{
  private string name;
  private Bank bank;

  public AtmThread(string name, Bank bank)
  {
    this.name = name;
    this.bank = bank;
  }

  public void Start()
  {
    Thread thread = new Thread(new ThreadStart(ThreadMethod));
    thread.Start();
  }

  private void ThreadMethod()
  {
    int balance = bank.Balance;
    Thread.Sleep(1000); // わざと競合を起こすため
    bank.Balance = balance + 200;
    Console.WriteLine("{0}: balance + 200 = {1}", name, balance + 200);
  }
}

List1 データの不整合が発生する可能性のあるC#のサンプル・プログラム(List1.cs)
List1.csのダウンロード

Imports System
Imports System.Threading

Public Class List1

  ' 2つのATMThreadクラスから1つのBankクラスにアクセスする。
  Public Shared Sub Main()
    Dim bank As New Bank()

    Dim atmA As New AtmThread("A", bank)
    atmA.Start()

    Dim atmB As New AtmThread("B", bank)
    atmB.Start()
  End Sub 'Main
End Class 'List1

' 預金残高(balance)を保持するBankクラス

Class Bank
  Private _balance As Integer = 1000

  Public Property Balance() As Integer
    Get
      Return _balance
    End Get
    Set
      _balance = value
    End Set
  End Property
End Class 'Bank

' 預金の出し入れを行うスレッドクラス
' スレッドを使用している。
Class AtmThread
  Private name As String
  Private bank As Bank

  Public Sub New(name As String, bank As Bank)
    Me.name = name
    Me.bank = bank
  End Sub 'New

  Public Sub Start()
    Dim thread As New Thread(New ThreadStart(AddressOf ThreadMethod))
    thread.Start()
  End Sub 'Start

  Private Sub ThreadMethod()
    Dim balance As Integer = bank.Balance
    Thread.Sleep(1000) ' わざと競合を起こすため
    bank.Balance = balance + 200
    Console.WriteLine("{0}: balance + 200 = {1}", name, balance + 200)
  End Sub 'ThreadMethod
End Class 'AtmThread

List1 データの不整合が発生する可能性のあるVB.NETのサンプル・プログラム(List1.vb)
List1.vbのダウンロード

 まずはMainメソッド内で、銀行口座を管理するBankオブジェクトを1つと、現金を入金したり、出金したりできるスレッド(AtmThreadオブジェクト)を2つ用意する(以降、スレッドAとスレッドB)。

 そして、預金残高が1000円の銀行口座に、200円入金するという処理(AtmThreadクラスのThreadMethodメソッド)を、2つのスレッドで動作させることを考える。

 さて、1000円の残高がある口座に、200円を2回入金したら、合計はいくらになるだろうか。もちろん、答えは1400円で、そうなっていなくてはいけないのであるが、実際にList1を実行してみると、1200円になってしまうことがある(List1の実行結果が1200円にならない場合は、何度かList1のプログラムを再実行してみていただきたい)。

 1400円となるべき口座の預金残高が1200円になってしまうケースは、次の図に示すようなタイミングで2つのスレッドがBankオブジェクトのBalanceプロパティにアクセスした場合だ。

List1の実行シーケンス図
このようなタイミングで実行されると、List1のプログラムは正しい結果を返さない。

 この原因は、排他制御ができていないことにある。

 スレッドAもしくはスレッドBが「BankオブジェクトのBalanceプロパティを確認し、200円を追加してBalanceプロパティを更新する」という一連の作業を行っている間は、他方のスレッドがBalanceプロパティにアクセスをするのを待つことができれば、このようなデータの不整合は起きなくなる。

 このように、1つのスレッドのある処理が終わるまで、ほかのスレッドがアクセスをするのを防ぐことを排他制御という。つまり、BankオブジェクトのBalanceプロパティというリソースにアクセスできるのは、同時に1つのスレッドだけにするべきであるということである。

■lockステートメント(SyncLockステートメント)による排他制御

 排他制御を行うために、C#ではlockステートメントが用意されている(VB.NETではSyncLockステートメント)。以下にlockステートメントを使用し排他制御を行った場合のAtmThreadクラスのThreadMethodメソッドを示す。

private void ThreadMethod()
{
  lock (bank) //排他制御
  {
    int balance = bank.Balance;
    Thread.Sleep(1000); // わざと競合を起こすため
    bank.Balance = balance + 200;
    Console.WriteLine("{0}: balance + 200 = {1}", name, balance + 200);
  }
}

lockステートメントによって排他制御を行ったList1のThreadMethodメソッド(C#)

Private Sub ThreadMethod()
  SyncLock bank ' 排他制御
    Dim balance As Integer = bank.Balance
    Thread.Sleep(1000) ' わざと競合を起こすため
    bank.Balance = balance + 200
    Console.WriteLine("{0}: balance + 200 = {1}", name, balance + 200)
  End SyncLock
End Sub 'ThreadMethod

SyncLockステートメントによって排他制御を行ったList1のThreadMethodメソッド(VB.NET)

 lockステートメントのパラメータには、排他制御を行う対象となるリソース(オブジェクト)を指定する。lockステートメントの働きは、そのリソースをロックすることだ。

 上記のコードでは、パラメータにBankオブジェクト(変数bank)を指定している。これにより、変数bankをロックしようとするほかのスレッドは、変数bankのロックが取得できないので、スレッドの実行はそこでブロックされることになる。

 よって、ThreadMethodメソッドの実行途中では、ほかのスレッドが同じようにThreadMethodメソッドを実行したとしても、このBankオブジェクトのBalanceプロパティにアクセスできない(lockステートメントのブロック内を実行できない)。

lockステートメントによるリソースのロック
lockステートメントによる排他制御で、スレッドAがThreadMethodメソッドを実行している間(Bankオブジェクトのロックを保持している間)は、ほかのスレッドは同じBankオブジェクトのロックを取得できない。

 このようにlockステートメントによる排他制御を使用することで、複数のスレッドからアクセスされるリソースを、不整合から守ることができる。これが排他制御の基本的な仕組みである。

 lockステートメントの働きを理解するために注意する点としては、lockはリソースへのアクセスを保護するのではなく、あくまでリソースのロックを取得するだけだということである。

 つまり、通常は排他制御を行いたいリソースへのアクセスを行うコードにはすべてlockステートメントを記述する必要がある。以下のコードは排他制御がうまくできていない例である(C#)。

public void MethodA()
{
  lock (object1)
  {
    // object1へのアクセス
  }
}

public void MethodB()
{
  // object1へのアクセス

  // ロックの取得チェックを行っていないので、
  // MethodAメソッドの実行中でもobject1にアクセスできてしまう。
}

lockステートメントによる排他制御がうまくできていない例(C#)

 例えば、MethodAメソッドとMethodBメソッドが異なるスレッドで並行して実行される場合、MethodBメソッドは、MethodAメソッドのロックとは無関係にobject1にアクセスすることができる。なぜならMethodB内ではlockステートメントを用いてobject1のロックを取得しようとしていないからである。lockステートメントは、同一オブジェクトのロックを取得しようとする場合にのみ意味がある。

       1|2|3 次のページへ

Copyright© Digital Advantage Corp. All Rights Reserved.

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。