Grani Engineering Blog

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

メモリダンプから.NETのメモリ状態を探りたい

こんにちは、@mayukiです。

以前、このブログにてダンプ解析入門 - Visual Studioでの可視化によるC#トラブルシューティングというスタックオーバーフローのような問題を調査する方法について触れましたが、今回はダンプを元にメモリ周りの状態を見ていく方法について調べたので少しまとめてみました。

長い時間実行するようなアプリケーション(アプリケーションサーバーなど)ではメモリの使用状況やメモリリークなどを調査したいというケースがたまにやってきます。そんなときにはプロセスのメモリダンプを取得して解析することで問題の原因がわかりそう…そんなシチュエーションで役立つかもしれません。

お品書き

前提

まずダンプを取得する対象のbitness(64bit/32bit)と解析するマシンのbitnessは合わせておきましょう。合致していない場合であっても頑張れば解析できるとは思うのですが今回はそこに関して触れません(調べてないともいう)。

特にAny CPUでビルドしていてもPrefer 32-bitが有効な状態でビルドされている場合、64bitマシン上では32bitで起動するので要注意です。

image.png (10.3 kB)

またダンプファイルの取得の仕方に関しては先のエントリーを参照してください。

追記: 今回の開発環境はVisual Studio 2017 (15.4.1) + .NET Framework 4.7 + Windows 10 Version 1709の組み合わせです。バージョンによっては若干手順が異なる、利用できない場合があります。

ダンプのみどころ

ダンプをとったはいいものの一体何を見ればいいのかを考える必要があります。メモリ周りと言っても単純にメモリの使用状態を見たいのか、メモリリークを調べたいのかで少し違いますが次のようなものをよく見る感じでしょうか。

  • メモリ使用量全般(リーク時も含む)を調べる
    • ヒープの使用状況/オブジェクトの数
      • 何が大きく占めているのか、異常に増え続けていそうなものがあるのか?
      • 各ジェネレーションごとのサイズなど
  • メモリリークを調べる
    • オブジェクトのルート
      • GCで回収できないのは誰がつかんでいるせいなのか?
    • ファイナライザーの状態
      • ファイナライザースレッドがブロックされていないか
        • ファイナライザースレッドがブロックされているとFinalize待ちが溜まる

もっとも、メモリダンプをとってメモリ周りを調べるようなシチュエーションでは大抵はリークを疑っているケースがほとんどかと思います。

どのツールで解析すれば?

さて、ダンプファイルを無事取得でき、見たいところも何となくわかったとしてどうやって調べていけばよいのか…。前回はVisual Studioを利用しましたが今回はそれに2つ加えて、3つの解析ツールをご紹介します。

これら3つともMicrosoftの提供しているツールで、下に進むにつれ詳細な情報が取れるとふんわりと考えてもらえればよいかと思います。今回WinDbgに関してはコマンドがいろいろあるのでその部分が若干多めな解説です。

サードパーティーのツールなどもありますが今回は標準的なツールのみです(最近はdotMemoryもダンプをインポートできるらしいとか)。

Visual Studioを試してみる

さてまずVisual Studioです。メモリダンプをとった後とりあえず開いてみるならVisual Studioではないでしょうか?開発環境であれば特に追加のインストールも不要ですしお手軽ですね。(※ただし Enterprise に限る)

ここでは以下の単にオブジェクトを作るだけのプログラムでダンプをとってみたものを解析してみます。

namespace ConsoleApp18
{
    class Program
    {
        private static List<A> _values = new List<A>();

        static void Main(string[] args)
        {
            for (var i = 0; i < 100000; i++)
            {
                _values.Add(new A() { ValueB = new B() });
            }
            Console.ReadLine(); // ← ここで止まっているときにダンプをとる
        }
    }

    class A
    {
        public B ValueB { get; set; }
    }
    class B
    {
        public int Value { get; set; }
    }
}

というわけで上記プログラムからとったメモリダンプをVisual Studioでひらくと次のような画面が表示されます。

image.png (40.8 kB)

この画面の右側のActionsの"Debug Managed Memory"を選択するとメモリ状態を表示するモードとなります。

image.png (65.5 kB)

これでオブジェクトがどのぐらいあり、どのぐらいのメモリを使っているのかという情報を一覧表示できます。お手軽ですね

一覧の下には "Paths to Root" と "Referenced Types" という二つのビューがあります。

image.png (12.5 kB)

"Paths to Root" は選択したオブジェクトからルートとなるオブジェクトへのツリー、つまり参照元を表示します(最終的にはGC対象となるルート)。上の図の例では Program._values(static変数) → List<A> → A → B という順でたどれるよということになります。この場合 values しかRootがないので values がなくなればGCが回収可能となりますし、逆を言えばここが残っているということは解放できないということでもあります。

image.png (17.4 kB)

"Referenced Types" は選択したオブジェクトが参照しているオブジェクトへのツリー、つまり参照先を表示します(選択したオブジェクトが依存しているオブジェクト)。上記の図ではList<A>はA[]を持ち、A[]はAを、AはBを参照しているということがわかります。

なお、一覧のObject Typeのカラムでアイコンが表示されるのでクリックすることで個々のオブジェクトを探っていくこともできます。 image.png (12.2 kB)

と、Visual Studioで見れば何となくあれが多いんだなとか、これで開放されてなさそうだなといった情報をつかめる場合が多いでしょう。これだけではわからない、Visual Studioでは開けないなど、もう少し突っ込んだ状態を調べたくなったら他のツールを検討します。

メモ: 以前はVisual StudioにSOS拡張をロードできたのですがここ最近はそのようなことができなくなっています

DebugDiagを試してみる

次はDebugDiagを使ってみましょう。DebugDiagはMicrosoftが提供しているダンプ自動解析ツールです。ダンプファイルを突っ込むとまあまあいい感じにレポートを出してくれるという便利ツールです(それだけではないのですが)。無料です。

インストールして DebugDiag 2 Analysis を起動すると以下のような画面が表示されます。

image.png (77.5 kB)

ここで今回はメモリ関連の解析を行いたいので DotNetMemoryAnalysis にチェックを入れ、Add Data Files でダンプファイルを選択し、Start Analysisで解析を開始します。解析が完了するとレポートがInternet Explorer(!)で開かれます。

レポートには以下のようなものが記録されます。

  • ヒープのサイズ等の情報
  • メモリを多く使っているオブジェクトの一覧
  • ファイナライゼーションキューにあるオブジェクトの一覧
  • 怪しそうな項目の警告

image.png (66.3 kB)

image.png (46.0 kB)

あくまでレポートなのでVisual Studioに比べて逆に細かいところは見れないのですが、まとめを出してくれるので大まかに状態の把握をするのには便利ですし、ファイナライゼーションキューの情報なども出せるのはなかなかわるくないのです。

