CTOの河合(@neuecc)です。Game Tech Session ~AWS Summit Tokyo 2017~にて「『黒騎士と白の魔王』の gRPC による HTTP/2 API/ストリーミング通信の実践」と題して登壇しました。参加いただいたみなさま、ありがとうございます。
4 月にリリースした「黒騎士と白の魔王」では、iOS/Android のモバイルアプリケーションからの全ての通信を gRPC による HTTP/2 で行っています。API リクエストからストリーミングまで、gRPC のあらゆる機能を使って実現した「黒騎士と白の魔王」のアーキテクチャについて、AWS 上でのスケーリングやデプロイを考慮した構成も含めてご紹介します。
5/10にUnite 2017 Tokyo講演「「黒騎士と白の魔王」にみるC#で統一したサーバー/クライアント開発と現実的なUniRx使いこなし術」として、クライアント側の話をしました。今回はサーバー側にフォーカスしたセッションになっています。
さて、この記事ではP.41に少し書いた、BattleEngineのサーバーサイドゲームループと、その実装について深掘りします。
サーバーサイドゲームループ
リアルタイム通信のサーバーといっても、様々です。クライアントから送られるコマンドを中継するだけのものもあれば、すべての処理をサーバー側で行うものもあります。「黒騎士と白の魔王」のバトルは、全てサーバー側のみで処理しています。なお、その辺の細かい分類は[CEDEC 2010]ネットゲームの裏で何が起こっているのか。ネットワークエンジニアから見た,ゲームデザインの大原則に詳しく、その中で言うと「非同期型/サーバー集中処理型」にあたります。
サーバー側で処理を行うというのは、クライアントからのコマンドは一旦サーバー側で溜めておいて、何らかのタイミングで溜まったコマンドを処理して、クライアントを結果に送信する、という流れになります。サーバー側のクロックとクライアント側のクロックが別軸で動くことになりますが、ゲーム性として、そのズレをどこまで許容できるかがデザイン上の肝となるでしょう。黒騎士の場合は、最悪、数百ms遅れてもそこまで支障はないゲームデザインになっているため、極度なネットワーク遅延以外はユーザーにストレスを与えません。
さて、ここで問題になるのは、何らかのタイミングをどのように発火させればよいか。例えばJavaScriptではsetTimeoutを延々と繰り返すのも、一定間隔になるでしょうし、C#にも勿論Timerはあります。そのようにして構築された一定間隔の発火は、サーバーサイドにおけるゲームループといえるでしょう。
Dedicated Threadによるゲームループのホスト
スライド中でもコードを出しましたが、黒騎士と白の魔王は以下のようなコードでサーバーサイドでのゲームループを回しています。
var thread = GameLoopThreadPool.GetThread(); while (true) { // frameActionの中でAIの行動選択処理や溜まったコマンドをもとにダメージ与えたり回復させたりのコードが動く var shouldContinue = frameAction(this); if (!shouldContinue) break; await thread.NextFrame(); }
コアの数だけ、スレッドを作成し、1スレッドにBattleField(黒騎士と白の魔王での1バトルのホスティング単位で、例えばクエストバトルの場合、最大4人のプレイヤーと敵モンスターが存在する)を複数詰め込みます。
つまり、ゲームループ側のコードでは
// Thraed側の簡易コードイメージ foreach (var field in battleFields) { field.Invoke(); // 中身は while(true) { do(); await thread.NextFrame(); } }
のようになっているわけです。
ポイントになるのは await thread.NextFrame()
で、このawait可能なNextFrameは独自に定義したものです。C#のasync/await構文を活用して、所謂コルーチンを実現しています。
public class GameLoopThread { const int InitialSize = 16; Thread thread; // 生Threadを使う。 int threadNumber = 0; readonly object gate = new object(); int frameMilliseconds; // 二本の配列(縮小しない)をキューとして使うことで // ロックを最小限にすることと配列拡縮のコスト、そしてループ速度を稼ぐ bool isQueueA = true; int queueACount = 0; Action[] queueA = new Action[InitialSize]; int queueBCount = 0; Action[] queueB = new Action[InitialSize]; public GameLoopThread(int frameMilliseconds, int threadNumber) { this.frameMilliseconds = frameMilliseconds; this.thread = new Thread(new ParameterizedThreadStart(RunInThread), 32 * 1024) // maxStackSizeは小さめで構わない { Name = "GameLoopThread" + threadNumber, Priority = ThreadPriority.Normal, IsBackground = true }; this.thread.Start(this); } static void EnsureCapacity(ref Action[] array, int count) { if (count == array.Length) { var newLength = count * 2; var newArray = new Action[newLength]; Array.Copy(array, newArray, array.Length); array = newArray; } } static void RunInThread(object objectSelf) { var self = (GameLoopThread)objectSelf; self.Run(); } void Run() { var sw = Stopwatch.StartNew(); while (true) // 実際のコードでは外から終了できるような口を用意してある { sw.Restart(); Action[] useQueue; int useQueueCount; lock (gate) { if (isQueueA) { useQueue = queueA; useQueueCount = queueACount; queueACount = 0; isQueueA = false; } else { useQueue = queueB; useQueueCount = queueBCount; queueBCount = 0; isQueueA = true; } } for (int i = 0; i < useQueueCount; i++) { useQueue[i].Invoke(); // こいつが例外を吐くことは許さん。というのは呼び元で保証してくれ。 useQueue[i] = null; } useQueue = null; sw.Stop(); if (useQueueCount != 0) { // Datadogに処理時間のモニタリングを入れておく。 var tags = new[] { "gameloopthreadnumber:no-" + threadNumber }; DatadogStats.Default.Gauge("GameLoopThreadRun.avg", sw.Elapsed.TotalMilliseconds, sampleRate: 0.1, tags: tags); DatadogStats.Default.Increment("GameLoopThreadRun.count", sampleRate: 0.1, tags: tags); DatadogStats.Default.Gauge("GameLoopThreadRun.ProcessCount", useQueueCount, sampleRate: 0.1, tags: tags); } // 正確なゲームループを模す場合は、時間の差分を取りながらSleep(1) で処理回転に入るかどうかのチェックいれること // ループを回す間隔の曖昧さを許容する場合は、このままでも問題はない Thread.Sleep(frameMilliseconds); } } void RegisterCompletion(Action continuation) { lock (gate) { if (isQueueA) { EnsureCapacity(ref queueA, queueACount); queueA[queueACount++] = continuation; } else { EnsureCapacity(ref queueB, queueBCount); queueB[queueBCount++] = continuation; } } } // 外から取り出すあれそれ public GameLoopAwaiter NextFrame() { return new GameLoopAwaiter(this); } public struct GameLoopAwaiter : ICriticalNotifyCompletion, INotifyCompletion { GameLoopThread thread; public GameLoopAwaiter(GameLoopThread thread) { this.thread = thread; } public bool IsCompleted => false; public void GetResult() { } public GameLoopAwaiter GetAwaiter() { return this; } // awaitを呼ぶとOnCompletedシリーズが呼ばれて、GameLoopThreadにActionを詰む public void OnCompleted(Action continuation) { thread.RegisterCompletion(continuation); } public void UnsafeOnCompleted(Action continuation) { thread.RegisterCompletion(continuation); } } }
キュー代わりの配列のせいで、ややコードがゴチャゴチャしてしまっていますが、基本的には「専用スレッドを作り」「キューにたまったActionを実行し」「Thread.Sleepして次のループを待つ」。という流れになっています。専用スレッドを使う理由は、まず、超長期(サーバーのライフサイクルと等しい)に実行されるものであることが一つです。このような場合、ThreadPoolが使われるのは向いていません。TaskにもLongRunningオプションがあり、専用スレッド上で動かすこと自体は可能であるが、Taskの機能性が不要であること(ただたんにループ回してSleepするだけ)から、Threadの単純さのほうを取りました。実際に、Threadに名前をつけることにより、アプリケーションのダンプから名寄せできたりなど、デバッグ性もむしろ向上します。
コード自体は単純なものですが、C#ならではのハックとして await thread.NextFrame();
により GameLoopAwaiter
構造体経由で、キューに再登録しています。つまり
while (true) { // GameLoopThread.Runにより定期的に呼ばれる var shouldContinue = frameAction(this); if (!shouldContinue) break; // awaitを呼ぶと一回のInvokeは終了する // GameLoopAwaiter.RegisterCompletion 経由でキューにこのActionを再登録する await thread.NextFrame(); }
という流れになっています。
パフォーマンス
実はリリース当初はこの構成になっていませんでした。なっていなかった結果、かなり不安定でして、それがこのDedicated Threadによるゲームループに変更後はようやく安定しました。
そして、この構成の良いところは、限界が読みやすいところにもあります。1台辺りの性能限界というのは(ほぼ)完全にCPUバウンドで、1スレッドにどれだけの数のBattleFieldをホストできるかで決まります。1フレーム100msで動かすとして、仮に1フィールドの演算処理が1msかかるなら100フィールドまでなら安全。それを、例えば16コアのサーバーで動かすなら16スレッドを並走させるので、1600フィールドということになります。1フィールド辺り4人まで格納可能で平均2.5人(実際の値は違います)ならば4000接続まで。という計算になります。ゲーム性的に、常にキューが溜まって全フィールドがピーク時の演算が走るわけではないので、1ループでの処理時間は均されるのと、たとえ1フレーム2フレーム遅延しても、その後のフレームで処理が間に合えばゲームとしてはユーザーに遅延を感じさせないため、実際はもう少し余裕があります。
また、ここで、1msかかる演算処理を0.5msにできれば、その2倍、3200フィールドホスティングできることになります。サーバーサイドのコードはどうしても甘くなりがち(どうせDBの処理時間のほうが大きいから誤差範囲でやっても意味がない、とか、困ったらサーバーを並べればスケールするでしょ、とか)ですが、「黒騎士と白の魔王」の、このBattleEngineのような純粋なCPU処理で回しているコードの場合は、相当大きな意味を持ってきます。そして、そういう視点で見ると「C#は決して速い言語ではない」し、1~2msぐらい重たくなる処理も簡単に書けてしまいます。
と、いうことを胸に刻んでおくと、疎かになりがちなサーバーサイドのCPU最適化のモチベーションがあがります。
実際の1ループでの処理速度はモニタリング指標として非常に重要なので、グラニではDatadogを用いて常に監視しています。Datadogに関しては黒騎士と白の魔王を支えるDatadogを使ったモニタリングも参照ください。
まとめ
サーバーサイドゲームループはかなり応用が効いて、位置情報を送るようなMMORPGにもできるし、CEDEC講演の資料にもあったようにQuakeのようなFPSでもありでしょう。gRPC、あるいはその高レベルフレームワークであるMagicOnionは、ゲーム的な処理は一切存在しません。一切存在しないが故に、工夫次第でなんでもやれます。その実証を、「黒騎士と白の魔王」のリリースにより、まず第一歩として行えました。
グラニでは引き続きモバイルゲームの開発を行っていくほか、VR Studioも立ち上げ、VR/AR/MRの開発にも乗り出しています。どちらにも欠かせないのがネットワーク技術で、グラニはgRPCを中核にして、今後もノウハウを築き上げていきます。