WisdomSoft - for your serial experiences.

7.2 スレッドによる並行処理

新しいスレッドを作成し、プログラム内で複数のコードを同時に実行します。

7.2.1 複数のコードを同時実行する

Intel の 16 ビット CPU が中心だった時代のコンピュータは、オペレーティングシステムを含めてプログラムコードを実行することができる権限は 1 つしか存在しませんでした。何らかのプログラムコードが CPU を使うには、他のすべてのプログラムが停止していなければならなかったのです。現代の Windows のように、ブラウザでインターネットを駆け巡りながら、音楽再生ソフトウェアでBGM を再生し、音声チャットソフトで友人と会話を楽しむというプログラムの同時実行は不可能だったのです。少なくとも、これを実現させるにはコンピュータを 3 台用意するか、CPU の使用権限を分割する仕組みをソフトウェアで実装する必要があったでしょう。

しかし、Pentium の直接の祖先である Intel 486 プロセッサと Microsoft Windows 95 の登場で時代は大きく変わりました。マルチタスク、マルチスレッドの到来です。

タスク(プロセスとも呼ばれる)とは、アプリケーションを実行する実行単位のことを表します。MS-DOS 時代の古いコンピュータは常に 1 つのタスクしか実行することができませんでしたが、マルチタスク環境ではシステムが実行するべきコードを自動的に切り替えて管理するため、同時に複数のプログラムを起動することができます。

しかし、マルチタスクをサポートしていても、コンピュータに実装されている CPU の数は限られています。マルチタスクは CPU の使用権を複数のプログラムの間で高速に切り替わることで実現しているのです。これをプリエンプティブ方式と呼んでいます。

因みに、古いコンピュータや古いシステムでは、複数のプログラムを同時に起動することができても、特定のプログラムに処理が集中すると他のプログラムが停止するというものがあります。これは、プログラムが処理を開始すると、それが終了するまで CPU の使用権を切り替えないで到着順に実行するノンプリエンプティブ方式を採用していたためです。現代のコンピュータは、処理中のプログラムを一定時間間隔で次々と切り替えています。マルチタスクといっても、システムによって様々な方法で実現されているのです。

しかし、マルチタスクを実現するには、それぞれのタスクが保有するメモリなどの資源を切り替える必要があるため負担が大きいという問題もあります。そこで、タスクが保有する資源は共有し、実行するコードだけを分離する手法がマルチスレッドです。マルチスレッドの機能を使えば、プログラムは同一アプリケーションの中で複数のコードを実行することができます。マルチスレッドはゲームプログラムでは極めて重要な存在です。

ややこしいことを書いてきましたが、アプリケーション開発者にとってマルチスレッドとは、Main() メソッドを実行するコードとは分離し、独立してメソッドを実行できる新しい実行単位を作ることができるという機能です。アプリケーションは、最初 Main() メソッドから開始され、プログラムはソースコードの上から下に向かって順番にメソッドの内容を処理しました。他のメソッドを呼び出せば、制御がそのメソッドに移り、メソッドの終了と共に呼び出し元に制御が返され、この流れが Main() メソッドの終了まで続きます。

図1 単一のスレッドがメソッドを実行している状態
図1 単一のスレッドがメソッドを実行している状態

Windows フォームアプリケーションでは、Application.run() メソッドを実行しているスレッドを何らかの処理に集中させてしまうと、メッセージループが処理できなくなってしまうためコントロールの基本的な機能が失われてしまいます。ユーザーにとってこれはビジーと判断されるでしょう。しかし、ゲームプログラムでは画面の描画などに多くの処理を集中させる必要があります。この 2 つの条件を解決する方法はマルチスレッドを利用することです。

図2 複数のスレッドが同時にメソッドを実行している状態
図2 複数のスレッドが同時にメソッドを実行している状態

プログラムが実行されたときには、Main() メソッドを実行しているスレッドがすでに存在します。新しいスレッドを作成したり、既存のスレッドを操作するには System.Threading.Thread クラスを利用します。

System.Threading.Thread クラス
System.Object
    System.Threading.Thread
