Grani Engineering Blog

株式会社グラニはC#を中心として、ASP.NET、Unity、VR開発を行っています。

ダンプ解析入門 - Visual Studioでの可視化によるC#トラブルシューティング

CTOの河合(@neuecc)です。ある日のこと、アプリケーションが突然死!しょうがないのでサーバーに入ってイベントビューアーを見るとそこには……

image.png (28.3 kB)

何の役にも立たない情報が並んでいるのであった。こんなんじゃ原因も分からず、どう対処もできない!

という時に役に立つのがダンプです。ダンプさえあれば、クラッシュ時の情報が手に入り、遥かに解決に向かいやすくなります。ダンプ解析というとハードコアな印象もありますが、最近はツールが充実していることもあり、やってみると意外とイージーです。幾つかの実例とともに、どのように解析していくかを見ていきましょう。

ダンプを収集する

クラッシュは突然に。やってくるので、まずはアプリケーションクラッシュと共に自動的にダンプが収集されるように設定しましょう。ダンプファイルがなければ話は始まりません。

ここではWindows Error Reporting (WER)を使います。マニュアルはCollecting User-Mode Dumpsにありますが、かいつまむとレジストリに手書きすると自動で収集するようになります。

では設定しましょう。regedit.exeを開き Windows Error Reporting\LocalDumps、LocalDumpsキーが存在しない場合は新規に作ります。そしてDumpCount, DumpFolder, DumpTypeを設定。

image.png (37.7 kB)

DumpCountは10, DumpFolderは適当なところ、DumpTypeはフル(2)にしました。再起動は必要なく、これで設定は完了です。

スタックオーバーフローを解析する

一撃即死の定番といったらスタックオーバーフロー。まずは簡単なコンソールアプリケーションを作りましょう。

namespace ConsoleApp93
{
    class Program
    {
        static void HogeHoge(string s)
        {
            HogeHoge(s);
        }

        static void Main(string[] args)
        {
            HogeHoge("hoge-");
        }
    }
}

image.png (6.1 kB)

なるほど即死。からの先程のDumpFolderには

image.png (5.8 kB)

ちゃんと収集されています。では、こいつの解析をしましょう。Visual Studio 2017を開き、.dmpファイルをドラッグアンドドロップすると、ダンプファイルの概要が出ます。

image.png (59.0 kB)

この時点で「例外情報:スレッドのスタックがいっぱいです」によりスタックオーバーフローが原因だとわかりますが、更にアクションから「混合でデバッグ」、あるいは「マネージのみでデバッグ」をクリックしましょう。

image.png (44.6 kB)

ソースコードレベルで丸わかり。これ以上ないぐらい分かりやすいですね、完璧!と、いうわけで、pdbやソースコードがあればそこを組み合わせてクラッシュ時の箇所に止めてくれるので、一発です。なのでできればそれらも合わせておきたいですが、しかし、実際のクラッシュダンプ解析時にないよ、という状況もままあるでしょう。そんな時のケースも見てみます。

いったん、Releaseビルドし、Program.csは削除しておきましょう。これでVisual Studioがソースコードと突き合せられなくなります。

image.png (50.7 kB)

見つかりません、と。一気に情報量が減りました。この状態で、Visual Studioでのダンプ解析に役立つウィンドウがあり、それらを開いていきます。

一つはスレッドウィンドウ。

image.png (31.9 kB)

実際のアプリケーションでは多数のスレッドが動いているはずで、各スレッドの情報や、切り替えての把握が重要になってきます。

もう一つが呼び出し履歴ウィンドウ。

image.png (33.6 kB)

まぁもうスタックオーバーフローだと一目でわかりますね。これはスレッドウィンドウからのスレッド選択と連動しています。

そして最後に、欠かせない最も強力なものが並列スタックウィンドウ。

image.png (52.1 kB)

グラフ表示で、各スレッドとそのスタックの中身を表示してくれます。今回の例のような単純なものではあってもなくても変わりませんが、実際のアプリケーションでは100を超えるスレッドが動いているわけで、それらが俯瞰して見えるのは非常に強力です!

スタックオーバーフローぐらい別に場所すぐ分かるんじゃないの?と思いきや、特定条件が重なったときだけ発生する、あるプルリクエストをマージしてから発生したのだけれどプルリクエストが巨大でちょっとどこだかわからない、というかそもそもスタックオーバーフローなのかどうかもわからない、とにかく突然死する。といった状況下、なくはないというか普通にあると思います。そんな時、ダンプの取得と解析は助けになってくれるでしょう。一発で原因を特定できます。

CPU100%やデッドロックを解析する

クラッシュのほうがダンプ取得に関してはイージーで、実際に困るケースとしてはCPUが100%に張り付いてしまったりアプリケーションが止まってしまったりといった、ハングするケースのほうが多いでしょう。

自動で収集するには、ダンプの取得自体がハイコストな処理なのでややリスキーです。瞬間的に100%なだけで復帰できるものが、ダンプを取得しようとしたばかりに片っ端からアプリ停止して、より深刻な障害に結びつく可能性があります。特定のタイミングで発生することが分かっている場合(たとえば高負荷時のみヤバくなる、など)なら、手動でタイミング見計らってダンプ取得するのもいいかもしれません。何れにせよ、場合によりマチマチで、ベストな回答というのはありません。

手動でダンプ取得する場合、最も簡単なのがタスクマネージャーから「右クリック→ダンプファイルの作成」です。

実際にASP.NETのデッドロックするシチュエーションを作り、解析してみましょう。ASP.NET MVCのテンプレートプロジェクトを作り、Indexを以下のように書き換えます。

public class HomeController : Controller
{
    async Task<string> GetAsync()
    {
        var str = await new HttpClient().GetStringAsync("http://google.com/");
        return str;
    }

    public ActionResult Index()
    {
        var s = GetAsync().Result;
        return View();
    }
}

このコードはasync/awaitによるデッドロックを引き起こします。なぜ?というとSynchronizationContextが待ち合うからで、詳しくは以前に私が発表した以下のスライドに書いてあります。

