マルチスレッドプログラミング#2 最も簡単に説明したActive Objectパターン

Tech

2024年2月8日


main

最も簡単に説明したActive Objectパターン

前編の続きで、マルチスレッドプログラミングの2番目のトピックを始めましょう。今回学んでみるのはActive Objectパターンですが、すぐに説明せずに必要な技術を一つずつあらかじめ見てみながら、最終的にActive Objectを説明したいと思います。

今回のポストはできるだけ簡単に説明し、核心内容に集中したいと思います。したがって、もっと深く勉強したい方は、POSA(Pattern-Oriented Software Architecture)などの書籍や講義資料を参照してください。

スレッド関連プロセスを隠す

ファイルにいくつかのログを残す必要がある状況を考えてみましょう。 ログを残すように求めるメッセージだけが LogWriterオブジェクトに通知されます。したがって、興味を分離することをお勧めします。このような場合、外部オブジェクトはスレッドやキューなどのアルゴリズムについて知らなくても問題がないようにカプセル化することをお勧めします。

前編で学んだGuarded Suspensionパターンにスレッドを合わせた形でLogWriterを作成しました。これはよく使われるパターンです。説明する必要もないほどの単純な使用の例です。

						
						  public class LogWriter
						  {
							  private readonly string _filePath;
							  private readonly BlockingCollection _messageQueue = new BlockingCollection();
							  private readonly Thread _logThread;
						  
							  public LogWriter(string filePath)
							  {
								  _filePath = filePath;
								  _logThread = new Thread(WriteLog);
								  _logThread.Start();
							  }
						  
							  public void Info(string message)
							  {
								  WriteMessage($"INFO: {message}");
							  }
						  
							  public void Error(string message)
							  {
								  WriteMessage($"ERROR: {message}");
							  }
						  
							  private void WriteMessage(string message)
							  {
								  _messageQueue.Add($"{DateTime.Now}: {message}");
							  }
						  
							  private void WriteLog()
							  {
								  foreach (var message in _messageQueue.GetConsumingEnumerable())
								  {
									  using (var writer = new StreamWriter(_filePath, true))
									  {
										  writer.WriteLine(message);
									  }
								  }
							  }
						  }
						
					  

GetConsumingEnumerable() メソッドは、キューにデータが入るたびにブロックが解放される形で動作します。キューが空のときはスレッドをブロックして停止して待機状態になり、もはや資料がない状態ではないため、繰り返し文は中断されません。

BlockingCollectionの代わりに、前編で見たSuspensionQueueを使用することもできます。

Futureパターン

このポストの例では、ゲームサーバーのシナリオを実装する予定です。その中でログイン過程について考えてみましょう。

データベースは比較的処理速度が遅い方です。したがって、データベースにIDとパスワードを要求して待っていると、ゲームロジックの処理に遅延が発生し、最終的にサービスに大きな影響を与える可能性があります。この場合、別のスレッドに遅い処理を非同期的に要求して解決できます。 しかし、非同期で要求された処理の結果を取得する方法が必要です。このとき使えるのがFutureパターンです。

C#では、Taskクラスは結果が利用可能になるまで呼び出しスレッドをブロックするResultというプロパティを提供します。次の例では、非同期で計算を実行し、将来の結果を表すTaskを返すCalculateAsync()メソッドがあります。Main() メソッドで CalculateAsync()を呼び出し、返された作業を future 変数に割り当てます。次に、別のタスクを実行し、最後に future タスクの Result プロパティを使用して計算結果を取得します。

						  
							class Program
							{
								static void Main()
								{
									Task future = CalculateAsync();
									Console.WriteLine("Doing some other work...");
									int result = future.Result;
									Console.WriteLine($"Calculation result: {result}");
								}
							
								static async Task CalculateAsync()
								{
									await Task.Delay(3000);
									return 42; // 計算された結果を返す
								}
							}
						  
						
future
  • Main() メソッドで CalculateAsync() メソッドを呼び出します。このメソッドは、async キーワードを使用して非同期として宣言されました。
  • CalculateAsync() メソッド内で await Task.Delay(3000) を呼び出して 3 秒間待機します。このときawaitキーワードを使用すると、 CalculateAsync() メソッドの実行を一時停止し、制御権を呼び出し元の Main() メソッドに返します。
  • Main() メソッドで制御権を越えながら、"Doing some other work..." を出力します。
  • Main() メソッドで future.Result を呼び出します。このプロパティはTaskの結果を取得し、非同期操作が完了するまで現在のスレッドをブロックします
  • 指定された3秒後、CalculateAsync()メソッドは計算された結果42を返します。この値は Taskオブジェクトの Result プロパティに格納されます。
  • Main() メソッドで future.Result を使用して計算された結果を取得します。以降、「Calculation result: 42」を出力します。