ただこのレポート、MHTML形式なうえに微妙に壊れることがあるのが若干難ありですが読めるのであればあまり気にしないことにしましょう。読めないようなものが出てきた場合には…お疲れさまでした…。また巨大なダンプファイルの解析にはとても時間がかかるので辛抱も必要です(とはいえWinDbgでもかかります)。

おまけ: ハングやクラッシュを調べる

ちなみに解析ルールとしてCrashHangAnalysisというクラッシュやハングを解析するルールもあります。

image.png (28.3 kB)

折角なのでダンプ解析入門 - Visual Studioでの可視化によるC#トラブルシューティングと同じようにスタックオーバーフローを確認してみましょう。

namespace ConsoleApp18
{
    class Program
    {
        static void Main(string[] args)
        {
            HogeHoge(1);
        }
        static void HogeHoge(int s)
        {
            HogeHoge2(s);
        }
        static void HogeHoge2(int s)
        {
            HogeHoge(s);
        }
    }
}

前回のエントリーのコードをx64で実行したところRyuJITは賢いおかげだからなのかStack overflowにならなかったため少し変更しています。これをビルドして実行すると無事死亡です。

その際作られたダンプファイルをDebugDiagに食べさせて、CrashHangAnalysisをかけてみましょう。するとレポートに力強くErrorの文字とProgram.Hogehoge(Int32)stack overflowという結果が表示されます。

image.png (39.9 kB)

レポートにはスレッドのスタックトレースなども出てくるので一発でダメなのがわかりますね。

image.png (101.6 kB)

このあたり、[それVisual Studioでできるよ]と言われるとそうですねという感じなのですが、GUIでポチポチやって明らかにダメなものを発見できるお手軽解析ツールとして入れて置いておいても良いかなと思います。

まあしかしレポートなどが古めかしいの直したいのでオープンソースにしてほしいぞい…。

WinDbgを試してみる

最後はハードコアなデバッガーWinDbgで解析する方法です。いろいろと面倒な代わりに詳細な情報を得られるのが特徴です。そもそも.NET用のデバッガーではないというあたりから難易度の高さを誇り、最低限の操作だけ覚えていく感じです。これも無料です。

最近Preview版として見た目がよくなったバージョンがリリースされたのですが、本質は変わっていないのでぬるま湯マネージコードマンには相変わらずハードコアです。

2017-11-06 (2).png (72.8 kB)

インストール

WinDbgのインストールですが、なんとPreview版ではMicrosoftストアからワンポチでインストールできます。おてがるですごい。

image.png (66.0 kB)

ダンプファイルを開いてまずはSOS

WinDbgを起動したらFileメニューから"Open dump file"で.dmpファイルを選択してダンプファイルを開きます。

2017-11-07.png (58.2 kB)

ダンプファイルを開くと次のような画面になります。中央の "Command" タブの下部にある 0:000> とその横の入力エリアがコマンド入力欄となっていて、WinDbgであれこれ操作するのはここにコマンドを打ち込んで操作を実行していくことになります。

2017-11-07 (2).png (63.7 kB)

ダンプファイルを開いてもそのままではマネージコードのデバッグはできないのでSOS拡張をロードするところから始まります。SOS拡張をロードするには以下のコマンドを実行してください。

!loadby sos clr

この際、LoadLibraryでライブラリが見つからない的なエラーになる場合がありますが、それはbitnessがあっていない、つまり64bitマシンで32bitのダンプを解析しようとしてる場合に出てきます。 読み込むDLLを変えれば対応できるはずですが前提にも書いた通りターゲットアプリケーションを64bitにするほうが手っ取り早いのでそうしましょう。

雑基礎知識