このコードを実行すると、無事ブラウザが真っ白で一向にレスポンスを返してくれなくなります。OK、ではダンプを取りましょう!手元で動かしている場合はIIS Expressがホストしているので、IIS Expressのダンプを取ります。

image.png (30.5 kB)

これを開いて、「混合でデバッグ」から並列スタックで見ると……

image.png (88.4 kB)

右下のグラフエクスプローラーで分かる通り、巨大なツリーになっていてハードコアでわけわからないです。これはネイティブコードが混ざっているからで、正直ネイティブ部分はユーザーコードの外で見てもあまり分からないので(真の全体像を知るという点で使えないわけではない)、省きましょう。「マネージドのみでデバッグ」から始めるのが正解。

image.png (119.9 kB)

すっきりしたグラフが得られました。さて、どこを見るべきか、ですが、こういう場合は長いやつが大抵正解、です。

image.png (22.8 kB)

HomeController.Indexから来て、ManualResetEventSlim.Waitで停止してそうな雰囲気があります。これっぽいですね。

image.png (154.9 kB)

このようにウィンドウを4分割で並べておくと、把握しやすくて便利です。

デッドロック、またはそれに近い状況は様々な原因で起こります。特に起きやすいと感じているのは、プール関連。スレッドプールは伸張に時間がかかるため、用意されたプールを使い切ったあたりからアプリケーションのパフォーマンスが急速に低下します。また、コネクションプールも、通常最大数まで使い切ると、空きが出るまでは同期的に待ち(あるいはタイムアウト)、その間は詰まったような動作をします。それらが複合された結果、全体的に動かなくなってデッドロックと化す複合パターンもありえます。

そのようなときでのダンプと、大量の並列動作を俯瞰して見ることが出来る「並列スタックウィンドウ」は、解析にあたって大きな助けとなるでしょう。

まとめ

よりハードコアなダンプについては、Microsoftのサポートエンジニアの方々がdecode:2017で発表したハードコア デバッギング ~ Windows のアプリケーション運用トラブルシューティング実践というセッションならびに資料が大変詳しいです。Dumpの採取方法からWinDbgによる解析、PerfViewによるCPU負荷の解析まで、実にハードコアに網羅されています。

が、しかし、いきなりハードコアにやる必要はないんですね。Visual Studioによる解析がもっともイージーだと思いますし、存外それで解決できるトラブルも少なくないと思います。ハードコアに入るのは、それで解決できなかったときからでよいわけです、まずは簡単なところから入りましょう。

更により高度な解析、もしくは自動化なども試みるなら、プログラマブルなダンプの解析まで進んでもいいかもしれません。

Microsoft/clrmdを使うことで、ダンプファイルをC#で解析することが可能です。例えば先のIIS Expressのダンプをロードするには

using (var target = DataTarget.LoadCrashDump(@"C:\Users\y.kawai\AppData\Local\Temp\iisexpress.DMP"))
{
    var version = target.ClrVersions[0];

    // dacが見つからない場合もある(MicrosoftのSymbolサーバーから落としてきたい……)
    var runtime = version.CreateRuntime();

    // 以下runtimeを使ってあらゆる解析がプログラマブルにできる!
}

コンソールアプリケーションのダンプだとうまく動くのですが、IIS Expressのものだとうまく動かなかったり。昔のドキュメント?にはGetting the Dac from the Symbol Serverとあったのに、現在は完全に削られていて、自前でうまく合わせないといけないようなのですが……。

ともあれ、C#で自由に解析できるなら、あとはやりたい放題です!WinDbgのコマンドを覚えるぐらいなら、C#とLINQでガリガリ弄って、ウォッチウィンドウや、あるいはLINQPadのDumpなどで確認していったほうが、むしろ強力かもしれません。

LINQPadでAWS APIを5倍理解する

こんにちは。インフラ部の齋藤(@sri_1128)です。

今回はAWSのAPIを扱うときにLINQPadを使うと何かと便利です。 という話をしたいと思います。

AWS APIの便利さと複雑さ

普段、Windows環境でAWSのAPIを叩くときには、CUIで扱う場合にはAWS Tools for Windows PowerShell、C#から扱う場合にはAWS SDK for .NETを使います。 Windows環境からでも全く問題なく扱えますし、新サービス等への追従も早く、仕事をする上で欠かせない便利な存在です。いつもお世話になっております。

しかし一方で、APIのRequestに使うクラスやResponseで返ってくるクラスって結構ネストが深かったり複雑で、パッと見だとどう扱うべきなのか分かりづらいデータ構造になっているものが多いと感じます。 EC2インスタンス名の一覧を取得する。のような一見シンプルな操作だけでも意外とレスポンスの中身を掘っていかないとたどり着かないので、 ちょっと何かしたいだけなんだけど意外と苦戦した。という経験を持っている方も少なくないんじゃないでしょうか?

ただ、これはそもそもAWSのサーバーやサービスの状態が細かく扱える便利さ故の複雑性だと思うので、これはもうそんなものと考えるべきなのかな。とは感じます。

とはいえ、複雑なものは複雑なのでどうしたものかなというのはあるのですが、私はLINQPadに非常に助けられています。

LINQPadって?

LINQPadに関しては下記の記事が良くまとまってます。

要は軽量なC#/F#/VB等のエディタ/実行環境なのですが、

  • InteliSense likeな優れた入力補完
  • オブジェクトの構造を綺麗に整形して出力するDumpメソッド
  • NuGetによるパッケージ管理

等、小さいコードを書いてTry&Errorするのに非常に便利な機能を持ったツールです。

無料版だとコア機能がかなり制限されてしまってうーん。。といった感じになってしまうので、 もし継続的に使用するのであれば有料版がオススメです。

個人的にはこの便利さを得られるのであれば、全然悪くない買い物だと思っています。 グラニでは愛用者が多いこともあり、Enterprise License($950.00 - Premium Editionでインストールユーザー数無制限)を購入しています。(ということもあり、本記事でも有料版が前提です)

LINQPadでEC2インスタンスの情報を取得する

では実際にLINQPadを使ってEC2インスタンスの情報を取得してみましょう。