次の例は、2つの非同期実行結果を組み合わせる必要がある場合を示しています。このポストでは必要な状況ではありませんが、よく使われる形なので紹介しました。

コードを見ると、計算を実行し、将来の結果を表すTask intを返すCalculateAsync1とCalculateAsync2という2つのメソッドがあります。Mainメソッドで 2 つのメソッドを呼び出し、返されたアクションを task1 およびtask2 変数に割り当てます。次に、Task.WhenAllメソッドを使用して、2つの操作が完了するのを待ち、結果を配列として取得します。最後に結果を加算して合計を出力します。

						  
							class Program
							{
								static async Task Main()
								{
									Task task1 = CalculateAsync1();
									Task task2 = CalculateAsync2();
							
									// 両方の操作が完了するまで待つ
									int[] results = await Task.WhenAll(task1, task2);
							
									int total = results[0] + results[1];
									Console.WriteLine($"Total result: {total}");
								}
							
								static async Task CalculateAsync1()
								{
									await Task.Delay(3000);
									return 42; // 計算された結果を返す
								}
							
								static async Task CalculateAsync2()
								{
									await Task.Delay(5000);
									return 58; // 計算された結果を返す
								}
							}
						  
						

Futureパターンを使用する主な理由は次のとおりです。

  • 非同期プログラミングの簡潔さ:Futureパターンを使用すると、非同期コードを同期コードのように読み書きできます。asyncawaitキーワードを使用すると、複雑なコールバックやイベントハンドラなしで非同期操作の結果を簡単に処理できます。
  • 応答性の向上:特にUIベースのアプリケーションでFutureパターンを使用すると、UIスレッドがブロックされず、アプリケーションの応答性が向上します。ユーザーは、ジョブがバックグラウンドで実行されている間でもUIと対話できます。
  • リソースの最適化:非同期タスクを開始すると、スレッドをブロックせずに他のタスクを続行できます。これはリソースの使用を最適化し、特にI / Oバウンド操作にメリットがあります。
  • 並列処理:複数の非同期ジョブを同時に開始し、すべてのジョブが完了するのを待ってから結果を集計できます。これにより、ジョブの並列処理とパフォーマンスの向上を実現できます。
  • 例外処理の簡潔さ: awaitを使用すると、非同期操作で発生した例外を同期コードと同じように処理できます。これにより、コードの読みやすさと保守性が向上します。
  • スケーラビリティ:Futureパターンは、スケーラブルなアプリケーションを構築するのに役立ちます。サーバーアプリケーションは同時に複数の要求を処理できるため、より多くのユーザー要求をすばやく処理できます。
  • コードの一貫性:Futureパターンを使用すると、同期コードと非同期コードの違いを最小限に抑えることができます。これによりコードの一貫性が維持され、開発者は非同期ロジックをより簡単に理解してデバッグできます。

要約すると、C#でFutureパターンを使用すると、非同期プログラミングをより簡単かつ効果的に実行でき、アプリケーションのパフォーマンスと応答性を向上させることができます。

Commandパターン

Commandパターンについて見てみましょう。今回取り上げる内容は Commandパターンの全体的な内容ではなく、多型性を利用したサンプルコードに重点を置きます。

command

ゲームサーバーロジックを実装する過程でログインする場合とユーザーがアイテムを獲得したとき、データベースにアクセスして情報を取得または保存する動作を処理しようとします。このときデータベースを操作するインタフェースを統一しようとするのですが、ゲームロジックを処理する立場ではデータベース操作の内容を具体的に知る必要がないからです。

この場合、Commandパターンを使用してください。要求する側では、具体的な動作には関心を持つ必要がない場合に Command パターンを使用します。呼び出し方法をインタフェースを通じて統一して同じように扱い、さまざまなタスクを同じ方法で要求できるようにします。

以下のコードは例のために作られたもので、具体的な動作はしない擬似コードです。Futureパターンを利用して非同期的に結果をもたらして使用できるようにしました。

						
						  public interface ICommand
						  {
							  Task Execute();
						  }
						  
						  public class LoginCommand : ICommand
						  {
							  private string _username;
							  private string _password;
						  
							  public LoginCommand(string username, string password)
							  {
								  _username = username;
								  _password = password;
							  }
						  
							  public async Task Execute()
							  {
								  // TODO: ログイン処理
								  return true;
							  }
						  }
						  
						  public class SaveItemCommand : ICommand
						  {
							  private string _username;
							  private string _item;
						  
							  public SaveItemCommand(string username, string item)
							  {
								  _username = username;
								  _item = item;
							  }
						  
							  public async Task Execute()
							  {
								  // TODO: アイテム保存処理
								  return true;
							  }
						  }
						
					  

