와탭랩스 블로그 오픈 이벤트 😃
자세히 보기
Tech
2023-08-02
マルチスレッドプログラミング#1

はじめに


同時に動作する必要があるコードを書いたり、実行効率を上げるためにスレッドを使用することになります。スレッドを使用するとコードが複雑になり、デバッグが困難になるため、スレッドを使用するときは慎重に使用する必要があります。また、スレッドを誤って使用したときにはむしろパフォーマンスを損なう場合も発生します。

このポストでは、スレッドを効果的かつ安全に使用する方法について説明します。

 

 

Producer-consumer pattern (生産者-消費者パターン)

生産者-消費者パターンは、マルチスレッド環境でよく使用されるデザインパターンの1つです。 このパターンは、2つ以上のスレッドがジョブを配布して処理する方法を説明します。

このパターンの主なコンポーネントは次のとおりです。

 

  • プロデューサー(Producer):ジョブを作成してキューに追加する役割を果たします。たとえば、レストランで注文を受け取り、注文書を作成し、注文書に追加する従業員が生産者として機能します。

  • コンシューマー(Consumer):キューから作業をインポートして処理する役割を果たします。たとえば、レストランでシェフが注文書を持って料理を作るのは消費者の役割です。

  • キュー(Queue): 作業が保存されるスペースです。プロデューサによって作成された作業はキューに保存され、消費者はキューから作業をインポートして処理します。このキューは一種のバッファとして機能し、生産者と消費者の間のワークフローを制御します。

 

このパターンのポイントは、生産者と消費者が独立して作業しながら効率的に作業を配布して処理することです。プロデューサーはジョブを作成してキューに追加することに集中し、消費者はキューからジョブをインポートして処理することに集中します。これにより、各スレッドは自分の作業に集中することができ、コードの複雑さを減らし、プログラムの全体的な効率を向上させます。

ただし、このパターンを使用するときに注意すべき点があります。 プロデューサがタスクをあまりにも早く作成したり、消費者がタスクを十分に早く処理できなかった場合、キューがいっぱいになったりメモリを使い果たしたりする可能性があります。この場合、プロデューサーはキューにジョブを追加できなくなり、プログラムのパフォーマンスが低下し、重大なエラーが発生する可能性があります。

したがって、このパターンを使用するときは、生産者と消費者の作業速度を考慮する必要があります。消費者スレッドの数を増やしたり、可能であれば、生産量を調整するなどの措置が必要な場合があります。

 

生産者の消費パターン
Untitled.png


  • Producerは、処理する必要があるタスク(task)が発生したらキューに入れ(push)ます。

  • Consumerはqueueから仕事を取り出し(pop)処理します。

  • 処理することがない(nil)ときは無視し、仕事が見つかるまで繰り返します。

 

簡単な例を作成して、もう少し詳しく説明しましょう。例を説明すると、次のようになります。

ゲームでは、プレイヤーが戦闘を通じてアイテムを獲得したり、経験値を獲得するなどのイベントが発生します。これらの情報は、ゲームの進行状況を維持するためにデータベース(DB)に保存する必要があります。

しかし、DBに情報を保存するプロセスは、比較的時間がかかる作業です。 ゲームロジックを処理するメインスレッドが直接DBにデータを保存しようとすると、DB保存操作中にゲームロジック処理が中断され、ゲームが停止する現象が発生することがあります。

この問題を解決するには、生産者 - 消費者パターンを使用できます。ゲームロジックを処理するメインスレッド(生産者)は、DBに保存する情報をキューに追加するだけで、その他のゲームロジック処理を続けます。これにより、メインスレッドは、DBストレージ操作による遅延なしにゲームロジックをすばやく処理できます。

一方、別々のDBストレージスレッド(消費者)はバックグラウンドで動作し、キューに蓄積されたデータをDBに保存する作業を行います。 このスレッドは、DBの保存操作に必要な時間がかかってもメインスレッドの操作には影響しないため、ゲームが停止する現象を防ぐことができます。

このように生産者 - 消費者パターンを使用すると、ゲームロジック処理とDB保存操作を独立して実行でき、ゲームのパフォーマンスと反応性を向上させることができます。

以下は、上記の状況をC#を使用してConcurrentQueueと2つのスレッドを使用する単純なコンソールアプリケーションの例です。この例では、あるスレッドは「DB操作」文字列をキューに追加し、別のスレッドはキューから文字列を取得してコンソールに出力します。

 