まず、LINQ Padを立ち上げQueryのLanguageをC# Programに変えて下記のようなコードを書きます。 image.png (34.0 kB)

void Main()
{
    // accessKeyIdとsecretAccessKeyは適当なものに変更してください。
    var accessKeyId = "hoge"; 
    var secretAccessKey = "huga";

    var ec2Client = new AmazonEC2Client(accessKeyId, secretAccessKey, RegionEndpoint.APNortheast1);
    var response = ec2Client.DescribeInstances();
    
    response.Dump(); //DescribeInstanceResponseの中身を見る
}

次に、LINQPadの機能の一つであるNuGet Managerを使用し、AWSSDKパッケージをインストールしましょう。

F4を押すとQueryのプロパティが開くので、AddNuGetからNuGet Managerを開きましょう。 開いたら、AWSSDK - Amazon Elastic Compute Cloudのパッケージを検索し、Add To Queryしましょう。 screenshot.751.jpg (358.8 kB)

SDKを入れたらnamespaceをインポートしましょう。 インポートはプロパティでもできますが、コード上で赤くなってるクラス名にカーソルをあて「Ctrl + . 」すると候補が出るので、これを使ったインポートがお手軽でオススメです。 screenshot.752.jpg (77.3 kB)

namespaceがインポートできたらコードを実行する準備は完了です。F5を押し実行してみましょう。 するとDescribeInstancesが実行され、戻り値であるDescribeInstancesReponseクラスの中身がDumpメソッドにより出力されます。

image.png (215.2 kB)

この出力結果から、 DescribeInstancesResponseには何個かプロパティがあって、Reservationsっていうプロパティに何かEC2の情報っぽいのが詰まっていて、それ自体もListになってて、更にInstancesっていうプロパティもListになってて…あーこのプロパティからこの値取れるんだーといった感じでAPIレスポンスの全体像を視覚的に把握することができます。便利ですね。

実際には生のレスポンスを扱わないで整形して使うことがほとんどだと思うので、 各EC2のインスタンスID、プライベートIP、起動状態を配列として取得する。という条件で試してみましょう。

void Main()
{
    var accessKeyId = "hoge";
    var secretAccessKey = "huga";

    var ec2Client = new AmazonEC2Client(accessKeyId, secretAccessKey, RegionEndpoint.APNortheast1);
    var response = ec2Client.DescribeInstances();

    var result = response
    .Reservations
    .SelectMany(x => x.Instances)
    .Select(x => new { x.InstanceId, x.PrivateIpAddress, State = x.State.Name.Value.ToString() })
    .ToArray();

    result.Dump(); 
}

結果としては下記のようになります。 モザイクモザイクしててなんのこっちゃって感じですが、InstanceIdとPrivateIpが表示されています。(短そうなインタンスIDがあるのはご愛嬌)

image.png (34.3 kB)

こんな感じで何か特定の情報を取ってきたい。となったときも、 Dumpの結果からどんな構造になってるか分かるし、わからなくなってもDumpの結果を見ながらコードを書けばいいので、非常に簡単です。 Reservationsから引っ張ってくるのもSelectManyで平坦化する必要があるのも、中々一発で書くのは難しいですが、これならすぐに書けます。

APIリストを確認する

LINQPadの優秀な入力補完機能が利いてくれるため、そのクラスで扱えるメソッド一覧が全て候補して表示されます。 image.png (39.5 kB)

メソッド一覧は実質の扱えるAPIの一覧なので、この候補を見ることにより APIで何ができるのか、どんな値をパラメータとして渡す必要があるかということを把握することができます。 シンプルなものであれば、ドキュメントを読まなくてもこれだけでAPIを叩けるのでこちらも便利です。

まとめ

LINQPad便利です。そして複雑なデータ構造を持っているAWS APIと非常に相性が良いと思います。 AWSの進化は目覚ましく、新しい製品も続々とでてきます。使ったことのないAPIを叩く機会も多いでしょう。 そんな時にLINQPadを使うことで理解の手助けになるのではないでしょうか。

C#でTypeをキーにしたDictionaryのパフォーマンス比較と最速コードの実装

CTOの河合(@neuecc)です。今回はパフォーマンス比較もそうなのですが、どちらかというと、それを具体的な例にして、マイクロベンチマークの測り方の説明をしたいと思っています。その具体的な例、題材なのですが、特に動的コード生成においては、Typeをキーにして生成したデリゲートをキャッシュすることがよくあります。その場合に最速なのはジェネリッククラスを一つ作って、そこに貯めることで

public static class Cache<T>
{
    public static Func<T> cache;
}

最速に取り出すことが出来ます。これはEqualityComparer<T>.Defaultなどでも使われている、覚えておきたいC#テクニックの一つです。とはいえ、常に必ずしもTを元にして取り出せるわけではなく、Typeをキーにした辞書を作って、そこから取り出すケースも多いでしょう。

具体的にはMessagePack for C#では Deserialize<T>(byte[]) の他に Deserialize(Type, byte[]) のAPIもあります。フレームワークに統合する場合、フレームワーク側がTypeをパラメータとして渡してくることが多いため、それに応えるために非ジェネリックのAPIが必要になってきます。

Hashtableの秘密

Typeをキーにして何らかの値を詰めていく場合、グローバルにあらゆるところからリクエストされる可能性があるため、スレッドセーフでなければならず、ConcurrentDictionaryがよく使われます。

static ConcurrentDctionary<Type, Action> delegateCache;

これはlock不要のため、lock + Dictionaryよりも高速に動作します。しかし、.NET標準でこのケースにおいて最速の手法はConcurrentDictionaryではありません。実は非ジェネリックのHashtableが最速です。

MSDNのドキュメントを見てみましょう(msdn/Hashtable Class)、そこにはこう記されています。

Hashtable is thread safe for use by multiple reader threads and a single writing thread. It is thread safe for multi-thread use when only one of the threads perform write (update) operations, which allows for lock-free reads provided that the writers are serialized to the Hashtable.