今回の例では簡単に必要な実装で仕上げましたが、Commandパターンはインタフェースを統一するだけでなく、いくつかの追加的な利点があります。以下は、Commandパターンを使用することで得られる利点です。

  • 分離とカプセル化:Commandパターンを使用すると、要求を実行するオブジェクトと要求を発生するオブジェクトを分離できます。これにより、要求を実行するロジックをカプセル化して、さまざまな要求を同じ方法で処理できます。
  • 再利用と組み合わせ:Commandオブジェクトを再利用してさまざまな操作を実行できます。また、複数のCommandオブジェクトを組み合わせて複雑な操作を実行できます。
  • 遅延実行:Commandパターンを使用すると、すぐに要求を実行せずに後で実行できます。これにより、遅延実行、予約実行などの機能を実装することができます。
  • キャンセルと再実行:Commandパターンを使用すると、実行した要求をキャンセルまたは再実行できます。これにより Undo/Redo 機能を実装できます。
  • ログと履歴:Commandパターンを使用すると、実行した要求をログに記録または保存できます。これにより、システムの状態を復元したり、問題を診断したりできます
  • 並列および非同期処理:Commandパターンを使用すると、要求を並列に処理したり非同期に処理したりできます。これにより、システムのパフォーマンスを向上させることができます。
  • スケーラビリティ:Commandパターンを使用すると、新しいリクエストを簡単に追加できます。これにより、システムのスケーラビリティを向上させることができます。
  • テストの容易さ:Commandパターンを使用すると、要求を独立してテストできます。これにより、システムのテストの容易さを向上させることができます。

このため、Commandパターンは、リクエストを実行するロジックをカプセル化、再利用、拡張するのに役立ちます。

Active Objectパターン

今まで説明した材料を利用して、Active Objectパターンについて調べてみましょう。

Active Objectパターンは、並行性の問題を解決するために使用される設計パターンです。このパターンは、オブジェクトのメソッド呼び出しを非同期的に処理するように設計されています。Active Objectパターンは要求をキューに格納し、別のスレッドでキューに格納された要求を順次処理します。これにより、並行性の問題を解決し、オブジェクトの状態を安全に管理できます。

以下のコードは、ゲームサーバーと呼ばれる仮想シナリオでActive Objectを利用する例です。2つのイベントを処理しています。ユーザーがゲームサーバーにログインしたときに発生するOnLogin()と、ユーザーがiTepを取得したときにデータベースにバックアップしたいOnSaveItem()イベントを扱っています。

OnLogin() イベントでは、ユーザーのパスワードをデータベースで確認し、その結果を利用してログイン処理を進めています。したがって、非同期的に実行されるデータベース検証タスクをawaitを利用して待ち、その結果を使用するプロセスです。

一方、OnSaveItem() の場合には、あえてデータベース処理結果を待つ必要がない場合を説明しています。 ゲームロジックはメモリで行われ、後でデータベースにバックアップを取るシナリオです。 データベースへの保存中に発生するエラーなどの処理はカプセル化され、GameServerでは注意を払わないようにします。これにより関心事を徹底的に分離しています。

ActiveObjectに非同期的に操作を要求するだけなので、OnSaveItem()ではasync/awaitを使用する必要はありません。

						
						  public class GameServer
						  {
							  private static ActiveObject _activeObject = new ActiveObject();
						  
							  public static async void OnLogin(Connection connection, string username, string password)
							  {
								  var command = new LoginCommand(username, password);
								  var loginTask = _activeObject.Enqueue(command);
								  var result = await loginTask;
								  if (!result) {
									  connection.SendLoginFailed();
									  return;
								  }
						  
								  connection.SendLoginSuccess();
						  
								  // ログイン完了処理が必要
							  }
						  
							  public static void OnSaveItem(Connection connection, string username, string item)
							  {
								  var command = new SaveItemCommand(username, item);
								  _activeObject.Enqueue(command);
						  
								  // キャラクターの在庫にアイテムを追加
							  }
						  }
						
					  