public sealed class Thread

新しいスレッドを作成して、既存のスレッドの流れとは別の流れとしてメソッドを実行することができます。この方法は難しいものではなく、仕組みとしてはタイマーによく似ています。ただし、タイマーはシングルスレッドによるものだったので、メソッドが同時に実行されるということはありませんでした。マルチスレッドの場合は、既存のスレッドと新しく生成したスレッドがそれぞれ独立して、同時並行的にコードを処理します。

新しいスレッドを実行するには、Thread インスタンスを生成して、新しいスレッドが実行するメソッドへのデリゲートを設定します。Thread インスタンスを生成するには、次のコンストラクタを呼び出してください。

Thread クラスのコンストラクタ
public Thread(ThreadStart start)

このコンストラクタの start パラメータには、このスレッドが起動したときに実行するメソッドへのデリゲートを指定します。ここに指定するのは System.Threading.ThreadStart デリゲートです。

System.Threading.ThreadStart デリゲート
[Serializable]
public delegate void ThreadStart()

ThreadStart デリゲートは、パラメータを受け取らない、戻り値を返さない単純なメソッドです。スレッドが起動するとこのメソッドが実行され、このメソッドが制御を返した時点でスレッドは停止しいます。ただし、インスタンスを生成した時点ではまだスレッドは起動しません。スレッドを起動させるには Start() メソッドを呼び出します。

Thread クラス Start() メソッド
public void Start()

Start() メソッドを呼び出した時点で、Thread() コンストラクタで指定したデリゲートが参照するメソッドが呼び出されます。このメソッドの実行は他のスレッドとは別に動作するため、例えばメソッドが長時間制御を返さなくても、アプリケーションのメッセージループに影響はありません。

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

public class Test : Form
{
	private int count;

	private void Test_Start()
	{
		while(!IsDisposed) 
		{
			count++;
			Invalidate();
		}
	}

	protected override void OnPaint(PaintEventArgs e)
	{
		base.OnPaint (e);
		Font font = new Font(Font.Name, 20);
		Brush brush = new SolidBrush(ForeColor);
		e.Graphics.DrawString("Count=" + count, font, brush, 0, 0);
	}

	public Test() 
	{
		Thread thread = new Thread(new ThreadStart(Test_Start));
		thread.Start();
	}

	static void Main() 
	{
		Application.Run(new Test());
	}
}
実行結果
コード1 実行結果

コード1は、Test クラスのコンストラクタで新しい Thread インスタンスを生成し、このスレッドから Test_Start() メソッドを起動しています。Thread オブジェクトの Start() メソッドを呼び出した時点で、Main() メソッドから始まっている主スレッドとは別のスレッドとして Test_Start() メソッドが実行されます。

Test_Start() メソッドは while 文でフォームが閉じられるまで count フィールドの値をインクリメントし続けるようにプログラムしてあります。このメソッドはフォームが閉じられるまで while 文をループし続けます。これをメッセージループのスレッドでやってしまうと、メッセージを監視するロジックが実行されなくなってしまい、アプリケーションウィンドウがまともに動作しなくなってしまいます。しかし、Test_Start() メソッドのループはメッセージループのスレッドとは別のスレッドで実行しているため、アプリケーションの基本動作は失われません。

プログラムを正常に終了させるには、フォームが閉じられてアプリケーションが終了するときに、生成した Test_Start() メソッドを実行するスレッドも停止させるべきです。このプログラムでは、フォームの閉じるボタンが押されて破棄されたときにスレッドを終了させています。フォームが破棄されているかどうかは IsDisposed プロパティから取得することができます。

Control クラス IsDisposed プロパティ
public bool IsDisposed { get; }

このプロパティは、コントロールが破棄されている場合は true を返します。

コード1の Test_Start() メソッドは CPU を休ませることなく最大速度でループを実行し続けるため、再描画が高速で繰り返されて画面がひどくちらつきます。また、Test_Start() メソッドを実行するスレッドが CPU を独占するため、他のアプリケーションの動作が鈍くなる可能性があります。