書き込み時のみlockを使っていれば、読み込み側はスレッドセーフという特性を持っています。これはコード生成用に最初の一回だけデリゲートを生成して、Typeをキーにしてキャッシュしておく。のようなシナリオにぴったりです。削除もないし、書き込みは初回の一回だけで、あとはずっと読み込みしかないので。

BenchmarkDotNet

では、実際にどの程度の違いがあるのか、見ていきましょう。マイクロベンチマークにはBenchmarkDotNetを使いましょう。現状.NET標準のベンチマークフレームワークといってもよく、オレオレベンチマークでやるよりも楽、というよりかは、数字の信頼性がずっと高くなります。マイクロベンチマークを正しくやるのはかなり難しいため(Stopwatchで囲めばOKというものではない!ウォームアップ、GC、JITの影響、メモリ計測、etc…)、ある程度の部分をきちんと吸収してくれる重要な土台になっています。プロジェクトを主導しているのはAndrey Akinshin氏で、現在はJetBrainsでRiderの開発をしているようです。

さて、BenchmarkDotNetは非常に正しく、多機能ではあるのだけれど、いきなり入れただけだとどう使っていいのかさっぱりわからなくて、毎回コピペ元を探してくる羽目になります。ので、ここに基本的なコンフィグを置いておきましょう(新たなるコピペ元として)。

class Program
{
    static void Main(string[] args)
    {
        // Switcherは複数ベンチマークを作りたい場合ベンリ。
        var switcher = new BenchmarkSwitcher(new[]
        {
            typeof(DictionaryBenchmark)
        });

        // 今回は一個だけなのでSwitcherは不要ですが。
        args = new string[] { "0" };
        switcher.Run(args); // 走らせる
    }
}

public class BenchmarkConfig : ManualConfig
{
    public BenchmarkConfig()
    {
        Add(MarkdownExporter.GitHub); // ベンチマーク結果を書く時に出力させとくとベンリ
        Add(MemoryDiagnoser.Default);

        // ShortRunを使うとサクッと終わらせられる、デフォルトだと本気で長いので短めにしとく。
        // ShortRunは LaunchCount=1  TargetCount=3 WarmupCount = 3 のショートカット
        Add(Job.ShortRun);
    }
}

// ベンチマーク本体
[Config(typeof(BenchmarkConfig))]
public class DictionaryBenchmark
{
    ConcurrentDictionary<Type, Action> concurrentDict;
    Dictionary<Type, Action> dict;
    Hashtable hashtable;

    Type key;

    [GlobalSetup]
    public void Setup()
    {
        concurrentDict = new ConcurrentDictionary<Type, Action>();
        dict = new Dictionary<Type, Action>();
        hashtable = new System.Collections.Hashtable();

        // 3000件ぐらいを突っ込んで
        foreach (var item in typeof(int).Assembly.GetTypes())
        {
            concurrentDict.TryAdd(item, () => { });
            dict[item] = () => { };
            hashtable[item] = new Action(() => { });
        }

        // intのlookup速度を競う
        key = typeof(int);
    }

    // 戻り値を返すようにしているのは最適化で消滅しないようにするため
    [Benchmark]
    public Action Dictionary()
    {
        // 今回はマルチスレッド環境下をイメージするのでlockいれます
        lock (dict)
        {
            Action _;
            dict.TryGetValue(key, out _);
            return _;
        }
    }

    [Benchmark]
    public Action Hashtable()
    {
        var _ = hashtable[key];
        return (Action)_;
    }

    // ConcurrentDictionaryを基準にしましょう。
    [Benchmark(Baseline = true)]
    public Action ConcurrentDictionary()
    {
        Action _;
        concurrentDict.TryGetValue(key, out _);
        return _;
    }
}

結果はこうです。

Method Mean Error StdDev Scaled ScaledSD Allocated
Dictionary 40.79 ns 5.815 ns 0.3286 ns 1.31 0.02 0 B
Hashtable 14.97 ns 1.290 ns 0.0729 ns 0.48 0.01 0 B
ConcurrentDictionary 31.18 ns 9.007 ns 0.5089 ns 1.00 0.00 0 B

実際HashtableのほうがConcurrentDictionaryよりも、このようなシナリオでは高速なのです。このことは広く知らてい、るわけではないですが、知っている人は知っています。例えばDapper/SqlMapper.cs#L2989Jil/DeserializeIndirect.cs#L17で、Hashtableが採用されています(そのため.NET CoreではSystem.Collections.NonGenericの参照を要求します)。DapperもJilも、作者はともにStackExchange勤務なので、その縁で知られているテクニックという感じもしますが。私もDapperのコードを見ていたときに気づいて、そこから知りました。

ところでBenchmarkDotNetの見方がそもそもわからない、というのも実際ある。それぞれのカラムの意味は

  Mean      : Arithmetic mean of all measurements
  Error     : Half of 99.9% confidence interval
  StdDev    : Standard deviation of all measurements
  Scaled    : Mean(CurrentBenchmark) / Mean(BaselineBenchmark)
  ScaledSD  : Standard deviation of ratio of distibution of [CurrentBenchmark] and [BaselineBenchmark]
  Gen 0     : GC Generation 0 collects per 1k Operations
  Allocated : Allocated memory per single operation (managed only, inclusive, 1KB = 1024B)
  1 ns      : 1 Nanosecond (0.000000001 sec)

になっています。Meanは算術平均、Errorは信頼区間に関わる数値で、まぁ大雑把にはMeanの小ささがパフォーマンスです。

なお、このベンチマークはシングルスレッドなので、マルチスレッドで動かした場合はlockの影響が大きくなり、lock + Dictionaryのパフォーマンス低下はもう少し大きくなります。残念ながらBenchmarkDotNetはそのままでParallel実行はサポートしていませんが、Parallel.Forで囲うという雑対応で、見てみましょう。算出される数字にはParallel.Forのオーバーヘッド/回数の違いが含まれるため、シングルスレッド時とでは数字の比較はできませんが、同条件での相対的な比較は可能です。

