背景
C#で作っている趣味のゲームのゲームループ設計がカチッとはまらない。他言語の様子を見るとC++のboost::asioはシングルスレッドでもかなり高速に動いていたし、流行りのjavascriptも同じくシングルスレッドで特に問題なく使われているようだ。シングルスレッドかつタスクキューの組み合わせが重要っぽい感じ。何が違うのか自分的に既知の設計2つと比較してみる。
前提
ゲームループ実装3つについて比較してみる。机上検討のみ。記載コードは疑似コードで、C#とSDLライブラリをイメージしてはいるがビルドや実行確認はしていない。
1,素朴なブロッキングSleepとループによる基盤
メインスレッドでSleepを使って定期的なタイミングを計る。
void Main()
{
while (...)
{
while (eventQueue.TryDequeue(out var ev))
{
switch (ev.Type)
{
case EventType.Abc:
OnAbc(ev);
break;
case EventType.Def:
OnDef(ev);
break;
default:
break;
}
}
Update();
Sleep(16);
}
}
void OnAbc(Event ev) {...}
void OnDef(Event ev) {...}
void Update() {...}
2,イベントキュー基盤
繰り返しタイマーにより定期的なタイミングを計る。タイマーイベントを含めてすべてのイベントをいったん唯一のイベントキューに入れ、それを1つのスレッド上で取り出すことでシングルスレッド動作を保証する。イベントはSDLライブラリを想定。
void Main()
{
eventListener.AddEventListener(EventType.Abc, OnAbc);
eventListener.AddEventListener(EventType.Def, OnDef);
eventListener.AddEventListener(EventType.OnEnterFrame, Update);
var timer = new RepeatTimer(16, () => {
// eventLoopへのenqueueはthread-safe.
eventLoop.EnqueueEvent(EventType.OnEnterFrame);
});
timer.Start();
while (eventQueue.WaitDequeueEvent(out var ev))
{
if (eventListener.TryGetValue(ev, out var func))
{
func();
}
}
}
void OnAbc(Event ev) {...}
void OnDef(Event ev) {...}
void Update(Event ev) {...}
3,タスクキュー基盤(Fiber基盤)
定期的なタイミングを計る方法は繰り返しタイマーでもなんでもよい(下記コード例ではタイマー)。定期処理およびイベントハンドル処理含めすべての処理をいったん唯一のタスクキュー(Fiber)に入れる。取り出す側では1つ取り出して実行完了してから次のものを取り出すことでシングルスレッド的な動作を保証する。
タスクキューは ConcurrentQueue<Action>
と BlockingCollection
を使えば実装できそうに思える。
void Main()
{
eventListener.AddEventListener(EventType.Abc, OnAbc);
eventListener.AddEventListener(EventType.Def, OnDef);
var timer = new Timer(16, Repeat, () => {
// mainFiberはthread-safe.
mainFiber.Enqueue(Update);
});
timer.Start();
th = new Thread(() => {
// eventLoopのサイズ取得はthread-safe.
while (eventQueue.WaitNewData())
{
eventWaiter.Reset();
mainFiber.Enqueue(() => {
while (eventQueue.WaitDequeueEvent(out var ev))
{
if (eventListener.TryGetValue(ev, out var func))
{
func();
}
}
eventWaiter.Set();
});
eventWaiter.Wait();
}
});
while (mainFiber.WaitDequeue(out var func))
{
func();
}
}
void OnAbc(Event ev) {...}
void OnDef(Event ev) {...}
void Update() {...}
※Fiber利用コード例、RetlangライブラリのThreadFiber:
https://github.com/gmnash/retlang/blob/master/src/RetlangTests/Tests/ThreadFiberTests.cs
比較検討
シングルスレッド的な動作可否
どの手法もロック記述の手間を省きつつシングルスレッドのように正しく動く。
余計な依存関係有無
2番はUpdate関数の引数にEvent型が増えている。余計な依存関係ができていてちょっと邪魔。1番と3番はその余計なものが無く、比較的きれい。
性能差
2番と3番は定期処理実行に余計なキューを介しているだけ負荷が増える。とはいえ同じようなことをすでにイベントキューでやっていて問題ないのだし、許容範囲に思える。
マルチスレッド処理への拡張性
2番と3番はキューへの投稿元スレッドが複数になったとしても動く。マルチスレッド処理に対応しやすいように思える。
結論
比較してみると3番のFiber(タスクキュー)は悪くないように思える。
キュー操作が増えただけ余計な負荷も増えてるじゃんという点について自分を納得させられるかどうかで見方が変化する。そのくらい今時問題ないよと思えたなら、あとは依存関係の単純さ、ロック不要なシングルスレッド的動作、非同期処理への親和性など、設計の素性の良さが見えてくる。