Posts
ゲームアプリケーションのマルチスレッド化
自作のゲームライブラリはシングルスレッドでの実装になっていたが、シングルスレッド故に発生する問題の解消や将来的な拡張を見据え、マルチスレッド化を行った。具体的にはメッセージループ (UI スレッド) とゲームループ (ゲームスレッド) を別々のスレッドへ分離した。
マルチスレッド化に際して色々と試行錯誤したこともあり、間違った内容が含まれている可能性は十分あるので、その点については了承いただきたい。ソースコードが公開されているゲームライブラリの実装を見てみたりもしたが、ほとんどがシングルスレッド実装で参考にはならなかった。
シングルスレッド方式
Win32 API ベースの GUI アプリケーションはウィンドウを作成したらメッセージループに突入し、送られてきたメッセージ (発生したイベント) をウィンドウプロシージャで適切に処理する、というのが基本構造となっている。ゲームアプリケーションの場合、この土台にゲーム関連の処理を肉付けしていくことになる。
ゲームループの構造
イベントドリブンはイベントが発生するまで処理を行わない受動的な仕組みなので、ゲームのような「一定間隔で画面を更新する」といった能動的な処理とは相性がよろしくない。タイマーを使えば一定間隔で処理を行うことも可能だが、その際に送られてくる WM_TIMER メッセージは優先度も精度も低いのでゲーム用途としてはちょっと厳しい。ならどうするのかというと、メッセージループによる完全なイベントドリブン構造を少し崩し、能動的な処理が行える構造へ作り替える。
標準的なメッセージループは以下のようになっているが、
while ( GetMessage( &msg, hWnd, 0, 0 ) ) { TranslateMessage( &msg ); DispatchMessage( &msg ); }
GetMessage はメッセージが来るまで待機してしまうので、直ぐに制御が返ってくる PeekMessage でメッセージの有無をチェックし、メッセージがあったときだけ GetMessage を呼ぶ構造に変更する。PeekMessage ではメッセージループの終了を検知できないので、ブロックの外側に無限ループを追加し、終了判定は従来通り GetMessage 側で行う。
while ( true ) { if ( PeekMessage( &msg, NULL, 0, 0, PM_NOREMOVE ) ) { if ( GetMessage( &msg, NULL, 0, 0 ) ) { TranslateMessage( &msg ); DispatchMessage( &msg ); } else { // ループ終了 break; } } else { // デッドタイム // ここにゲーム関連の処理を記述する } }
メッセージが存在しない空き時間はデッドタイムと呼ばれ、このイベントドリブンから切り離された時間にゲーム処理を行うことで高いリアルタイム性を確保することができる。やや強引なやり方に見えるが、DirectX SDK のサンプルでも使われているのでマイクロソフト公認のやり方とも言える。
ちなみに GetMessage は、
エラーがある場合、戻り値は -1 です。たとえば、hWnd が無効なウィンドウハンドルであるか、lpMsg が無効なポインターである場合、関数は失敗します。戻り値はゼロ以外、ゼロ、または -1 になる可能性があるため、次のようなコードは避けてください。
while (GetMessage( lpMsg, hWnd, 0, 0)) ...
BOOL 以外の値を返すことがあるので、戻り値の判定には注意が必要。
デッドタイム方式の問題点
先程のコードは概ねうまく動作するが、デッドタイムに時間のかかる処理を行うと PeekMessage に続くメッセージ関連の処理が行われず、結果としてウィンドウが応答しなくなってしまう。これは GUI アプリケーションでしばしば発生する問題で、ゲームアプリケーションに限った話ではない。
ひとつの解決策として、重い処理を分割して間にメッセージ処理を少しだけ行う DoEvents 的なものを挟むという方法があるが、処理時間は環境によって異なり、どこまで分割すれば妥当な処理時間になるのか判断するのが難しいため、どうしても場当たり的な対応になってしまう。
また、ゲームを一時停止させるために単純にゲーム処理をスキップすると大変なことになる。通常、ゲーム処理が行われている間は垂直同期かタイマーによる待機が行われるが、スキップすると待機が無くなってしまい、ゲームループはビジーループへと変化する。ゲームを非アクティブにした途端に CPU の使用率が急上昇してしまうアプリケーションは、この待機処理が漏れている可能性が高い。
マルチスレッド方式
ゲームループが重い状態でもメッセージを処理できるようにするには、ゲームループとメッセージループを同時に動かす必要があり、マルチスレッドによる並行処理で対応する。
スレッドライブラリの使い方
以前は、CreateThread で生成したスレッド上で C ランタイムライブラリを使うとメモリリークが発生したり、それを回避するために _beginthreadex を使う必要があったりと、スレッドを使う前から面倒なことになっていたが、C++11 でスレッドがサポートされたことによって比較的簡単に利用できるようになった。
機能 | 対応メソッド |
---|---|
生成と開始 | std::thread (コンストラクタ) |
終了まで待機 | std::thread::join |
待機 | std::this_thread::sleep_for |
スレッドライブラリ自体はシンプルで使いやすい作りにはなっているが、スレッドの停止といった定型処理が必要になるので、std::thread を軽くラップした単純なスレッドクラスを作っておくとより使いやすくなる。
#include <thread> #include <atomic> class CThread { public: CThread() : m_lpthread( nullptr ), m_bTerminated( true ) { } virtual ~CThread() { this->Terminate(); } void Start() { if ( this->m_lpthread != nullptr ) { return; } this->m_bTerminated = false; this->m_lpthread = new std::thread( &CThread::Run, this ); } void Terminate() { if ( this->m_lpthread == nullptr ) { return; } this->m_bTerminated = true; this->m_lpthread->join(); delete this->m_lpthread; } protected: virtual void Run() = 0; bool IsTerminated() const { return this->m_bTerminated; } void Sleep( unsigned int uTime ) const { std::this_thread::sleep_for( std::chrono::milliseconds( uTime ) ); } void Yield() const { this->Sleep( 1 ); } void WaitForTerminate() const { while ( !this->m_bTerminated ) { this->Yield(); } } private: std::thread* m_lpthread; std::atomic< bool > m_bTerminated; };
このクラスを使ってマルチスレッド化を行っていく。
UI スレッドとゲームスレッドの分離
UI 処理をメインスレッドに残したまま、ゲーム処理だけを新たに生成したスレッドへ移すことで分離を行う。ゲームスレッドへ移動する処理の量に比例して並行化される範囲も大きくなるが、それと同時に UI スレッドから細かく制御できる範囲は小さくなるので、どこまで移動させるのか考える必要がある。
ゲーム関連の処理を大きく分けると以下のようになり、
- サブシステムの初期化と解体
- ゲームの初期処理と後処理
- ゲームループ
サブシステムは DirectX のような API やそれらをラップしたライブラリ、ゲームの初期処理はゲーム起動時に行う設定ファイルやリソースの読み込みのことを指している。
ゲームループはこれを並行動作させるためのマルチスレッド化なので、どうすべきかは言うまでもない。ゲームの初期処理は、リソースの読み込み等で重くなりがちなので、ゲームスレッドに置くのが望ましいと言える。サブシステムの初期化には COM (DirectX) が関係し、複数スレッドに跨った COM の利用には細心の注意が必要になるので、サブシステムとゲームループを同じスレッドにしてしまい、面倒なことはやらずに済ませたい。
上記を踏まえ、全てのゲーム処理を移動することにした。ただ、最初に触れたように細かい制御はできなくなるので、サブシステムを初期化する過程で発生する処理 (画面切り替えなど) のタイミングを UI スレッド側から制御するのは難しくなる。
ゲームスレッドの実装
先程の CThread クラスを継承し、Run メソッド内にゲームループを作成する。デッドタイム方式はメッセージループとゲームループでひとつのループを共有しているので、分離に際して新たにループを作ってやる必要がある。停止要求が来たら即座に終了できるよう、ループ条件にはスレッド状態判定を入れておく。
// スレッドの停止要求が来るまで while ( !this->IsTerminated() ) { // デッドタイムで行っていた処理をここへ移動する }
UI スレッド側にあるデッドタイム処理はもう使わなくなるので、GetMessage による標準的なメッセージループに戻す。
ゲームスレッドの開始
ゲームループが出来上がったら CThread::Start を呼び出してスレッドを開始するだけだが、UI スレッドと並行して動くことになるので適切なタイミングで開始する必要がある。
例えば、ウィンドウ関連の初期化処理でよく使われる WM_CREATE で開始した場合、どのような結果になるのか。WM_CREATE は CreateWindow が呼ばれたときに送信されるメッセージなので、まだメッセージループは開始しておらず、ShowWindow すら呼ばれていない。ウィンドウが非表示のままゲームスレッドは動きだし、ウィンドウが表示される頃には僅かな時間ではあるものの既にゲームは進行しているため、ゲームを途中から始めたような状態になってしまう。
つまり、ゲームスレッドは最低限のウィンドウ処理が終わった後に開始するのが理想と言える。メッセージループを開始する頃には一通り処理は終わっているはずなので、メッセージループの手前でゲームスレッドを開始することにした。
std::thread は生成と同時にスレッドが開始してしまうので、CThread では new による動的生成を行うことで開始タイミングを制御している。その代わり delete が必要になってしまうが、デストラクタで補足できるので問題はない。
ゲームスレッドの停止
UI スレッドを終了するタイミング (WM_DESTROY など) になったら、CThread::Terminate を呼び出してゲームスレッドを停止する。呼び出しから停止に至るまでの詳細なフローは以下のようになる。
- CThread::Terminate を呼ぶ。
- CThread::m_bTerminated が true になる。
- CThread::IsTerminated が true を返すようになる。
- ゲームループの継続条件を満たさなくなりループが終了する。
- ゲームスレッドが終了するまで std::thread::join で待機する。
CThread::Terminate は一連の手続きを行ってくれるので、制御が返ってきた頃には既にゲームスレッドは終了した状態になっている。後は通常通り終了処理 (PostQuitMessage の呼び出しなど) を行い、アプリケーションを終了させる。
UI スレッドの停止
「ゲーム内メニューからの終了」といったゲームスレッド起点でアプリケーションを終了させる場合、単純にゲームループを抜けてしまうと UI スレッドだけ動いている状態になり、何も表示されていないウィンドウが残ってしまう。
こうならないよう、UI スレッドからゲームスレッドを停止したのと同じように、ゲームスレッドから UI スレッドの停止を行う。CThread は外部から停止することを前提にした作りなので停止メソッドが用意されているが、UI スレッドにはそれに相当する機能がない。幸い、UI スレッドはメッセージ処理を行っているので、PostMessage を使えば通知を行うことはできる。
各スレッドから異なるフローで相互に停止を行うとプログラムがややこしくなってしまうので、ゲームスレッドからは UI を終了させるアクション (WM_CLOSE の送信など) だけを行い、UI スレッド起点でゲームスレッドを停止させた時と同じフローにする。
正常にアプリケーションを終了されられるようにするため、ゲームループの下に以下のようなコードを追加する。
// ゲームループを break で抜けたのでまだ停止していない if ( !this->IsTerminated() ) { // ウィンドウを閉じる // Terminate は PostMessage で WM_CLOSE を送るだけのメソッド Window.Terminate(); // ウィンドウからスレッドの停止要求が来るまで待機 this->WaitForTerminate(); }
この後にサブシステムの解体が行われるが、ウィンドウの終了を待たずに最速でサブシステムを解体してしまうと、フルスクリーン解除後のデスクトップにまだ生きているウィンドウが一瞬表示されるといったことが発生するので、CThread::WaitForTerminate でウィンドウが終了状態になるまで待機している。UI スレッドから終了する場合は、既にウィンドウは破棄される直前なので、こういった問題は発生しない。
マルチスレッドに纏わる問題
マルチスレッドを使う上で注意すべき点についてまとめておく。
データ競合
C++ では同じ変数に対する複数スレッドからの書き込みを含んだアクセスはデータ競合と定義され、データ競合は未定義動作となっているため避けなければならない。
スレッドに状態を通知するための変数は、片方のスレッドで変数を更新し、もう片方のスレッドで変数を参照するという性質上、必然的にデータ競合が発生してしまう。CThread では m_bTerminated がそれに該当する。
データ競合を回避するには排他制御が必要になり、std::mutex などを使用したロック処理を思い浮かべてしまうが、単純にデータ競合を回避したいだけであれば std::atomic というテンプレートライブラリを使うだけで済み、std::atomic で宣言した変数はアトミック操作になる。
std::atomic はクラステンプレートのため bool 型であれば std::atomic< bool > という宣言になり、プリミティブ型の一部は std::atomic_bool のような別名が用意されている。ただ、Visual Studio 2013 には別名で宣言した変数がコンストラクタで初期化出来なくなる不具合がある。
また、operator は読み書きを行うメソッドと対応しているので、
操作 | operator | メソッド |
---|---|---|
書き込み | operator = | store |
読み込み | operator T | load |
通常の変数と同じように扱うことができ、既存の変数宣言を std::atomic に変えるだけでデータ競合対策が終わってしまうこともある。
yield vs sleep_for
CThread::WaitForTerminate は CThread::Yield を呼び出して待機を行っているが、Yield と言っておきながら内部では std::this_thread::sleep_for を呼び出している。std::this_thread::yield は、
この関数の正確な動作は処理系に、特に使用中の OS のスケジューラの機構やシステムの状態に依存します。例えば、先入れ先出しのリアルタイムスケジューラ (Linux の SCHED_FIFO) は、現在のスレッドをサスペンドし、同じ優先度の実行可能なスレッドのキューの末尾に置くでしょう (そしてもし、同じ優先度のスレッドが他になければ、yield は効果を持ちません)。
スレッドの状態によっては効果がなくなり、実際、自分の環境では yield だと CPU の使用率が急上昇したので sleep_for を使っている。
乱数
ゲーム内で乱数を使っている処理が毎回同じ結果になったので調査したところ、メインスレッドで設定した乱数シード (srand) がゲームスレッドに反映されていないのが原因だった。Microsoft Docs には、
現在のスレッドで一連の擬似乱数を生成するための開始点を設定します。
と書かれていたので、メインスレッドの乱数シード設定処理をゲームスレッドへ移動したら正常に動くようになった。ただ、これは Microsoft 固有の実装らしく、他の処理系も同じとは限らないとのこと。
また、C の乱数ライブラリは乱数の品質が低く、スレッドセーフ保証もないので、C++11 で追加された乱数ライブラリ <random> の使用が推奨されている。