[Config(typeof(BenchmarkConfig))]
public class ParallelBenchmark
{
    ConcurrentDictionary<Type, Action> concurrentDict;
    Dictionary<Type, Action> dict;
    Hashtable hashtable;

    Type key;

    // この辺は一緒です。
    [GlobalSetup]
    public void Setup()
    {
        concurrentDict = new ConcurrentDictionary<Type, Action>();
        dict = new Dictionary<Type, Action>();
        hashtable = new System.Collections.Hashtable();

        foreach (var item in typeof(int).Assembly.GetTypes())
        {
            concurrentDict.TryAdd(item, () => { });
            dict[item] = () => { };
            hashtable[item] = new Action(() => { });
        }

        key = typeof(int);
    }

    // 雑ぃですが。
    [Benchmark]
    public void ParallelDictionary()
    {
        Parallel.For(0, 100, x =>
        {
            lock (dict)
            {
                dict.TryGetValue(key, out _);
            }
        });
    }

    [Benchmark]
    public void ParallelHashtable()
    {
        Parallel.For(0, 100, x =>
        {
            var _ = (Action)hashtable[key];
        });
    }

    [Benchmark(Baseline = true)]
    public void ParallelConcurrentDictionary()
    {
        Parallel.For(0, 100, x =>
        {
            concurrentDict.TryGetValue(key, out _);
        });
    }
}

// Mainのところは2つならべておくとベンリ
static void Main(string[] args)
{
    var switcher = new BenchmarkSwitcher(new[]
    {
        typeof(DictionaryBenchmark),
        typeof(ParallelBenchmark)
    });

    // ↑これで実行時に選択可能
    switcher.Run(args);
}

結果はこうなりました。

Method Mean Error StdDev Scaled ScaledSD Gen 0 Allocated
ParallelDictionary 69.603 us 283.4327 us 16.0145 us 6.54 1.23 0.2035 1027 B
ParallelHashtable 9.343 us 0.5297 us 0.0299 us 0.88 0.00 0.2136 921 B
ParallelConcurrentDictionary 10.637 us 0.4833 us 0.0273 us 1.00 0.00 0.2136 950 B

ブレが多いため、具体的にx倍、みたいなことをこれで断言はできませんが、少なくとも lock + Dictionaryの結果が相対的にかなり悪くなったことが確認できます。

独自の追加専用ハッシュテーブルを作る

よし、ではHashtableを使おう、で完結してしまってもいいのですが、そこから更に先を行ってみましょう。私は先日MicroResolverという最速のDIライブラリを作成したのですが、そこで自作のハッシュテーブルを作ることにより非ジェネリックでのパフォーマンスを向上させることに成功しました。

キーポイントは幾つかあるのですが、そのなかの

var buckets = table[hashCode & tableMaskIndex]; // table size is power of 2, fast lookup

ハッシュテーブル内のバケットの参照方法を除算ではなくビット演算で済ますことで、それなりの成果がありました。Hashtable, Dictionary, ConcurrentDictionary は除算による算出で、良し悪しがあるので一概にビット演算にしたほうがいい、とはいわないのですが、性能向上は見込める気がします。

ついでに、追加側は必ずlockしなければならない、というのも大変なので、lockも内包して、全体的にスレッドセーフで、読み込みはロックフリーで最速、という汎用ハッシュテーブルを作りましょう。シナリオ的にRemoveのサポートもなしで、AddとGetしか用意しません。

internal class ThreadsafeHashTable<TKey, TValue>
{
    Entry[] buckets;
    int size; // only use in writer lock

    readonly object writerLock = new object();
    readonly float loadFactor;
    readonly IEqualityComparer<TKey> comparer;

    public ThreadsafeHashTable()
        : this(EqualityComparer<TKey>.Default)
    {

    }

    public ThreadsafeHashTable(IEqualityComparer<TKey> comaprer)
        : this(4, 0.75f, comaprer)
    {

    }

    public ThreadsafeHashTable(int capacity, float loadFactor = 0.75f)
        : this(capacity, loadFactor, EqualityComparer<TKey>.Default)
    {

    }

    public ThreadsafeHashTable(int capacity, float loadFactor, IEqualityComparer<TKey> comparer)
    {
        var tableSize = CalculateCapacity(capacity, loadFactor);
        this.buckets = new Entry[tableSize];
        this.loadFactor = loadFactor;
        this.comparer = comparer;
    }

    public bool TryAdd(TKey key, Func<TKey, TValue> valueFactory)
    {
        TValue _;
        return TryAddInternal(key, valueFactory, out _);
    }

    bool TryAddInternal(TKey key, Func<TKey, TValue> valueFactory, out TValue resultingValue)
    {
        lock (writerLock)
        {
            var nextCapacity = CalculateCapacity(size + 1, loadFactor);

            if (buckets.Length < nextCapacity)
            {
                // rehash
                var nextBucket = new Entry[nextCapacity];
                for (int i = 0; i < buckets.Length; i++)
                {
                    var e = buckets[i];
                    while (e != null)
                    {
                        var newEntry = new Entry { Key = e.Key, Value = e.Value, Hash = e.Hash };
                        AddToBuckets(nextBucket, key, newEntry, null, out resultingValue);
                        e = e.Next;
                    }
                }

                // add entry(if failed to add, only do resize)
                var successAdd = AddToBuckets(nextBucket, key, null, valueFactory, out resultingValue);

                // replace field(threadsafe for read)
                System.Threading.Volatile.Write(ref buckets, nextBucket);


                if (successAdd) size++;
                return successAdd;
            }
            else
            {
                // add entry(insert last is thread safe for read)
                var successAdd = AddToBuckets(buckets, key, null, valueFactory, out resultingValue);
                if (successAdd) size++;
                return successAdd;
            }
        }
    }

