WisdomSoft - for your serial experiences.

7.4 スレッドの同期

同期オブジェクトによる排他ロックで、特定のコードを複数のスレッドが同時並行に実行できないよう、複数のスレッド間で同期させることができます。

7.4.1 コードの実行権に鍵をかける

複数のスレッドが一つのオブジェクトを共有して、同じメソッドを同時に実行すると、データに不整合が発生する可能性が出てきます。例えば、複数のスレッドが一つのファイルに同時に書き込めば、無秩序にファイルの内容が書き換えられて予期しない結果が得られることになります。

このように、マルチスレッドはスレッド同士の実行順序が不明確なので、複数のスレッドが特定のメソッドを同時に実行し、同じ変数を適当なタイミングで設定したり参照しあったときに致命的なエラーを起こすことがあります。こうした場合、特定の条件で必ずエラーが出るというものではなく、スレッドの気まぐれでエラーが出てしまうことがあるため原因の究明が困難になるのです。

一番良い設計方法は、異なるスレッド間が同一の変数やメソッドを共有せず、それぞれがまったく個別に、独立して動作する形です。しかし、全てのケースでそのような関係を作ることは難しく、場合によってはやはりスレッド間でデータやオブジェクトを共有することが必要になるでしょう。

このとき、異なる複数のスレッドから気まぐれなタイミングでコードが実行されることが問題になるようなコードやメソッドは、複数のスレッドに同時に実行されないように、スレッドを制御する必要が出てきます。これを実現するのが、スレッドの同期です。

複数のスレッドを同期させるには、同期オブジェクトを使います。同期オブジェクトとは、スレッドが実行権を得るまでその場で実行を待機させるというもので System.Threading.WaitHandle クラスを継承しています。

System.Threading.WaitHandle クラス
System.Object
    System.MarshalByRefObject
        System.Threading.WaitHandle
public abstract class WaitHandle : MarshalByRefObject, IDisposable

WaitHandle クラスを使えばスレッドが共有するデータへのアクセスを排他的に処理することができます。排他アクセスとは、ある特定のスレッドがデータにアクセスするコードを実行している間に、別のスレッドが同じコードを実行しようとしたときに、先に実行しているスレッドが処理を終了するまで待機させるという技術です。

図1 スレッドの相互排他実行
図1 スレッドの相互排他実行
図2 次のスレッドがメソッドを実行する
図2 次のスレッドがメソッドを実行する

スレッドの同期は、同期オブジェクトに鍵をかけることで実現します。この同期オブジェクトに対する鍵(実行権)はシグナルと呼ばれます。あるオブジェクトが共有データにアクセスするコードを実行しようとしたとき、その直前に同期オブジェクトの WaitOne() メソッドを呼び出します。

WaitHandle クラス WaitOne() メソッド
public virtual bool WaitOne()

WaitOne() メソッドは、同期オブジェクトがシグナルを受信するまでこのメソッドを呼び出した現在のスレッドを待機させるという性質を持ちます。スレッドが他のスレッドと同期させたいコードを実行する直前に WaitOne() メソッドを呼び出すと、シグナルが存在すればそのままコードを実行し続けますが、シグナルが存在しない場合はその場でシグナルを受信するまで待機します。

シグナルの管理方法については、WaitHandle クラスを継承する実装に委ねられます。同期オブジェクトを取得するには System.Threading.AutoResetEvent クラスか、または System.Threading.ManualResetEvent クラスを使います。どちらのクラスも基本的な部分はほとんど同じなのですが、シグナルの管理方法がやや異なっています。

System.Threading.AutoResetEvent クラス
System.Object
    System.MarshalByRefObject
        System.Threading.WaitHandle
            System.Threading.AutoResetEvent
public sealed class AutoResetEvent : WaitHandle
System.Threading.ManualResetEvent クラス
System.Object
    System.MarshalByRefObject
        System.Threading.WaitHandle
            System.Threading.ManualResetEvent
public sealed class ManualResetEvent : WaitHandle

どちらのクラスも、シグナル状態・非シグナル状態に設定するメソッドを使ってシグナルを管理し、同期させる必要があるコードを実行する直前に同期オブジェクトを非シグナル状態に設定し、他のスレッドが実行中に同じコードに介入しないように仕組むことができます。そして、スレッドがコードの実行を終了した時点で同期オブジェクトをシグナル状態に変更して次のスレッドがコードを実行できるように通知します。