スレッドが長時間、特定のコードをループし続けるようなプログラムでは、余分な処理時間を他のスレッドのために空けるべきです。例えば、タイマーのような用途で何かを監視し続けるループをスレッドで作ろうと考えた場合、CPU の最高速度を発揮して監視する必要はないと考えられます。余っている時間を使って一定時間だけスレッドを休ませれば、他のスレッドに CPU の使用権を渡すことができます。どんなに高速に処理しなければならない場合でも、長時間繰り返し文を実行し続けるようなスレッドは、最低でも数ミリ秒を他のスレッドに明け渡すべきです。

スレッドを一定時間停止させるには Sleep() メソッドを呼び出します。Sleep() メソッドは静的なクラスメソッドなので Thread オブジェクトは必要ありません。Sleep() メソッドを呼び出したスレッド自身(すなわち、現在のスレッド)を停止させます。

Thread クラス Sleep() メソッド
public static void Sleep(int millisecondsTimeout)

millisecondsTimeout パラメータには、このメソッドを呼び出したスレッドを停止させる時間をミリ秒単位で指定します。指定した時間が経過するまでは、システムが他のスレッドに処理を割り当てられるため、スレッドの実行を一時的に停止させるのに利用できます。

指定したミリ秒が経過すれば、システムは再びこのスレッドをアクティブにします。ただし、時間が経過した瞬間に必ず実行されるというものではなく、スレッドが実行状態になるというだけで、スレッドの処理を進めるタイミングはシステムが決定します。よって、正確な時間を計測する手段として Sleep() を使ってはいけません。

コード2
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;

	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() 
	{
		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(50);
		}
	}
}
実行結果
コード2 実行結果

コード2は、フォームのクライアント領域をクリックすると、クリックした座標から画面下部に向かって徐々にラインが引かれていくというアニメーションを実行します。1つのスレッドが1つのラインを担当しているため、生成したスレッドの働きが視覚的に確認できます。つまり、1つのラインが1つのスレッドを表しています。実際には、生成したビットマップイメージ上に Graphics オブジェクトを通して線を引いています。

このプログラムで重要なのは、線が一瞬で描画されるのではなく、徐々に少しずつ下に向かって描画されるところにあります。スレッドは他のスレッドとは関係なく DrawThread_Start() メソッドの内部で線を引くコードを繰り返し実行していますが、スレッドが最高速度で繰り返しを行ってしまえば、線が一瞬で描画されてしまいます。このプログラムでは DrawThread_Start() メソッドの内部で小さな楕円を描画する座標をインクリメントしながら Sleep() メソッドでスレッドを一時停止させています。これによって、スレッドは指定された時間だけ停止するので少しずつ線が作られているのです。Sleep() メソッドに渡す休止時間を変更すれば、線が引かれる速度が変化します。

様々なオブジェクトが画面上を自由に飛び交うゲームプログラミングでは、スレッドが重要な役割を果たします。しかし、スレッドプログラミングの初心者はマルチスレッドが諸刃の剣であることも学ぶ必要があります。マルチスレッドは、複数のコードが適当なタイミングで実行されるため、複雑なプログラムになるとデバッグが困難になります。複数のスレッドが同じオブジェクトを参照する場合などは、プログラム方法によってはデータの整合性が失われるなどの問題が発生することもあります。マルチスレッドプログラミングは、プロフェッショナルでも頭を抱える難しい問題です。

マルチスレッドプログラミングを、一定の可読性や生産性、安全性を保ちながら実現するには、スレッドの役割を明確に定め、スレッドは単純な 1 つの役割を果たすことだけに徹底することです。また、タイマーなどを使ってシングルスレッドで実現できる処理ならば、マルチスレッドを使わない方法を採用するべきです。ただし、近年では CPU レベルでマルチスレッドに対応したり、マルチコア技術によって 1 つの CPU が物理的に複数のスレッドを並行処理できるようになりました。コンピュータの資源を最大限にまで利用する必要があるならば、十分に検討した後に分離できる機能を別のスレッドとして実行させるのも方法です。