    bool AddToBuckets(Entry[] buckets, TKey newKey, Entry newEntryOrNull, Func<TKey, TValue> valueFactory, out TValue resultingValue)
    {
        var h = (newEntryOrNull != null) ? newEntryOrNull.Hash : comparer.GetHashCode(newKey);
        if (buckets[h & (buckets.Length - 1)] == null)
        {
            if (newEntryOrNull != null)
            {
                resultingValue = newEntryOrNull.Value;
                System.Threading.Volatile.Write(ref buckets[h & (buckets.Length - 1)], newEntryOrNull);
            }
            else
            {
                resultingValue = valueFactory(newKey);
                System.Threading.Volatile.Write(ref buckets[h & (buckets.Length - 1)], new Entry { Key = newKey, Value = resultingValue, Hash = h });
            }
        }
        else
        {
            var searchLastEntry = buckets[h & (buckets.Length - 1)];
            while (true)
            {
                if (comparer.Equals(searchLastEntry.Key, newKey))
                {
                    resultingValue = searchLastEntry.Value;
                    return false;
                }

                if (searchLastEntry.Next == null)
                {
                    if (newEntryOrNull != null)
                    {
                        resultingValue = newEntryOrNull.Value;
                        System.Threading.Volatile.Write(ref searchLastEntry.Next, newEntryOrNull);
                    }
                    else
                    {
                        resultingValue = valueFactory(newKey);
                        System.Threading.Volatile.Write(ref searchLastEntry.Next, new Entry { Key = newKey, Value = resultingValue, Hash = h });
                    }
                    break;
                }
                searchLastEntry = searchLastEntry.Next;
            }
        }

        return true;
    }

    public bool TryGetValue(TKey key, out TValue value)
    {
        var table = buckets;
        var hash = comparer.GetHashCode(key);
        var entry = table[hash & table.Length - 1];

        if (entry == null) goto NOT_FOUND;

        if (comparer.Equals(entry.Key, key))
        {
            value = entry.Value;
            return true;
        }

        var next = entry.Next;
        while (next != null)
        {
            if (comparer.Equals(next.Key, key))
            {
                value = next.Value;
                return true;
            }
            next = next.Next;
        }

        NOT_FOUND:
        value = default(TValue);
        return false;
    }

    public TValue GetOrAdd(TKey key, Func<TKey, TValue> valueFactory)
    {
        TValue v;
        if (TryGetValue(key, out v))
        {
            return v;
        }

        TryAddInternal(key, valueFactory, out v);
        return v;
    }

    static int CalculateCapacity(int collectionSize, float loadFactor)
    {
        var initialCapacity = (int)(((float)collectionSize) / loadFactor);
        var capacity = 1;
        while (capacity < initialCapacity)
        {
            capacity <<= 1;
        }

        if (capacity < 8)
        {
            return 8;
        }

        return capacity;
    }

    class Entry
    {
        public TKey Key;
        public TValue Value;
        public int Hash;
        public Entry Next;

        // debug only
        public override string ToString()
        {
            return Key + "(" + Count() + ")";
        }

        int Count()
        {
            var count = 1;
            var n = this;
            while (n.Next != null)
            {
                count++;
                n = n.Next;
            }
            return count;
        }
    }
}

長い!ので要点だけ摘むと、APIは TryAdd, TryGetValue, GetOrAdd の3つのみ。追加と読み込みは出来るけど削除は不可。リード側がスレッドセーフで、ライト側はlock内でやるようになってます。この実装でOKの理由は、ハッシュテーブルの実体が Entry[] bucketsのフィールド一個だけに収めてあるからで、もし読み込み中に追加があっても、削除がないので、内部の連結リストの末尾に追加されるだけなら問題ない。追加時にキャパシティを超えてリハッシュが発生する際にも、全てを新規に生成してEntry[]だけ差し替えればいいので、読み込み中の参照には全く影響しない。

というようになってます。良さそうじゃん?というわけで、計測しましょう。

// ベンチマークに追加
[Benchmark]
public Action ThreadsafeHashTable()
{
    Action _;
    threadsafeHashTable.TryGetValue(key, out _);
    return _;
}
Method Mean Error StdDev Scaled ScaledSD Allocated
Dictionary 42.24 ns 2.043 ns 0.1154 ns 1.33 0.02 0 B
Hashtable 14.94 ns 3.522 ns 0.1990 ns 0.47 0.01 0 B
ConcurrentDictionary 31.70 ns 8.067 ns 0.4558 ns 1.00 0.00 0 B
ThreadsafeHashTable 15.20 ns 2.506 ns 0.1416 ns 0.48 0.01 0 B

なるほど、ぶっちけほとんど変わらなかった……!誤差範囲でむしろ遅い!おうふ、わざわざ自作するというリスクを犯してまでやることなのだろうか、これが……。それではあんまりなので、更にブーストさせましょう。KeyはTypeで固定します。

// TKeyはTypeで固定する
internal class ThreadsafeTypeKeyHashTable<TValue>
{
    // これが
    // var hash = comparer.GetHashCode(key);

    // こうなる
    var hash = key.GetHashCode();

    // これが
    // if (comparer.Equals(entry.Key, key))

    // こうなる
    if (entry.Key == key)
}

// ベンチマークに追加
[Benchmark]
public Action ThreadsafeTypekeyHashTable()
{
    Action _;
    typekeyHashtable.TryGetValue(key, out _);
    return _;
}

結果はこうです。

Method Mean Error StdDev Scaled ScaledSD Allocated
Dictionary 41.705 ns 6.6646 ns 0.3766 ns 1.31 0.02 0 B
Hashtable 15.018 ns 3.0491 ns 0.1723 ns 0.47 0.01 0 B
ConcurrentDictionary 31.962 ns 10.0487 ns 0.5678 ns 1.00 0.00 0 B
ThreadsafeHashTable 14.973 ns 5.0342 ns 0.2844 ns 0.47 0.01 0 B
ThreadsafeTypekeyHashTable 8.507 ns 0.0741 ns 0.0042 ns 0.27 0.00 0 B

圧倒的改善……!2倍弱高速化されています。差分は、 EqualityComparer<Type> 経由でのGetHashCodeとEqualsを直呼び出しに変えたことです。全体的に余計なコードがほとんどない状態なので、たったそれだけで大きな変化が見込めた、ということですね。

Performance Improvements in .NET Coreにて.NET Core 2.0では2倍速くなっている~などの例が挙げられていますが、それにならえば、素朴にConcurrentDictionaryを使うことと比べて4倍の高速化が果たせています。