ManualResetEvent と AutoResetEvent クラスの違いは、非シグナル状態の設定を手動で行うか、自動的に設定されるかです。AutoResetEvent は、WaitOne() メソッドを実行すると同期オブジェクトがシグナル状態になるまで待機し、同期オブジェクトシグナル状態になると待機しているスレッドが解放されると自動的に非シグナル状態に戻ります。ManualResetEvent の場合は、非シグナル状態に設定する場合も手動で行う必要があります。

この場では、AutoResetEvent クラスを使って同期方法を解説しますが、ManualResetEvent クラスの場合も同じように使うことができます。AutoResetEvent クラスのコンストラクタは次の通りです。

AutoResetEvent クラスのコンストラクタ
public AutoResetEvent(bool initialState)

initialState パラメータには、シグナルの初期状態を設定します。この値が true であればシグナル状態で初期化され、false であれば非シグナル状態で初期化されます。通常は、スレッドが何らかのクリティカルなコードに入ってから非シグナル状態にすればよいので、初期状態は true を設定します。

AutoResetEvent オブジェクトのシグナルを操作するには Set() メソッドReset() メソッドを使います。Set() メソッドを呼び出すと同期オブジェクトはシグナル状態に設定され、Reset() メソッドを呼び出すと非シグナル状態に設定されます。

AutoResetEvent クラス Set() メソッド
public bool Set()
AutoResetEvent クラス Reset() メソッド
public bool Reset()

AutoResetEvent クラスの場合、スレッドが WaitOne() メソッドを呼び出して制御が返ると自動的に非シグナル状態になりますが、ManualResetEvent の場合はスレッドが同期させたいコードを実行する直前で Reset() メソッドを呼び出さなければ非シグナル状態になりません。

コード1
using System.Threading;
using System.Drawing;
using System.Windows.Forms;

public class Test : Form
{
	private Image image;
	protected override void OnPaint(PaintEventArgs e)
	{
		base.OnPaint (e);
		e.Graphics.DrawImage(image, 0, 0);
	}

	protected override void OnMouseUp(MouseEventArgs e)
	{
		base.OnMouseUp (e);
		DrawThread thread = new DrawThread(this, image, e.X, e.Y);
		thread.Start();
	}

	public Test() 
	{
		SetStyle(
			ControlStyles.DoubleBuffer |
			ControlStyles.UserPaint |
			ControlStyles.AllPaintingInWmPaint, true
		);
		image = new Bitmap(400, 300);
	}

	static void Main() 
	{
		Application.Run(new Test());
	}
}

class DrawThread
{
	private Control control;
	private Image image;
	private Rectangle rect;
	private static readonly AutoResetEvent waitEvent = new AutoResetEvent(true);

	public DrawThread(Control control, Image image, int x, int y) 
	{
		this.control = control;
		this.image = image;
		rect = new Rectangle(x, y, 10, 10);
	}
	public void Start() 
	{
		new Thread(new ThreadStart(DrawThread_Start)).Start();
	}

	private void DrawThread_Start() 
	{
		waitEvent.WaitOne();

		Brush brush = new SolidBrush(Color.Black);
		Graphics g = Graphics.FromImage(image);
		int max = image.Height;

		while(rect.Y < max) 
		{
			g.FillEllipse(brush, rect.X - rect.Width / 2, rect.Y - rect.Height / 2, 10, 10);
			control.Invalidate();
			rect.Y++;
			Thread.Sleep(10);
		}
		waitEvent.Set();
	}
}
実行結果
コード1 実行結果

コード1は「7.2 スレッドによる並行処理 コード2」 の描画処理を同期させるように改良し、線を描画する DrawThread_Start() メソッドの処理で共有する同期オブジェクトを使って常に 1 つのスレッドしか実行できないようにプログラムされています。このプログラムの DrawThread クラスは、静的な AutoResetEvent オブジェクトを保有しています。

DrawThread_Start() メソッドでは、最初に waitEvent.WaitOne() を実行して同期オブジェクトがシグナル状態になるまで待機させます。もし、他のスレッドがすでに DrawThread_Start() メソッドを実行している場合は、同期オブジェクトが非シグナル状態になっているため新しいスレッドは、メソッドを実行しているスレッドが処理を終了するのを待機しなければなりません。

同期オブジェクトがシグナル状態になって WaitOne() メソッドから制御が返ると、AutoResetEvent オブジェクトの場合は自動的に非シグナル状態に戻ります。そのため、他のスレッドがコードを実行しようとすると、やはり現在のスレッドが処理を終了するのを待機しなければなりません。スレッドがラインの描画を終了して DrawThread_Start() を抜ける前に、最後に waitEvent.Set() を呼び出してシグナルを送信しています。これによって、同期オブジェクトがシグナル状態になり、待機していた次のスレッドが走り出します。