以下のコードはActiveObjectクラスの実装コードです。このクラスは、Commandパターンを使用して非同期的にコマンドを実行する役割を果たします。

						
						  using CommandTaskTuple = Tuple>;
			
							public class ActiveObject
							{
								private BlockingCollection _queue = new BlockingCollection();
							
								public ActiveObject()
								{
									Task.Run(() =>
									{
										foreach (var tuple in _queue.GetConsumingEnumerable())
										{
											var command = tuple.Item1;
											var tcs = tuple.Item2;
											var result = command.Execute().Result;
											tcs.SetResult(result);
										}
									});
								}
							
								public Task Enqueue(ICommand command)
								{
									var tcs = new TaskCompletionSource();
									_queue.Add(Tuple.Create(command, tcs));
									return tcs.Task;
								}
							}
						
					  
  • このコードは、Active Objectパターンを実装したActiveObjectクラスです。このクラスは非同期的にコマンドを実行する役割を果たします。ActiveObjectクラスは、次のコンポーネントで構成されています。
  • BlockingCollection:このクラスはスレッドセーフなコレクションで、複数のスレッドで同時にアクセスするときに発生する可能性のある並行性の問題を解決します。このクラスを使用すると、スレッドがキューにエントリを追加または削除したときに同期を自動的に処理します。これにより、要求されたタスクを安全に保存して処理できます。
  • Guarded Suspensionパターン:このパターンは、スレッドが特定の条件が満たされるのを待つようにするパターンです。ActiveObject クラスでは、BlockingCollection を使用してこのパターンを実装しています。キューにコマンドが追加されるまでスレッドが待機します。
  • ActiveObject() コンストラクタ: このコンストラクタは別のスレッドを起動し、キューに格納されたコマンドを順次実行します。キューからコマンドを取得し、Execute メソッドを呼び出してコマンドを実行し、その結果を TaskCompletionSource オブジェクトに設定します。これにより、非同期的にコマンドを実行して結果を返すことができます。
  • Enqueue() メソッド: このメソッドは、コマンドをキューに格納する役割を果たします。コマンドとその結果を表すTaskCompletionSource オブジェクトを作成し、キューに保存します。そしてTaskCompletionSourceを利用して、まだ実行されていないCommand のフューチャに対応するTask オブジェクトを返します。
  • TaskCompletionSource: このクラスは、非同期操作の結果を表す Task オブジェクトを生成し、その結果を設定するために使用されます。 これにより、非同期操作の完了を表示して結果を返すことができます。

以下は、Active Objectパターンを使用することで得られる利点です。

  • 並行性制御:Active Objectパターンを使用すると、並行性の問題を解決し、オブジェクトの状態を安全に管理できます。
  • 非同期処理:Active Objectパターンを使用すると、オブジェクトのメソッド呼び出しを非同期的に処理できます。 これにより、システムの応答性を向上させることができます。
  • コードの簡潔さ:Active Objectパターンを使用すると、並行性制御と非同期処理のためのコードを簡潔に作成できます。
  • スケーラビリティ:Active Objectパターンを使用すると、新しい要求を簡単に追加できます。 これにより、システムのスケーラビリティを向上させることができます。

このため、Active Objectパターンは並行性の問題を解決し、非同期処理用のコードを簡潔に記述するのに役立ちます。

並行性問題を解決するActive Objectパターン

例をできるだけ簡単で説明しやすく設定しようとしました。 実戦であれば、Command生成部分までも隠して、以下のような形で構成するのも大丈夫です。

						
						  public class GameServer
						  {
							  private static DatabaseController _databaseController = new DatabaseController();
						  
							  public static async void onLogin(Connection connection, string username, string password)
							  {
								  var result = await _databaseController.Login(username, password);
								  if (!result)
								  {
									  connection.SendLoginFailed();
									  return;
								  }
						  
								  connection.SendLoginSuccess();
						  
								  // ログイン完了処理が必要
							  }
						  
							  public static async void onSaveItem(Connection connection, string username, string item)
							  {
								  _databaseController.SaveItem(username, item);
						  
								  // キャラクターの在庫にアイテムを追加
							  }
						  }
						
					  

このポストでは、並行性の問題を解決するActive Objectパターンについて学びました。このパターンは、オブジェクトのメソッド呼び出しを非同期的に処理するように設計されており、リクエストをキューに格納し、別のスレッドでキューに格納されたリクエストを順次処理します。これにより、並行性の問題を解決し、オブジェクトの状態を安全に管理できます。

Active Object パターンは、ゲームサーバーなどのマルチユーザー環境で効果的に使用できます。このパターンを使用すると、ユーザーの要求を非同期的に処理し、同時に発生する複数の要求を安全に管理できます。

次回のポストでは、他の並行性パターンを紹介します。これからもどうぞよろしくお願いします。ありがとうございました。

종택님
Jongtaek Ryu([email protected])
Development TeamAPM Agent Developer

今すぐWhaTapをお試しください。