最後に、そもそもCache<T>.cache が最速ってことなので、実際にどの程度最速なのか見てみましょう。

Method Mean Error StdDev Scaled Allocated
Dictionary 41.9966 ns 9.2133 ns 0.5206 ns 1.28 0 B
Hashtable 14.6902 ns 5.3250 ns 0.3009 ns 0.45 0 B
ConcurrentDictionary 32.9257 ns 0.6586 ns 0.0372 ns 1.00 0 B
CacheFieldGet 0.2138 ns 0.4509 ns 0.0255 ns 0.01 0 B
ThreadsafeHashTable 14.9698 ns 4.2571 ns 0.2405 ns 0.45 0 B
ThreadsafeTypekeyHashTable 8.3876 ns 0.9811 ns 0.0554 ns 0.25 0 B

さすがに圧倒的。

まとめ

DictionaryのルックアップはO(1)でタダだと思いがちですが、そんなことはなく違いが出るものだ、というのは気に留めておくと良さそうです。特にStringがキーの場合(最も使い道としてよくあるケース!)なんて、StringのGetHashCodeEqualsは、全文字列を舐めて算出するわけで、別に全然タダではありません。ならばどうすれば良いか、というと、使うしかないわけですが、ちょっと中身について思いを馳せて見ると、コードに対して違った印象で見つめることができるのではないでしょうか。

今回やってるのはかなりミクロな差を追求してるので、そこは留意してください。EqualityComparer<T>経由で呼ばなくする程度で2倍の差がつくってことは、つまりそれぐらいに僅差の違いでしかないので、ConcurrentDictionaryでも別に全然遅くありませんし、わざわざそのために非ジェネリックのHashtable使って気を使ったコードを書く必要もあまりありません。

とはいえ、僅かな差なのだし意味がない、そんなところをみるのはC#的ではない(パフォーマンスを追求するならそもそもC#でなくていい)、という意見には反対です。こうした一つ一つの積み重ねは間違いなく意味を持ちますし、他の言語と戦う際の土台でもあると思っています。

MessagePack for C#に見るC#でのバイナリの読み方と最適化法

CTOの河合(@neuecc)です。今回は、2017年3月3日に行った社内での資料を公開します。

グラニでは、そして最新作の黒騎士と白の魔王では、私の開発したMessagePack for C#を全面的に採用しています。採用範囲は、クライアントとサーバー間でのリクエスト/レスポンス通信の他に、サーバーサイドでもRedisへのシリアライズ等に利用しています。

MessagePack for C#は、グラニでの豊富なシリアライザへの知見に基づき開発された、パフォーマンスと機能拡張性の両面において優れた、C#でのバイナリシリアライザではベストといえる仕上がりになっています。そして、既に実プロダクトで動いているので、プロダクション環境で安心して使えるという実績も備えています。

C#においてバイナリの読み書きは基本的にBinaryReader/Writerを用いて行いますが、より変則的な、あるいはパフォーマンスへの最適化を考えていく場合、もう少し低レベルに降りていくのもよいでしょう。資料では、C#における低レベルな手法について幾つか挙げています。また、MessagePack for C#の実装において行った泥臭い最適化の小ネタ(LookupTableから引いて判定を抑える、デリゲートではなくあえての愚直なインスタンスメソッド、構造体の配列など)も、具体的な使用例から挙げています。

まとめ

MessagePack for C#の真の誕生理由はUnite 2017 Tokyo講演「「黒騎士と白の魔王」にみるC#で統一したサーバー/クライアント開発と現実的なUniRx使いこなし術」の資料にて解説しましたが、単純に言って追い詰められていたからです……!パフォーマンス問題を裏口突破でなんとかするという奇策。人間、追い詰められると良い仕事をしますね……!というのはともかく、機能面でも黒騎士と白の魔王におけるMessagePack-CSharpのUnionの活用事例のように、よりよく活用できているので、結果として開発したのは正解でした。

グラニでは社内勉強会を頻繁に、でもないですが不定期定期に開催しています。今回のように、社外に出しても問題がないものは公開していこうと考えていますので、今後も是非お楽しみに。

リアルタイム通信におけるC# - async-awaitによるサーバーサイドゲームループ

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]ネットゲームの裏で何が起こっているのか。ネットワークエンジニアから見た,ゲームデザインの大原則に詳しく、その中で言うと「非同期型/サーバー集中処理型」にあたります。

f:id:neuecc:20170602173641p:plain

サーバー側で処理を行うというのは、クライアントからのコマンドは一旦サーバー側で溜めておいて、何らかのタイミングで溜まったコマンドを処理して、クライアントを結果に送信する、という流れになります。サーバー側のクロックとクライアント側のクロックが別軸で動くことになりますが、ゲーム性として、そのズレをどこまで許容できるかがデザイン上の肝となるでしょう。黒騎士の場合は、最悪、数百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人のプレイヤーと敵モンスターが存在する)を複数詰め込みます。

f:id:neuecc:20170602173618p:plain

つまり、ゲームループ側のコードでは

// 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を中核にして、今後もノウハウを築き上げていきます。

Prime31 Ver2.17以降のStoreKitプラグインを利用したiOS課金実装(レシートの取扱について)

 はじめまして!アプリケーション開発部の長内です。 突然ですが、UnityでiOSアプリ内課金を実装するとしたら何を利用しますか? Unity5.3以降であればUnity IAPを検討する方もいるでしょう。

 Unity IAPであれば、iOS App Store や GooglePlay だけでなく Windows Store や Amazon Appstore といった様々なストアなどに対応しており、さらにプラットフォーム毎の固有実装を気にせず共通のインターフェイスでゲーム側に組み込めるのも魅力的です。

実際は?

 しかし、現状の殆どの場合はUnityアセットストアで手に入るプラグインを利用する方が多いかと思います。

 そのアセットストアでiOSのアプリ内課金といえば、Prime31 さんからリリースされていますiOS StoreKit In App Purchase Pluginではないでしょうか。

 このアセットは、Unityがまだ3.2、3.3のバージョン頃からあらゆるスタジオなどでも採用され続けている古株のアセットです。

 やはり、昔から利用されているだけあってGoogleでUnityによるiOSアプリ内課金を検索すると候補として上がる程です。