using System;using System.Collections.Concurrent;using System.Threading;class Program{static ConcurrentQueue queue = new ConcurrentQueue();static void Main(string[] args){// DB ジョブをキューに追加するスレッドの作成Thread producer = new Thread(() =>{for (int i = 0; i < 10; i++){queue.Enqueue($"DB 작업 {i}");Thread.Sleep(100);}});// キューからジョブを取得してコンソールに出力するスレッドの作成Thread consumer = new Thread(() =>{string value;while (true){if (queue.TryDequeue(out value)){Console.WriteLine(value);}Thread.Sleep(50);}});// スレッドの開始producer.Start();consumer.Start();// スレッドが完了するまで待機producer.Join();consumer.Join();}}

 

Guarded Suspension Pattern

生産者-消費者パターンでは、生産者が要求したタスクに加えて他のタスクを処理できます。たとえば、プロデューサが作成したジョブの処理中に定期的に現在の状況をログに記録する必要がある場合などです。ただし、キューに積み重ねられたジョブのみを処理する場合、生産者 - 消費者パターンではジョブがなくてもプロセスが繰り返し繰り返され、CPUリソースを無駄にします。

消費者がキューに入ったジョブのみを処理する必要がある場合は、Guarded Suspensionパターンを考慮する必要があります。Guarded Suspension パターンでは、タスクがない場合はスレッドが完全に停止して待機します。つまり、新しいタスクが入り、スレッドが起きるまで完全に停止します。これにより、CPUリソースの無駄を防ぐことができます。

 

Untitled 1.png

Guarded Suspension Pattern

 

  • 生産者-消費者パターンでは、消費者スレッドが定期的にキューにデータを要求しますが、Guarded Suspensionパターンでは、消費者スレッドがパルス信号を受信したときに動作する点が異なります。

 

using System;using System.Collections.Generic;using System.Threading;public class Program{private static Queue<string> queue = new Queue<string>();private static object lockObject = new object();public static void Main(){Thread producerThread = new Thread(Producer);Thread consumerThread = new Thread(Consumer);producerThread.Start();consumerThread.Start();producerThread.Join();consumerThread.Join();}private static void Producer(){while (true){Thread.Sleep(1000);lock (lockObject){queue.Enqueue("task");Console.WriteLine("Task has been produced.");Monitor.Pulse(lockObject);}}}private static void Consumer(){while (true){string task = null;lock (lockObject){while (queue.Count == 0){Monitor.Wait(lockObject);}task = queue.Dequeue();}Console.WriteLine(task + " --> used");}}}

 

  • Producer メソッド: プロデューサースレッドが実行するメソッドです。このメソッドは、無限ループ内でジョブを作成してキューに追加します。ジョブをキューに追加したら、Monitor.Pulse(lockObject)を呼び出して待機しているコンシューマースレッドを起動します。

  • Consumer メソッド: コンシューマスレッドが実行するメソッドです。このメソッドは、無限ループ内でキューからジョブを取得して処理します。キューが空の場合は、Monitor.Wait(lockObject)を呼び出してコンシューマスレッドを待機状態にします。

これにより、コンシューマスレッドはキューにジョブがある場合にのみジョブを処理し、キューが空の場合は待機状態に切り替えてCPUリソースを節約できます。

 

SuspensionQueue

Guarded Suspension パターンは非常に頻繁に使用される設計パターンです。これを便利に使うために、私は SuspensionQueue<T>という再利用可能なクラスをあらかじめ定義しておきました。このクラスのコードは以下のように非常に簡潔です。

 

using System.Threading;using System.Collections.Generic;namespace WhaTap.Utils{public class SuspensionQueue<T>{public void Enqueue(T item){lock (_lock){_queue.Enqueue(item);Monitor.Pulse(_lock);}}public T Dequeue(){lock (_lock){while (_queue.Count == 0){Monitor.Wait(_lock);}return _queue.Dequeue();}}private readonly object _lock = new object();private readonly Queue<T> _queue = new Queue<T>();}}

 

このSuspensionQueue<T>クラスを使用して Guarded Suspension パターンを実装した例を次に示します。モニターを使用したロック機構をカプセル化することで、コードの読みやすさが向上しました。

 

using System;using System.Threading;using WhaTap.Utils;public class Program{private static SuspensionQueue<string> queue = new SuspensionQueue<string>();public static void Main(){Thread producerThread = new Thread(Producer);Thread consumerThread = new Thread(Consumer);producerThread.Start();consumerThread.Start();producerThread.Join();consumerThread.Join();}private static void Producer(){while (true){Thread.Sleep(1000);queue.Enqueue("task");Console.WriteLine("Task has been produced.");}}private static void Consumer(){while (true){string task = queue.Dequeue();Console.WriteLine(task + " --> used");}}}

 

おわりに

このポストでは、マルチスレッドプログラミングの重要なパターンである生産者-消費者パターンとGuarded Suspensionパターンについて学びました。これら2つのパターンは、マルチスレッド環境で作業を効率的に配布および処理するのに役立ちます。特に、Guarded Suspensionパターンは、CPUリソースを効率的に活用しながら、同時に作業の安全性を確保する上で重要な役割を果たします。

次回は、他のマルチスレッドプログラミング技術をご紹介します。これからもどうぞよろしくお願いします。

 

와탭 모니터링을 무료로 체험해보세요!