実際のWinDbgを使うと.NET CLRの中身が透けて見えるといっても過言ではない代わりに、それ相応の用語が出てくるので何となく押さえておくとはかどります。

  • ヒープ: なんかオブジェクトがあるところ。スタックじゃないやつ
  • EEClass: 要するに型。コールドな情報
  • MT/MethodTable: 要するに型。ホットな情報
    • 何らかコマンド実行してMTという項目に数字が出てきたらそれはMethodTableのアドレス
    • オブジェクトにはMTへの参照が書かれている
    • MethodTableはEEClassを参照している(MTからEEClassへの参照はセット)
      • 例えばEEClassはジェネリクスのオープン型、MethodTableはクローズ型みたいな
  • mdToken: メタデータのトークン(ハンドル)。これを元にクラスのリファレンスが解決される
    • 普通は用事ない
  • EEほにゃらら: Execution Engine
  • ファイナライゼーションキュー: Finalizer(つまりC#のデストラクタ)を持っていてファイナライザーを呼び出す前のオブジェクトのキュー
  • F-reachable キュー: Finalizerの呼び出しを待っているオブジェクトのキュー(一度死んだやつ)

…とはいえ MT(MethodTable) だけ覚えていれば大体なんとかなります(とは。

WinDbgでの解析のやっていきかた

では実際にWinDbgを利用してメモリの状態を調べてみましょう。

メモリリークの原因を突き止める場合、一体何が多く残っているのか、何が理由で残っているのかといったことを探るのが定石です。その調査として出来そうな手順は以下のような感じでしょうか。

  1. オブジェクトの統計情報を出力する
  2. 怪しそうな型を見つける
    • 例えば数が想定外に多いとか、GCに回収されていることを期待しているにもかかわらず残っているとか…
  3. 型から生きているオブジェクトの一覧を出力する
  4. オブジェクトからGCのルートとなるオブジェクトを探す
  5. 残っているのが正しいかどうかはアプリ次第…
    • 正しければまたやり直し or 別なオブジェクトを疑う
    • 正しくなければ参照を切るなどを考える

実際のところこれだけの情報であればVisual Studioで見るのと大差なくめんどくさいだけといった感じなのですが、コマンドに慣れる意味でも駆使して見てまわってみましょう。ここではVisual Studioでの解析で利用したダンプを利用します。

オブジェクトの統計情報を出力する & 怪しそうな型を見つける

まずは「オブジェクトの統計情報を出力する」です。 !DumpHeap -stat コマンドを使うと、ヒープにあるオブジェクトを型別で数とサイズを調べた結果を少ないほうから並べて出力してくれます。

0:000> !DumpHeap -stat
Statistics:
              MT    Count    TotalSize Class Name
(...✂...snip...✂...)
00007ffc9b5f7070        6        35304 System.Object[]
000001e5823ac0d0       31       132956      Free
00007ffc3e0f6310        5      1966200 ConsoleApp18.A[]
00007ffc3e0f64c8   100000      2400000 ConsoleApp18.B
00007ffc3e0f5af8   100000      2400000 ConsoleApp18.A
Total 200321 objects

一覧を見て怪しそうな型を見つけます。この例だとなんだかCountがやたら多い型 A と B があって怪しそうなのがわかります。

型から生きているオブジェクトの一覧を出力する

次に生きている(参照の残っている)オブジェクトを見繕うために A という型のオブジェクトの一覧を出力します。

!DumpHeap -live -mt <MT addr> というコマンドを利用することで指定した型の生きているオブジェクトの一覧を出力できます。<MT addr> には先ほどの DumpHeap 結果のMT列の値を指定します。先の例では 00007ffc3e0f5af8 となります。

0:000> !DumpHeap -live -mt 00007ffc3e0f5af8
         Address               MT     Size
000001e583e12eb0 00007ffc3e0f5af8       24     
000001e583e12f18 00007ffc3e0f5af8       24     
(...✂...snip...✂...) 
000001e5842c6d68 00007ffc3e0f5af8       24     
000001e5842c6d98 00007ffc3e0f5af8       24     

Statistics:
              MT    Count    TotalSize Class Name
00007ffc3e0f5af8   100000      2400000 ConsoleApp18.A
Total 100000 objects

これで型 Aのオブジェクトのアドレス一覧を得ることができました。

オブジェクトからGCのルートとなるオブジェクトを探す

個々のオブジェクトを見つけることができたら次はそのオブジェクトがどこから参照されているのかを調べます。

オブジェクトのアドレスからルートへの参照を調べるには !GCRoot コマンドを利用します。このコマンドに先ほどの一覧のAddressを渡せばそのオブジェクトの参照元を調べて出力してくれます。

0:000> !GCRoot 000001e583e12eb0
HandleTable:
    000001e5823817d8 (pinned handle)
    -> 000001e593e15a08 System.Object[]
    -> 000001e583e12e70 System.Collections.Generic.List`1[[ConsoleApp18.A, ConsoleApp18]]
    -> 000001e593ef9aa8 ConsoleApp18.A[]
    -> 000001e583e12eb0 ConsoleApp18.A

Found 1 unique roots (run '!GCRoot -all' to see all roots).

この結果では 000001e583e12eb0(A)からのルートへの参照は A[]→List<A>→object[]→pinned handleとなっています。

image.png (17.4 kB)

Visual Studioの表示と比べると object[]→pinned handle という部分が見えていて、これはエントリポイントがなんやかんやで普段見えない何かなのであろうということが想像できます(ちょっと例が悪かったですね)。

もしメソッドの途中のローカル変数などに保持されている場合には以下のような出力になります。

Thread 2e4c4:
    000000d7b20fec20 00007ffc3e2004b7 ConsoleApp18.Program.Main(System.String[]) [c:\Projects\ConsoleApp18\ConsoleApp18\Program.cs @ 21]
        rsi: 
            ->  00000255e4dc2e70 ConsoleApp18.A

WinDbgコマンドいろいろ

WinDbgというかSOS拡張のコマンドはたくさんあるのでメモリ周りでよく使いそうなものをいくつかまとめておきます。

メモ: トラブルシューティング

  • 初回コマンドを実行時に c0000005 Exception in C:\Windows\Microsoft.NET\Framework64\v4.0.30319\sos.dumpheap debugger extension. PC: 00007ffaeab47c11 VA: 0000000000000000 R/W: 0 Parameter: ffffd586`ff42f824 というようなエラーが表示される
    • 初回はエラーが出るものの、再度実行すると何もなかったかのように動くようになります。もしかしたら何か組み合わせがあっていないとかかもしれません(ちゃんと調べていない…)

ヒープのオブジェクト統計情報を取得する: !DumpHeap -stat

ヒープの使用状況、つまり型ごとのオブジェクトの数とその総サイズを取得します。

0:000> !DumpHeap -stat
Statistics:
              MT    Count    TotalSize Class Name
00007ffb11b4bfb8        1           24 System.Collections.Generic.ObjectEqualityComparer`1[[System.Type, mscorlib]]
00007ffb11b4b468        1           24 System.Collections.Generic.GenericEqualityComparer`1[[System.String, mscorlib]]
00007ffb11b49428        1           24 System.Int32
00007ffab4266518        1           24 ConsoleApp15.B
(略)
00007ffb11b469f8      182         7744 System.String
00007ffab4265c50        2        16048 ConsoleApp15.G[]
00007ffab4265bc0     2381       228576 ConsoleApp15.G
00007ffb11b47070        7       355288 System.Object[]
000001c1c47fdf60     2180       498596      Free
00007ffab4265fa0    23814      2286144 ConsoleApp15.F
00007ffab4266120   238141     22861536 ConsoleApp15.E
00007ffab42663a0  2381419    419129744 ConsoleApp15.D
00007ffb11b4bd38     4003   2228320842 System.Byte[]
Total 2652268 objects

ヒープの状況を取得する: !HeapStat or !EEHeap -gc

!HeapStatでは現在のヒープ領域とその中の世代別の使用量、空き容量を取得できます。

0:000> !HeapStat
Heap             Gen0         Gen1         Gen2          LOH
Heap0        26680120     12978040     29711672    253804240
Heap1        24312736     11994384     36392760    262158024
Heap2        30028920     16513736     31330472    269498448
Heap3        28022536     16091536     28290896    269818560
Heap4        24296528     18617496     28643072    250623072
Heap5        27216048     12314440     32227312    262158024
Heap6        25352648     12314264     33517448    260060760
Heap7        24502504     12050624     32706112    269498448
Total       210412040    112874520    252819744   2097619576

Free space:                                                 Percentage
Heap0            3496           72         2792         7896SOH:  0% LOH:  0%
Heap1            2064           72         5824         8024SOH:  0% LOH:  0%
Heap2            2344           72       398856         8248SOH:  0% LOH:  0%
Heap3            2296           72        10952         8312SOH:  0% LOH:  0%
Heap4             504           72         1016         7672SOH:  0% LOH:  0%
Heap5             560           72         2128         8024SOH:  0% LOH:  0%
Heap6             344           72          576         7960SOH:  0% LOH:  0%
Heap7             712           48         3368         8248SOH:  0% LOH:  0%
Total           12320          552       425512        64384

FreeにあるPercentageカラムがくっついていて読みにくいですが SOH が Small Object Heap、LOHが Large Object Heap の使用割合です。

!EEHeap -gc コマンドでは各ヒープのサイズとアドレス、ジェネレーション別のアドレスを確認できます。

0:000> !EEHeap -gc
Number of GC Heaps: 8
------------------------------
Heap 0 (000001c1c4854600)
generation 0 starts at 0x000001c1c8c474b0
generation 1 starts at 0x000001c1c7fe6d38
generation 2 starts at 0x000001c1c6391000
ephemeral segment allocation context: none
         segment             begin         allocated              size
000001c1c6390000  000001c1c6391000  000001c1ca5b8fe8  0x4227fe8(69369832)
Large object heap starts at 0x000001c5c6391000
         segment             begin         allocated              size
000001c5c6390000  000001c5c6391000  000001c5d559ced0  0xf20bed0(253804240)
Heap Size:               Size: 0x13433eb8 (323174072) bytes.
------------------------------
Heap 1 (000001c1c487db90)
generation 0 starts at 0x000001c2491b6448
generation 1 starts at 0x000001c248645f38
generation 2 starts at 0x000001c246391000
ephemeral segment allocation context: none
         segment             begin         allocated              size
000001c246390000  000001c246391000  000001c24a8e5fe8  0x4554fe8(72699880)
Large object heap starts at 0x000001c5d6391000
         segment             begin         allocated              size
000001c5d6390000  000001c5d6391000  000001c5e5d946c8  0xfa036c8(262158024)
Heap Size:               Size: 0x13f586b0 (334857904) bytes.
------------------------------
Heap 2 (000001c1c48a74a0)
generation 0 starts at 0x000001c2c9131b70
generation 1 starts at 0x000001c2c81720a8
generation 2 starts at 0x000001c2c6391000
ephemeral segment allocation context: none
         segment             begin         allocated              size
000001c2c6390000  000001c2c6391000  000001c2cadd4fe8  0x4a43fe8(77873128)
Large object heap starts at 0x000001c5e6391000
         segment             begin         allocated              size
000001c5e6390000  000001c5e6391000  000001c5f62947e0  0xff037e0(267401184)
000001c655240000  000001c655241000  000001c655441070  0x200070(2097264)
Heap Size:               Size: 0x14b47838 (347371576) bytes.
------------------------------
(✂snip✂)
------------------------------
GC Heap Size:            Size: 0x9f5dd1b8 (2673725880) bytes.

ファイナライゼーションキューの情報を取得する: !FinalizeQueue

ファイナライゼーションキューとF-reachable キューの中身を確認できます。

ファイナライゼーションキューはFinalizer(つまりC#のデストラクタ)を持っているオブジェクトのキュー、F-reachable キューはFinalizerの呼び出しを待っているオブジェクトのキューです。それらのキューの状態を各ヒープ領域ごとに取得できます。

なおFinalize可能なオブジェクトというのはC#でデストラクタを実装しているクラスのオブジェクトのことで、参照がなくなるとファイナライゼーションキューからF-reachable Queueに移動してファイナライザーによって処理されます。

0:000> !FinalizeQueue
SyncBlocks to be cleaned up: 0
Free-Threaded Interfaces to be released: 0
MTA Interfaces to be released: 0
STA Interfaces to be released: 0
----------------------------------
------------------------------
Heap 0
generation 0 has 108834 finalizable objects (000001c652f54ba8->000001c6530294b8)
generation 1 has 0 finalizable objects (000001c652f54ba8->000001c652f54ba8)
generation 2 has 100717 finalizable objects (000001c652e90040->000001c652f54ba8)
Ready for finalization 0 objects (000001c6530294b8->000001c6530294b8)
------------------------------
Heap 1
generation 0 has 119727 finalizable objects (000001c6545021c8->000001c6545ebf40)
generation 1 has 0 finalizable objects (000001c6545021c8->000001c6545021c8)
generation 2 has 131121 finalizable objects (000001c654402040->000001c6545021c8)
Ready for finalization 0 objects (000001c6545ebf40->000001c6545ebf40)
------------------------------
Heap 2
generation 0 has 153746 finalizable objects (000001c6547c8048->000001c6548f44d8)
generation 1 has 0 finalizable objects (000001c6547c8048->000001c6547c8048)
generation 2 has 120833 finalizable objects (000001c6546dc040->000001c6547c8048)
Ready for finalization 0 objects (000001c6548f44d8->000001c6548f44d8)
------------------------------
(✂snip✂)
              MT    Count    TotalSize Class Name
00007ffab4266518        1           24 ConsoleApp15.B
00007ffb11b81960        1           32 Microsoft.Win32.SafeHandles.SafeFileMappingHandle
00007ffb11b818d0        1           32 Microsoft.Win32.SafeHandles.SafeViewOfFileHandle
00007ffb11b4c308        1           32 Microsoft.Win32.SafeHandles.SafePEFileHandle
00007ffb11b4d5a8        1           64 System.Threading.ReaderWriterLock
00007ffb11b4a318        2           64 Microsoft.Win32.SafeHandles.SafeFileHandle
00007ffb11b47e50        2          192 System.Threading.Thread
00007ffab4265bc0     2000       192000 ConsoleApp15.G
00007ffab4265fa0    20009      1920864 ConsoleApp15.F
00007ffab4266120   200096     19209216 ConsoleApp15.E
00007ffab42663a0  2000964    352169664 ConsoleApp15.D
Total 2223078 objects

出力の読み方

各ヒープ領域ごとに情報が出力され、最後に統計が出力されます。

  • Ready for finalization
    • ファイナライザーによる実行を待っているオブジェクトの数
  • generation N has <Number> finalizable objects (<from addr>-><to addr>)
    • 各ヒープのジェネレーション NにあるFinalizeメソッドを持つ(デストラクタを持つ)オブジェクトの数
      • このオブジェクトが必要なくなった時点でF-reachable キューにまわされます。ただし、ここにある=参照されていないという意味ではないことに注意が必要です
      • <from addr>, <to addr> はオブジェクトのポインタが含まれている領域のアドレスです(=ヒープの範囲ではない)
        • ポインタの一覧は dd で見れます
          • dd 000001c653df0ab0 000001c653df2c18-8
          • x86は<from addr> <to addr>-4、 x64なら <from addr> <to addr>-8
  • Statistics for all finalizable objects (including all objects ready for finalization):
    • Finalizeメソッドを持つすべてのオブジェクトの種類と数とサイズです

MethodTable(型)から生きているオブジェクトの一覧を取得する: !DumpHeap -live -mt <MT addr>

MethodTableのアドレスを元に現在どこからか参照があるであろう(GCに回収されない)アドレスの一覧を表示します。

以下の例ではMT 00007ffab4265bc0 つまり ConsoleApp15.Gのオブジェクト一覧を出力しています。

0:000> !DumpHeap -live -mt 00007ffab4265bc0
         Address               MT     Size
000001c1c6fbccf8 00007ffab4265bc0       96     
000001c1c6fea618 00007ffab4265bc0       96     
000001c1c7017f38 00007ffab4265bc0       96     
000001c1c7045858 00007ffab4265bc0       96     
000001c1c7073178 00007ffab4265bc0       96     
(略)
000001c54820b9c0 00007ffab4265bc0       96     
000001c5482392e0 00007ffab4265bc0       96     
000001c548266c00 00007ffab4265bc0       96     
000001c548294520 00007ffab4265bc0       96     

Statistics:
              MT    Count    TotalSize Class Name
00007ffab4265bc0     1000        96000 ConsoleApp15.G
Total 1000 objects

この結果から1000オブジェクトが生きていることがわかります。

MethodTable(型)から死んでいるオブジェクトの一覧を取得する: !DumpHeap -dead -mt <MT addr>

MethodTableのアドレスを元に現在参照されていない(=次回のFull GCで回収される)オブジェクトのアドレスの一覧を表示します。

0:000> !DumpHeap -dead -mt 00007ffab4265bc0
         Address               MT     Size
000001c1c7fd5eb8 00007ffab4265bc0       96     
000001c1c80037f0 00007ffab4265bc0       96     
000001c1c8031110 00007ffab4265bc0       96     
000001c1c805ea30 00007ffab4265bc0       96     
000001c1c808c350 00007ffab4265bc0       96     
(略)
000001c54a4e0998 00007ffab4265bc0       96     
000001c54a50e2b8 00007ffab4265bc0       96     
000001c54a53bbd8 00007ffab4265bc0       96     
000001c54a5694f8 00007ffab4265bc0       96     
000001c54a596e18 00007ffab4265bc0       96     

Statistics:
              MT    Count    TotalSize Class Name
00007ffab4265bc0     1381       132576 ConsoleApp15.G
Total 1381 objects

オブジェクトの参照元を探す: !GCRoot <obj addr>

指定したオブジェクトの参照元を出力します。

例えば先ほどの生きているオブジェクトのアドレスの 000001c1c6fbccf8 に対して実行すると以下のような結果が得られます。

0:000> !GCRoot 000001c1c6fbccf8 
Thread 1f058:
*** WARNING: Unable to verify checksum for ConsoleApp15.exe
    0000000470fbeba0 00007ffab437098e ConsoleApp15.Program.Main(System.String[]) [C:\Projects\ConsoleApp15\ConsoleApp15\Program.cs @ 89]
        rdi: 
            ->  000001c2c6db1ff0 ConsoleApp15.G[]
            ->  000001c1c6fbccf8 ConsoleApp15.G

Found 1 unique roots (run '!GCRoot -all' to see all roots).

この結果からオブジェクト 000001c1c6fbccf8 のルートには オブジェクト 0000000470fbeba0 があることがわかります。

一方ですでに参照のなくなったオブジェクトのアドレス 000001c1c7fd5eb8 に対して実行するとルートとなるものがないことがわかります。

0:000> !GCRoot 000001c1c7fd5eb8 
Found 0 unique roots (run '!GCRoot -all' to see all roots).

場合によってはこれからファイナライザーが実行するぞいっとなっているとFinalizerQueueから参照されていることがあります。

0:000> !GCRoot 000001c54a596e18 
Finalizer Queue:
    000001c54a596e18
    -> 000001c54a596e18 ConsoleApp15.G

Warning: These roots are from finalizable objects that are not yet ready for finalization.
This is to handle the case where objects re-register themselves for finalization.
These roots may be false positives.
Found 1 unique roots (run '!GCRoot -all' to see all roots).

型情報を出力する: !DumpMT <MT addr> or !DumpMT -md <MT addr>

MethodTableのアドレスを指定して情報を出力します。-md オプションを付けて呼び出すとメソッド(MethodDescriptor)の一覧が出力されます。

0:000> !DumpMT -md 00007ffb11b49428 
EEClass:         00007ffb11561b80
Module:          00007ffb114b1000
Name:            System.Int32
mdToken:         00000000020000fb
File:            C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
BaseSize:        0x18
ComponentSize:   0x0
Slots in VTable: 56
Number of IFaces in IFaceMap: 5
--------------------------------------
MethodDesc Table
           Entry       MethodDesc    JIT Name
00007ffb118ed018 00007ffb115988b0   NONE System.Int32.ToString()
00007ffb118ed000 00007ffb11598868   NONE System.Int32.Equals(System.Object)
00007ffb118ed028 00007ffb11598898   NONE System.Int32.GetHashCode()
00007ffb1195ce50 00007ffb114b7580 PreJIT System.Object.Finalize()
00007ffb118ecff8 00007ffb11598838   NONE System.Int32.CompareTo(System.Object)
00007ffb118ecff0 00007ffb11598850   NONE System.Int32.CompareTo(Int32)
00007ffb11922858 00007ffb11598880   NONE System.Int32.Equals(Int32)
00007ffb11922838 00007ffb115988d8   NONE System.Int32.ToString(System.IFormatProvider)
00007ffb118ed010 00007ffb115988f0   NONE System.Int32.ToString(System.String, System.IFormatProvider)
00007ffb118ed020 00007ffb11598968   NONE System.Int32.GetTypeCode()
00007ffb11922878 00007ffb11598980   NONE System.Int32.System.IConvertible.ToBoolean(System.IFormatProvider)
00007ffb11922848 00007ffb11598998   NONE System.Int32.System.IConvertible.ToChar(System.IFormatProvider)
00007ffb11922810 00007ffb115989b0   NONE System.Int32.System.IConvertible.ToSByte(System.IFormatProvider)
00007ffb11922818 00007ffb115989c8   NONE System.Int32.System.IConvertible.ToByte(System.IFormatProvider)
00007ffb11922868 00007ffb115989e0   NONE System.Int32.System.IConvertible.ToInt16(System.IFormatProvider)
00007ffb11922820 00007ffb115989f8   NONE System.Int32.System.IConvertible.ToUInt16(System.IFormatProvider)
00007ffb118ed030 00007ffb11598a10   NONE System.Int32.System.IConvertible.ToInt32(System.IFormatProvider)
(略)

型情報を出力する: !DumpClass <EEClass addr>

EEClassのアドレスを指定して情報を出力します。

0:000> !DumpClass 00007ffb114b18c0
Class Name:      System.String
mdToken:         0000000002000073
File:            C:\WINDOWS\Microsoft.Net\assembly\GAC_64\mscorlib\v4.0_4.0.0.0__b77a5c561934e089\mscorlib.dll
Parent Class:    00007ffb114b1968
Module:          00007ffb114b1000
Method Table:    00007ffb11b469f8
Vtable Slots:    1b
Total Method Slots:  1d
Class Attributes:    102101  
Transparency:        Transparent
NumInstanceFields:   2
NumStaticFields:     1
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffb11b49428  400026f        8         System.Int32  1 instance           m_stringLength
00007ffb11b47bb0  4000270        c          System.Char  1 instance           m_firstChar
00007ffb11b469f8  4000274       90        System.String  0   shared           static Empty

ファイナライザースレッドがブロック状態かどうかを調べる

ファイナライザースレッドがブロックした状態になると当然ですがF-reachable queueが一向に減らない状態となり、回収されずにたまっていく一方となります。ではファイナライザースレッドがブロックしているかどうかを調べるにはどうするかというお話です。

まず は !Threads コマンドでファイナライザースレッドを特定します。

0:000> !Threads
ThreadCount:      11
UnstartedThread:  0
BackgroundThread: 9
PendingThread:    0
DeadThread:       0
Hosted Runtime:   no
                                                                                                        Lock  
       ID OSID ThreadOBJ           State GC Mode     GC Alloc Context                  Domain           Count Apt Exception
   0    1 1f058 000001c1c484ea70    2a020 Preemptive  000001C4CA773470:000001C4CA773FD0 000001c1c4824720 1     MTA 
  13    2 50754 000001c1c62dbd60    2b220 Preemptive  0000000000000000:0000000000000000 000001c1c4824720 0     MTA (Finalizer) 
  15    3 5378 000001c651bd8970    2b020 Preemptive  0000000000000000:0000000000000000 000001c1c4824720 0     MTA 
  16    4 1af00 000001c651bdb5f0    21220 Preemptive  0000000000000000:0000000000000000 000001c1c4824720 0     Ukn 
  17    5 424b0 000001c651bdcc50    21220 Preemptive  0000000000000000:0000000000000000 000001c1c4824720 0     Ukn 
  18    6 4c5c0 000001c651bde2b0    21220 Preemptive  0000000000000000:0000000000000000 000001c1c4824720 0     Ukn 
  19    7 24d80 000001c651bdf910    21220 Preemptive  0000000000000000:0000000000000000 000001c1c4824720 0     Ukn 
  20    8 42834 000001c651be0f70    21220 Preemptive  0000000000000000:0000000000000000 000001c1c4824720 0     Ukn 
  21    9 496cc 000001c651bebe50    21220 Preemptive  0000000000000000:0000000000000000 000001c1c4824720 0     Ukn 
  22   10 1d224 000001c651becd10    21220 Preemptive  0000000000000000:0000000000000000 000001c1c4824720 0     Ukn 
  23   11 18248 000001c651bee3e0    21220 Preemptive  0000000000000000:0000000000000000 000001c1c4824720 0     Ukn 

このリストでは2番目にFinalizerと書いてあるのでこれがファイナライザースレッドです。なおスレッドは ~* でも一覧できます(!ThreadsはCLRが知っている範囲の出力になります)。対象となるファイナライザースレッドが見つかったら先頭のIDを確認します。ここでは 13 となっていることがわかります。

スレッドを特定出来たら次にそのスレッドのスタックを見てみましょう。~[ID]k を実行するとネイティブスタックを確認できます。

0:013> ~13k
 # Child-SP          RetAddr           Call Site
00 00000004`71aff518 00007ffb`1f10a966 ntdll!NtWaitForMultipleObjects+0x14
01 00000004`71aff520 00007ffb`13b21b1e KERNELBASE!WaitForMultipleObjectsEx+0x106
02 00000004`71aff820 00007ffb`139def64 clr!FinalizerThread::WaitForFinalizerEvent+0xb6
03 00000004`71aff860 00007ffb`13966751 clr!FinalizerThread::FinalizerThreadWorker+0x54
04 00000004`71aff8a0 00007ffb`139666cc clr!ManagedThreadBase_DispatchInner+0x39
05 00000004`71aff8e0 00007ffb`1396660a clr!ManagedThreadBase_DispatchMiddle+0x6c
06 00000004`71aff9e0 00007ffb`13a628ba clr!ManagedThreadBase_DispatchOuter+0x75
07 00000004`71affa70 00007ffb`13b2222f clr!FinalizerThread::FinalizerThreadStart+0x10a
08 00000004`71affb10 00007ffb`1f711fe4 clr!Thread::intermediateThreadProc+0x86
09 00000004`71affbd0 00007ffb`2227ef91 kernel32!BaseThreadInitThunk+0x14
0a 00000004`71affc00 00000000`00000000 ntdll!RtlUserThreadStart+0x21

正常な場合にはこのようなスタック状態になるかと思います。WaitForFinalizerEventで次の呼び出しシグナルを待っているような感じでしょうか。

次にマネージコード上で無限ループに陥った場合のスタックです。

0:000> ~13k
 # Child-SP          RetAddr           Call Site
00 0000001e`a677f038 00007ffb`1f108dba ntdll!NtDelayExecution+0x14
01 0000001e`a677f040 00007ffb`13965263 KERNELBASE!SleepEx+0x9a
02 0000001e`a677f0e0 00007ffb`139c4dc0 clr!EESleepEx+0x33
03 0000001e`a677f110 00007ffb`139c4c9d clr!Thread::UserSleep+0xa5
04 0000001e`a677f170 00007ffb`1196d32a clr!ThreadNative::Sleep+0xad
05 0000001e`a677f2d0 00007ffa`b434142e mscorlib_ni+0x4bd32a
06 0000001e`a677f300 00007ffb`13965976 0x00007ffa`b434142e
07 0000001e`a677f330 00007ffb`139dfde1 clr!FastCallFinalizeWorker+0x6
08 0000001e`a677f360 00007ffb`139dfd69 clr!FastCallFinalize+0x55
09 0000001e`a677f3b0 00007ffb`139dfc9a clr!MethodTable::CallFinalizer+0xb5
0a 0000001e`a677f400 00007ffb`139dfb55 clr!CallFinalizer+0x5e
0b 0000001e`a677f440 00007ffb`139dee94 clr!FinalizerThread::DoOneFinalization+0x95
0c 0000001e`a677f520 00007ffb`139defcb clr!FinalizerThread::FinalizeAllObjects+0xbf
0d 0000001e`a677f560 00007ffb`13966751 clr!FinalizerThread::FinalizerThreadWorker+0xbb
0e 0000001e`a677f5a0 00007ffb`139666cc clr!ManagedThreadBase_DispatchInner+0x39
0f 0000001e`a677f5e0 00007ffb`1396660a clr!ManagedThreadBase_DispatchMiddle+0x6c
10 0000001e`a677f6e0 00007ffb`13a628ba clr!ManagedThreadBase_DispatchOuter+0x75
11 0000001e`a677f770 00007ffb`13b2222f clr!FinalizerThread::FinalizerThreadStart+0x10a
12 0000001e`a677f810 00007ffb`1f711fe4 clr!Thread::intermediateThreadProc+0x86
13 0000001e`a677f8d0 00007ffb`2227ef91 kernel32!BaseThreadInitThunk+0x14
14 0000001e`a677f900 00000000`00000000 ntdll!RtlUserThreadStart+0x21

FinalizeAllObjectsCallFinalizer という文字列が見え、何やら処理中であることがわかります。

このようにマネージコード実行中であるスレッドに対しては ~[ID]e!clrstack を実行するとCLR上のスタックを確認できます。

0:000> ~13e!clrstack
OS Thread Id: 0x8760 (13)
        Child SP               IP Call Site
0000001ea677f1d8 00007ffb222b04c4 [HelperMethodFrame: 0000001ea677f1d8] System.Threading.Thread.SleepInternal(Int32)
0000001ea677f2d0 00007ffb1196d32a System.Threading.Thread.Sleep(Int32)
0000001ea677f300 00007ffab434142e *** WARNING: Unable to verify checksum for ConsoleApp15.exe
ConsoleApp15.G.Finalize() [C:\\Projects\ConsoleApp15\ConsoleApp15\Program.cs @ 202]
0000001ea677f728 00007ffb13965976 [DebuggerU2MCatchHandlerFrame: 0000001ea677f728] 

スタックから ConsoleApp15.G.Finalize を実行中っぽいことがわかり、ファイナライザーがブロックされている可能性を疑えます(タイミングによっては単に実行中なだけかもしれませんが)。

また他にも同期ロックによるデッドロックなどがある場合にも引っかかります(!SyncBlkコマンドや!DumpHeap -thinlockなどがつかえそう)。

Appendix

WinDbgでもっと簡単に詳しく

WinDbgとSOS拡張でいろいろ見れて便利!と思いきや、静的なフィールドの中身などをカジュアルに見ることはできません。そこでSOSEXというWinDbgの拡張を公開している方がいらっしゃるのでこれをありがたく使わせてもらいます。

.load C:\Path\To\sosex.dll

このSOSEXを使うと以下のようなSOS拡張を強力にしたようなコマンドを使えるようになります。

  • !mk: マネージ、アンマネージが混在したスタックトレースを表示
  • !finq: ファイナライゼーションキュー (Finalizerを持つオブジェクトのキュー)を表示
  • !frq: F-reachable キュー (Finalize呼び出しまちキュー)を表示
  • !mdt: 指定した型やオブジェクトのフィールドを表示(万能)
  • !mlocks: ロックの一覧
  • !strings: 文字列のダンプ

例えば !mdt を使えば指定した型の静的なフィールドの一覧を表示できます。

0:000> !mdt ConsoleApp18.Program
ConsoleApp18.Program
    [s]_values: System.Collections.Generic.List<ConsoleApp18.A>
        AppDomain 'ConsoleApp18.exe' (000001e5823c30c0): 000001e583e12e70[System.Collections.Generic.List`1[[ConsoleApp18.A, ConsoleApp18]]]

他にもあらかじめインデックスを作っておくことで走査を爆速にするとか強い機能があるので使いこなせるとよさそうです(最近知ったのでほとんど使ってなかったのです…)。

ファイナライザーの実行を.NETアプリケーションコードから知りたい

ファイナライザーの実行タイミングを.NETアプリケーションコードから知りたいということもあるかもしれません。そんな時は GC.RegisterForFullGCNotificationGC.WaitForFullGCApproachGC.WaitForFullGCComplete を組み合わせることで検出可能です。

ただしこの手法が有効なのはGCの設定が <gcConcurrent enabled="false" /> の場合に限るので素直にパフォーマンスモニターなどに頼るほうが良いかと思います。

GC.RegisterForFullGCNotification(99, 99);
_thread = new Thread(() =>
{
    while(true)
    {
        var notification = GC.WaitForFullGCApproach();
        if (notification == GCNotificationStatus.Succeeded)
        {
            Console.WriteLine("GC Raised.");
        }

        notification = GC.WaitForFullGCComplete();
        if (notification == GCNotificationStatus.Succeeded)
        {
            Console.WriteLine("GC Completed.");
        }

        Thread.Sleep(500);
    }
});
_thread.Start();

まとめ

.NETアプリケーションを実行していると時に解決がなかなか難しい問題にあたることがあり、そんなときメモリダンプが解決の糸口になる可能性があります。もちろんダンプを眺めただけでは解決しない問題や、問題を発見したとしてもそもそもどうやって修正するのかというのはまた別の話です。

しかしながら解決の難しい問題は一つ一つ原因のようなものに見当をつけつつ追い詰めていく、その為にダンプを調査できるようになっておくのは悪くないと思いますのでお役に立てば幸いです。

String.InternによるUnityでの省メモリ化ハック

CTOの河合(@neuecc)です。常駐メモリは一ミリでも削りたい……!と思いつつも、それなりに富豪に使ってしまっていて削るのに四苦八苦な日々ですが、削れる箇所はテクスチャなどリソース系だけではない。C#側のマネージドなリソースもまた、それなりに確保しているので、削ることは可能なのだ……!というお話です。

ものすごいメモリプロファイラ(PA_ResourceTracker)を使う

まぁ、なにはともあれプロファイリングです。メモリプロファイラで見てみましょう。実際の黒騎士と白の魔王の開発ビルドに流してみると

f:id:neuecc:20171012210606p:plain

うーん、なかなか立派なString確保量。そして大量の「通常攻撃」という同一文字列。スクリーンショットからは削ってしまってますが、右側には誰が参照しているかがわかる表示もついているので、犯人はひと目で分かり、これは、マスタデータに起因するものでした。独自開発のインメモリデータベース内にオブジェクトとして配置しているのですが、同一文字列が設定されたデータが大量にある、と。インメモリデータベースといっても、一行一行がオブジェクトとして独立しているだけなので、気の効いた圧縮などはかかってません。さすがにザルすぎじゃね、と言われるとすみませんというところでもあり、しかしなるほど、こりゃしょうがないね、どうにもならないね。……というのはあんまりなので、それをなんとかするのが後述するString.Internです。

ところで、この見慣れたようで見慣れないメモリプロファイラは、PA_ResourceTrackerというもので、いつものUnity公式が提供しているMemoryProfilerを中国のエンジニアが魔改造したものです。その改造は素晴らしく、そもそも公式のかっこいいタイルマップなんかより、このシンプルなリストビューのほうが100億倍欲しかったものなのですが、と思います。更にスナップショット間のDiffなどもついているので、メモリの増減が容易に確認可能という素敵さ。

IL2CPPでビルドした実機iOSに接続すれば、詳細なマネージドメモリも確認可能(上のスクリーンショットはその手法で取得したものです)。大変素晴らしい。これを使って、現状と、そして改善結果を確認していきましょう。

String.Internについて

String.Internはあまり見慣れないメソッドだと思います。詳細はWikipediaのString interningの解説に譲るとして、多くの言語(PHP, Java, Python, etc...)にも存在する文字列のインターン化に関する.NETのメソッドです。

大雑把にいうと、文字列専用のハッシュテーブルといったところでしょうか。文字列は不変のため、同一の文字列は同一のメモリ領域を使うことで、全体的なメモリ使用量が節約されます。C#では文字列リテラルで記述された文字列は、必ずインターン化されています。unsafeを用いて、アドレスの位置を確認してみましょう。

// IL的にはldstr
var str1 = "foo";
var str2 = "foo";

fixed (void* p1 = str1)
fixed (void* p2 = str2)
{
    // 同一アドレスを指す
    Console.WriteLine((IntPtr)p1); // 46542800
    Console.WriteLine((IntPtr)p2); // 46542800

    // 勿論、true。
    Console.WriteLine(string.IsInterned(str1) != null); // true
    Console.WriteLine(string.IsInterned(str2) != null); // true
}

大昔には String.Empty vs "" のどちらがいいか、などという話もありましたが、インターン化されてるのでメモリ的には別に一緒、むしろldstrとldsfldで命令的には""のほうが有利、でもどうせJITで同じ結果になって一緒なので好みでOK(なお、私は""のほうを好んで使います)が結論です。

では、文字列リテラル以外で生成した場合は、というと

var str3 = Encoding.UTF8.GetString(Encoding.UTF8.GetBytes("foo"));
var str4 = new StringBuilder("f").Append("oo").ToString();

fixed (void* p3 = str3)
fixed (void* p4 = str4)
{
    // str1とも異なるアドレスを指す
    Console.WriteLine((IntPtr)p3); // 47150368
    Console.WriteLine((IntPtr)p4); // 47150432

    // 文字列自体がインターンプールの中に存在しているか、ではtrue。
    Console.WriteLine(string.IsInterned(str3) != null); // true
    Console.WriteLine(string.IsInterned(str4) != null); // true

    // ReferenceEqualsもfalse
    Console.WriteLine(String.ReferenceEquals(str3, str4)); // false
}

同じ短い文字列であっても、異なるアドレス、つまり二重に別のメモリ領域を使っているということになります。では、インターンプールから取得するにはどうすればいいか、というと、簡単です。

// インターンプールから取り出す(or 登録する, GetOrAddみたいなもの)
var str5 = string.Intern(str3);
var str6 = string.Intern(str4);

fixed (void* p5 = str5)
fixed (void* p6 = str6)
{
    // str1と同じアドレス
    Console.WriteLine((IntPtr)p5); // 46542800
    Console.WriteLine((IntPtr)p6); // 46542800

    // ReferenceEqualsもtrue
    Console.WriteLine(String.ReferenceEquals(str5, str6)); // true
}

無事、常駐の同一Stringは同一メモリ領域を使うようになりました。この場合、str3やstr4はGCで回収されます。

なお、インターン化した文字列はずっとメモリ領域を確保し続け、解放する手段はないことに気をつけてください。

MessagePack for C#のIMessagePackSerializationCallbackReceiver

string.Internが効果あるのは分かったが、適用して回るのは面倒くさいというか難しいというか無理というか、システムの根っこでやらないとらちがあきません。黒騎士と白の魔王ではMasterMemoryという独自開発のインメモリデータベースを通しています、が、MasterMemory自体は同じく弊社開発のMessagePack for C#でデシリアライズしたオブジェクトを登録しているだけです。

つまり、システムの根っこはMessagePack for C#のデシリアライザです。というわけで、デシリアライズ時に自動的にインターン化しましょう。ここで便利に使えるのが IMessagePackSerializationCallbackReceiver です。

public class ToaruMaster : IMessagePackSerializationCallbackReceiver
{
    public int Id { get; set; }
    public string Name { get; set; }
    public string Description { get; set; }

    void IMessagePackSerializationCallbackReceiver.OnAfterDeserialize()
    {
        // デシリアライズ後のタイミングでインターン化してしまう
        this.Name = (this.Name != null) ? string.Intern(this.Name) : null;
        this.Description = (this.Description != null) ? string.Intern(this.Description) : null;
    }

    void IMessagePackSerializationCallbackReceiver.OnBeforeSerialize()
    {
        // シリアライズ前に呼ばれる, なにもしない
    }
}

これにより、マスタデータに使われる型の、必要な、任意のプロパティだけインターン化することができました。「インターン化した文字列はずっとメモリ領域を確保し続け、解放する手段はない」わけですが、マスタデータはインメモリデータベースにアプリケーション起動時から終了時まで常駐し続けるので、インターン化は有効な手段です。

なお、IMessagePackSerializationCallbackReceiverの挙動はJsonUtilityISerializationCallbackReceiverと同一です。そのため、もしJsonUtilityを使っている場合で同じようにIntern化したい時も、同様の対応が取れます。

最後に、結果を見てましょう。

f:id:neuecc:20171012210748p:plain

Refsが1438と、見事にまとめられました!「通常攻撃」以外にもまとめられたものは結構あったので(それと他の部分でもインターン化が効率的なところに仕込んだ)、なんのかんのでトータルでは4MBぐらいは減ったっぽい。たった4MB、されど4MB。

まとめ

まず言うと、PA_ResourceTrackerは絶対的にお薦めです。本当に素晴らしい。最近、中国圏のゲームがストアランキングを賑わせていて、確かな実力を感じさせますが、エンジニアリングも素晴らしく、GitHubの中国圏でのみ賑わっているライブラリ類もまた、隠れた宝石といった趣があります。

String.Internは、存在は知っていましたが、使ったことはありませんでした。あまりゲーム以外だと気にするレベルではない、というのが実際ですが、こうした古典的なC#のテクニックも役に立つこともあるでしょう。まぁ、たまに、ですが。たまには。たまたま。

細かいC#の動きに関してのプロファリングは、Microsoft CLRの環境よりもUnityのほうが、豊富なツール郡やIL2CPPの力もあって、把握しやすいように思えます。Unityはより深くC#を追求していく環境としても、かなり面白い環境なのは間違いないので、是非ともエクストリームC#の沼にはまりましょう。

ダンプ解析入門 - 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やソースコードがあればそこを組み合わせてクラッシュ時の箇所に止めてくれるので、一発です。なのでできればそれらも合わせておきたいですが、しかし、実際のクラッシュダンプ解析時にないよ、という状況もままあるでしょう。そんな時のケースも見てみます。

とはいえ勿論、ソースコードがあったほうが圧倒的に解決が捗ります。ダンプを取得する際には、pdbやその時のdll一式も含めてコピーし、手元にはその際のリビジョンに戻したソリューションファイルを開いておき、そのVisual Studio上でダンプの解析を始めると、簡単にダンプとソースコードを突き合わせることができます。一手間ではありますが、可能な限りその状態を作っておくと良いでしょう。

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