見えないユーザーとの売買

 脱線したお話になりますが、通常レシート(以下領収書データ)は、プラットフォームストア(ここではGooglePlayStoreやiTunesStoreを指します)からユーザーの端末に直接送信されます。

 この時には既に決済は完了しているので、ゲームとしては対応する商品を付与してしまっても、売買のやり取りとしては成立しているのでOKです。

リアル環境なら

 では、簡単なシーケンス図で表してみましょう。

f:id:yosagrapon:20170525153637p:plain

 これが、現実世界でコンビニやスーパーマーケットなどであれば全く問題はありませんね、直接お客様とお店との取引をしていますので、これで成立します。

領収書をもらったことにしよう!

 しかし、このやり取りが電子世界になった途端、そうではいけなくなってしまいます。例えば次のパターンの時はどうでしょうか。

f:id:yosagrapon:20170525153319p:plain

 マズイです、実にマズイです。お客様はなんの売買取引をしていないのに、ゲームサーバーに領収書をもらったとしか言っていないだけでゲームサーバーは言われたアイテムを付与してしまっています。

貰ったことを言うのではなく、その物を渡しましょう

 では、領収書をもらったと言うのではなく、領収書そのものを渡しましょう。

f:id:yosagrapon:20170525153323p:plain

 お客様はちゃんと売買取引を行い、プラットフォームストアから受け取った領収書を渡しているので、ゲームサーバーとしても領収書を受け取っているのなら問題はなさそうです。

はたしてそれは本物?

 しかし、お客様の中でとてつもなくプラットフォームストアの領収書データに精通し有りもしない領収書データを作れた場合はどうなるでしょうか?

f:id:yosagrapon:20170525153327p:plain

 ダメです。実にダメです。結局、お客様は売買取引をせず偽物の領収書データを作って、あたかも売買取引したかのように振る舞った領収書をゲームサーバーに送りつけゲームサーバーは領収書があるから大丈夫と思い、アイテムを付与してしまいました。

本人確認は重要ですね

 では、この問題に対してどう対処するかというと、至極単純です。その領収書を発行した本人に確認を取れば良いのです。あらゆるサイトでも紹介されていてデファクトスタンダードな実装の簡易シーケンスを見てみましょう

f:id:yosagrapon:20170525153925p:plain

 本来はもっと複雑なフローなのですが、簡略的に書いた場合はだいたいこうなります。

また、これが本題では無いのでまたいつかということでおひとつ。

本題

 では、そんな昔からあるアセットの実装方法を今更何を書くのかというと、レシートの取り扱い方が変わったことについてです。

 Prime31さん公式サイトのあらゆるアセットのリリースノートに以下のような事が書かれています。

iOS StoreKit09-23-2016

Version 2.17

  • rebuilt against iOS 10 SDK

  • removed base64 encoded receipt as it has been deprecated by Apple since iOS 7

  • bug fix: avoid restoring the same transactions multiple times even if Apple sends them over and over again

このリリースノートでもっとも重要な内容は

removed base64 encoded receipt as it has been deprecated by Apple since iOS 7

の部分です。

 そうです。この base64 encoded receipt がiTunesStoreから送られてくるレシートそのものなのです。

これが削除されたということは、正しいレシートをゲームサーバーに送ることが出来ませんし Unityマニュアルでもこんな(base64エンコードされたレシートを送信)ことを書いているので削除されたら何も出来ません と見せかけて、そんなことはありませんでした。

 そのかわりのAPIとして

public static string getAppStoreReceiptLocation()

が増えましたので、こちらを使います。

 この関数は、iTunesStoreから受け取ったレシートデータをローカルストレージに保存した"生のレシートデータ"へのファイルパスが返されます。

 なので、このままファイルを読み込んでそのまま送ったとしても結局不正なレシートとして扱われてしまいますのでBase64エンコードから送信までこちらで実装します。

/// <summary>
/// StoreKitManager の purchaseSuccessfulEvent イベントから監視オブジェクトとして使います
/// </summary>
/// <returns>purchaseSuccessfulEvent イベントの監視オブジェクトを返します</returns>
private static IObservable<StoreKitTransaction> PurchaseSuccessfulEventAsObservable()
{
    // event機構からObservableへ
    return Observable.FromEvent<StoreKitTransaction>(x => StoreKitManager.purchaseSuccessfulEvent += x, x => StoreKitManager.purchaseSuccessfulEvent -= x);
}

グラニでは、UniRxを使った実装をしていますので、StoreKitライブラリのpurchaseSuccessfulEventを監視するようにします。

var successObservable = PurchaseSuccessfulEventAsObservable().First().PublishLast().RefCount()
    .SelectMany(transaction =>
    {
        // レシートデータを読み込んでBase64エンコードする
        var receiptUri = new Uri(StoreKitBinding.getAppStoreReceiptLocation());
        var rawReceipt = File.ReadAllBytes(receiptUri.LocalPath);
        var base64Receipt = Convert.ToBase64String(rawReceipt);


        // レシートを生成して検証する
        var receipt = new PlatformStoreReceipt(storeItem, base64Receipt, string.Empty, userOption);
        return ValidateReceiptAsync(receipt);
    })

 そうです。ここで実装している"base64Receipt"こそが、2.17より前のバージョンで存在していた"base64EncodedTransactionReceipt"の写し身です。

 あとは、ゲームサーバーとゲームクライアントの検証結果を元にどうするかという実装を、ゲーム側仕様に則って実装します。

 以上で、まさかの本題より前書きのほうが長い(尺稼ぎ)記事になってしまいましたが、iOS10であるとか新しいバージョンにしなければいけなくなったが突如APIインターフェイスが変わった! と慌てず、まずは今回の実装を参考に実装を修正してみて下さい。殆どのケースでは上手くいきます。(絶対にとは言っていない)