6時代のSocketプログラミング最適化のTips、或いはMagicOnionを絡めたメタバース構築のアーキテクチャについて2022-05-11タイトルはここぞとばかりに全盛りにしてみました!今回NATSの.NETクライアント実装としてAlterNatsというライブラリを新しく作成し、公開しました。Cysharp/AlterNats公式の既存クライアントの3倍以上、StackExchange.RedisのPubSubと比較して5倍以上高速であり、通常のPubSubメソッドは全てゼロアロケーションです。そもそもNATSとはなんぞやか、というと、クラウドネイティブなPubSubのミドルウェアです。projectなので、それなりの知名度と実績はあります。PubSubというと、特にC#だとRedisのPubSub機能で行うのが、StackExchange.Redisという実績あるライブラリもあるし、AWSやAzure、GCPがマネージドサービスも用意しているしで、お手軽でいいのですが、盲目的にそれを使うのが良いのか少し疑問に思っていました。RedisはKVS的な使い方がメインであり、PubSubはどちらかというとオマケ機能であるためPubSub専用のモニタリングの欠如PubSub用のクラスタリング対応マネージドサービスでの価格体系のバランスの悪さ(PubSub特化ならメモリはあまりいらない)そもそものパフォーマンスといった点が具体的な懸念です。NATSはPubSub専用に特化されているため、そのためのシステムが豊富に組まれているし、性能も申し分なさそうに思えました。欠点を言えばマネージドサービスが存在しないのがネックですが、純粋なPubSubとしての利用ならば永続化処理について考える必要がないので、ミドルウェアとしては運用しやすい部類にはいるのではないかと思っています。onceの保証のあるメッセージングの対応も可能ですが、そこに対応させるにはストレージが必要になる場合もあります)しかし調べていくうちに懸念となったのが公式クライアントであるnats.netで、あまり使いやすくないのですね。async/awaitにも対応していないし、古くさく、それどころかそもそも.NET的に奇妙に見えるAPIであり、そうなるとパフォーマンスに関しても疑問に思えてくる。Server自体はGoで書かれている)と同じようなコードベースになっている、と。そのためC#的ではない部分が多々あるし、GoとC#ではパフォーマンスを出すための書き方が全く異なるので、あまり良い状況ではなさそう。それならば完全にC#に特化して独自に作ってしまうほうがいいだろうということで、作りました。Coreに特化して、まずは最高速を叩き出せるようにしました。PubSub利用する分には機能面での不足はないはずです。AlterNatsは公式じゃないAlternativeなNATSクライアントという意味です。語感が良いので命名的には結構気に入ってます。StartedAPIは、nats.netがあまりにもC#っぽくなくややこしい、ということを踏まえて、シンプルに、簡単に、C#っぽく書けるように調整しました。Name);Subscribeでhandlerを登録し、Publishでメッセージを飛ばす。C#を用いたハイパフォーマンスなシリアライズも可能な拡張オプションも標準で用意してあります)別のURLへの接続や、認証のための設定などを行うNatsOptions/ConnectOptionsはイミュータブルです。そのため、with式で構築するやり方を取っています。NatsConnection(options);NATSには標準で結果を受け取るプロトコルも用意されています。サーバー間の簡易的なRPCとして使うと便利なところもあるのではないかと思います。usingですぐに破棄してしまっていますが、基本的にはConnectionはシングルトンによる保持を推奨しています。staticな変数に詰めてもいいし、DIでシングルトンとして登録してしまってもいいでしょう。接続は明示的にConnectAsyncすることもできますが、接続されていない場合は自動で接続を開くようにもなっています。コネクションはスレッドセーフで、物理的にも一つのコネクションには一つの接続として繋がり、全てのコマンドは自動的に多重化されます。これにより裏側で自動的にバッチ化された高効率な通信を実現していますが、負荷状況に応じて複数のコネクションを貼った場合が良いケースもあります。AlterNatsではNatsConnectionPoolという複数コネクションを内包したコネクションも用意しています。クライアント側で水平シャーディングを行うためのNatsShardingConnectionもあるため、必要に応じて使い分けることが可能です。内部のロギングはMicrosoft.Extensions.Loggingで管理されています。Hostと統合された形で適切なILoggerFactoryの設定と、シングルトンのサービス登録を行ってくれます。DIでの取り出しは直接NatsConnectionを使わずに、INatsCommandを渡すことで余計な操作(コネクションの切断など)が出来ないようになります。使えるネットワークフレームワークを作っているわけですが、AlterNatsはこれと絡めることで、構成の幅を広げることができると考えています、というかむしろそのために作りました。クライアントにUnity、サーバーにMagicOnionがいるとして、サーバーが一台構成なら、平和です、繋げるだけですもの。開発の最初とかローカルでは楽なのでこの状態でもいいですね。現実的にはサーバーは複数台になるので、そうなると色々なパターンが出てきます。あるのが、ロードバランサーを立ててそれぞれが別々のサーバーに繋がっているものを、更に後ろのPubSubサーバーを通して全サーバーに分配するパターン。これはNode.jsのリアルタイムフレームワークであるSocket.IOのRedisアダプター、それの.NET版であるSignalRのRedisバックプレーン、もちろんMagicOnionにもあるのですが、このパターンはフレームワークでサポートされている場合も多いです。RedisのPubSubでできることはNATSでもできる、ということで、NATSでもできます。これは各サーバーをステートレスにできるのと、スケールしやすいので、Chatなどの実装にはやりやすい。欠点はステートを持ちにくいので、クライアントにステートがあり、データのやり取りをするタイプしか実装できません。サーバー側にステートを持ったゲームロジックは持たせずらいでしょう(ステートそのものは各サーバーで共有できないため)。PubSubを通すことによるオーバーヘッドも気になるところかもしれません。ロードバランサーを立てる場合、ロードバランサーのスティッキーセッションを活用して一台のサーバーに集約させるというパターンもあります(あるいは独自プロトコルでもリバースプロキシーを全面に立てて、カスタムなロジックで後ろの台を決定することもほぼ同様の話です)。色々なユーザーを同一サーバーに集約させたいようなケースでは、そのクッキーの発行誰がやるの、みたいなところは変わらずありますね。そこまで決めれるならIPアドレスを返して直繋ぎさせてしまってもいいんじゃないの?というのも真です。した外側に対象のIPアドレスを教えてくれるサービスがいて、先にそれに問い合わせてから、対象のサーバーへ繋ぎに行くパターンは、古典的ですが安定です。この場合は同一サーバーに繋ぎにいくためにサーバー内にインメモリでフルにステートを持たせることが出来ますし、いわゆるゲームループを中で動かして処理するようなこともできます。画面のないヘッドレスUnityなどをホストして、クライアントそのものをサーバー上で動かすこともできますね。このパターンは素直なようでいて、実際VMだとやりやすいのですが、Kubernetesでやるのは難しかったりします。いうのも、Kubernetesの場合は外部にIPが露出していないため、クラスター内の一台の特定サーバーに繋ぎにいくというのが難しい......!このような場合に最近よく活用されているのがAgonesというGoogleが主導して作っているKubernetesの拡張で、まさにゲーム向きにKubernetesを使えるようにするためのシステムです。これはこれで難点があって、Agonesが想定しているゲームサーバーは1プロセス1ゲームセッション(まさにヘッドレスUnityのような)のホスティングであるため、1つのプロセスに多数のゲームセッションをホストさせるような使い方はそのままだと出来ません。コンテナなので、仮想的なプロセスを複数立ち上げればいいでしょ、というのが思想なのはわからなくもないのですが、現実的には軽量なゲームサーバー(それこそMagicOnionで組んだりする場合)なら、1プロセスに多数のゲームセッションを詰め込めれるし、これをコンテナで分けて立ち上げてしまうとコスト面では大きな差が出てしまいます。Cysharpではステートフルな、特にゲームに向いたC#サーバーを構築するための補助ライブラリとしてLogicLooperというゲームループを公開しています。このライブラリはこないだリリースしたプリコネ!グランドマスターズでも使用していますが、従来MagicOnionと同居して使っていたLogicLooperを、剥がしたアーキテクチャはどうだろうか、という提案があります。(実際のプリコネ!グランドマスターズのアーキテクチャはMagicOnionと同居し、リバースプロキシーを使った方式を採用しているので(↑の画像のものに近い)、この案とは異なります)パーツが増えて複雑になったように見えて、この構成には大きな利点があります。同居しているものがなくなったので複雑になったようで実はシンプルになっています。それぞれがそれぞれの役割にフルに集中できるようになるため、パフォーマンスも良くなり、かつ、性能予測もしやすくなります。ロジックをフルに回転させるLogicLooperがクライアントや接続数の影響を受けずに独立できているのは大きな利点です。ゲーム全体のステートはLogicLooper自体が管理するため、クライアントとの接続を直接受けているMagicOnion自体はステートレスな状態です。そのため、インフラ的にもロードバランサーの下にMagicOnionを並べるだけで済みますし、サーバー間の接続に伴う面倒事は全てNATSに押し付けられるため、インフラ管理自体はかなりシンプルな構成が取れます。MagicOnion自体はステートを持てるシステムであり、各ユーザーそれぞれのステートを持つのは容易です(サーバーを越えなければいい)。そこで、LogicLooperから届いたデータのうち、繋がってるユーザーに届ける必要がないデータは、MagicOnionの持つユーザーのステートを使ってカリング処理をして、そもそも転送しなかったり間引いたりして通信量を削減することで、ユーザーの体験が良くなります。各ユーザーから届くデータを使ったステート更新/データ送信に関しては、LogicLooperがゲームループ状になっているので、ループの間に溜まったデータをもとにしてバッチ処理を行えばいいでしょう。バッチ化というと、通信「回数」の削減のためのコマンドを単純にまとめあげて一斉送信するものと、内容を見て処理内容を縮小するパターンが考えられますが、LogicLooperを使ったアプローチでは後者を効率的に行なえます。前者のコマンドの一斉送信に関しては、AlterNatsが裏側で自動パイプライニング化としてまとめているので(後で詳しく説明します)、そこに関しても効率化されています。このアーキテクチャで気になるのがPubSub通信のオーバーヘッドですが、それに関しての解決策がAlterNatsで、究極的に高速なクライアントがあれば(さすがにインメモリには到底及ばないとはいえ)、そもそものクライアントとサーバーの間にもネットワークがいるわけで、経路のトータルで見れば実用的な範囲に収められる。いう想定で作りました。ところで、そして究極的な利点は、全てC#で組めるということです。いうことかというと、MagicOnionもLogicLooperも汎用的なC#フレームワークです。特別なプラグインを差し込んで処理するというわけではなくて、ふつーのC#コードをふつーに書くことで、それぞれの箇所に、アプリケーション固有のコードを仕込んでいくことができる。これが、本当の大きな利点です。専用のC++ミドルウェアを作って挟んで最適化できるぞ!などといったシステムは、素晴らしいことですが、専門性が高く再現性が低い。MagicOnionとLogicLooper、そしてAlterNatsを活用したこの構成なら、C#エンジニアなら誰でも(容易に)できる構成です。Cysharpのメッセージは「C#の可能性を切り開いていく」ですが、誰もが実現できる世界を作っていくというのが目標でもあります。6からのプロジェクトタイプが適切です。APIC#で最も低レベルにネットワーク処理を扱えるクラスはSocketです。非同期でハイパフォーマンスな処理を求めるならSocketAsyncEventArgsをうまく再利用しながらコールバックを仕込む必要があります。これは非常に厄介で些か難易度も高いのですが、現在はasync/awaitの時代、ちゃんとawaitできる***Asyncメソッド郡が用意されています。使ってはいけないAPI、使ってはいけないオーバーロードも並んでいるので、その選別が必要です。SocketのAPIは歴史的事情もあり混沌としてしまっているのです......。使うべきAPIを分かりやすく見分ける手段があります。cancellationToken))オーバーロードにはTask返しのものもあるので、気をつけてください。いうものがValueTaskの中身になるようになっていて、これがいい感じに使いまわされる(awaitされると内部に戻るようになっている)ことで、Taskのアロケーションもなく効率的な非同期処理を実現しています。SocketAsyncEventArgsの使いにくさとは雲泥の差なので、これは非常にお薦めできます。同期APIはSpanを受け取れるのですが、非同期APIは(ステートをヒープに置く都合上)Memoryしか受け取れないことには注意してください。これはSocketプログラミングに限らず非同期系APIにおける一般的な話で、全体的に上手く組んでおかないと、Spanが使えないことが障壁になることがあります。Memoryで取り回せるようにしておきましょう。テキストプロトコルのバイナリコード判定NATSのプロトコルはテキストプロトコルになっていて、文字列処理で簡単に切り出すことができます。実際これはStreamReaderを使うことで簡単にプロトコルの実装ができます。ReadLineするだけですから。ネットワークに流れるのは(UTF8)バイナリデータであり、文字列化は無駄なオーバーヘッドとなるため、パフォーマンスを求めるなら、バイナリデータのまま処理する必要があります。-ERRなど)によって流れてくるメッセージの種類が判定できます。すればめちゃくちゃ簡単ですが、先にも言った通り文字列変換は意地でも通しません。判定するのは悪くないでしょう。ReadOnlySpan<byte>のSequenceEqualはめちゃくちゃ最適化されていて、長いものであれば必要であればSIMDとかも使って高速に同値判定します。LINQのSequenceEqualとは別物です!しかし、もっと欲張って見てみましょう、プロトコルの識別子はサーバーから送られてくるものは全て4文字以内に収まっています。これはIntに変換しやすい状態です!というわけで、AlterNatsのメッセージ種判定コードはこうなっています。INFO{}これ以上速い判定はできないと思うので、理論上最速ということでいいでしょう。3文字の命令も、直後に必ずスペースや改行が来るので、それを含めた以下のような定数を使って判定に回しています。MemoryMarshal.Read<int>}バイナリプロトコルなら特に何のひねりも必要なく実装できるので、バイナリプロトコルのほうが実装者に優しくて好きです......。自動パイプライニングNATSプロトコルの書き込み、読み込みは全てパイプライン(バッチ)化されています。これはRedisのPipeliningの解説が分かりやすいですが、例えばメッセージを3つ送るのに、一つずつ送って、都度応答を待っていると、送受信における多数の往復がボトルネックになります。メッセージの送信において、AlterNatsは自動でパイプライン化しています。System.Threading.Channelsを用いてメッセージは一度キューに詰め込まれ、書き込み用のループが一斉に取り出してバッチ化します。ネットワーク送信が完了したら、再び送信処理待ち中に溜め込まれたメッセージを一括処理していく、という書き込みループのアプローチを取ることで、最高速の書き込み処理を実現しました。ラウンドトリップタイムの話だけではなく(そもそもNATSの場合はPublish側とSubscribe側が独立しているので応答待ちというのもないのですが)、システムコールの連続した呼び出し回数を削減できるという点でも効果が高いです。.NET最高速ロガーであるZLoggerでも同じアプローチを取っています。一つのオブジェクトに機能を盛るChannelに詰め込む都合上、データを書き込みメッセージオブジェクトに入れてヒープに保持しておく必要があります。書き込み完了まで待つ非同期メソッドのためのPromiseも必要です。connection.PublishAsync(value);こうしたAPIを効率よく実装するために、どうしても確保する必要のある一つのメッセージオブジェクト(内部的にはCommandと命名されている)に、あらゆる機能を同居して詰め込みましょう。dataを保持して、Socketにバイナリデータとして書き込むための役割(ICommand)をまずは持っています。それに加えてIValueTaskSourceであることにより、このオブジェクト自身がValueTaskになります。await時のコールバックとして、書き込みループを阻害しないためにThreadPoolに流す必要があります。生成してキューに詰め込むため、余計なアロケーションがあります。3.0からIThreadPoolWorkItemを実装することで、内部のThreadPoolWorkItemの生成をなくすことができます。最後に、同居させることで必要なオブジェクトが1つになりましたが、その1つをプーリングしてゼロアロケーション化します。オブジェクトプールはConcurrentQueue<T>などを使うと簡単に実装できますが、自分自身をStackのNodeにすることで、配列を確保しないで済むようにしています。Nodeの出し入れに関しては、今回のキャッシュの実装では正確に取り出せる必要性はないため、lockは使わず、マルチスレッドで競合が発生した場合はキャッシュミス扱いにして新規生成するようにしています。これはオブジェクトプーリングにおける性能バランスとしては、良いチョイスだと考えています。ArchitecturePublish/Subscribeするデータは通常、C#の型をJSONやMessagePackなどにシリアライズしたものを流します。この場合、どうしてもbyte[]でやり取りすることが多くなります、例えばStackExchange.RedisのRedisValueの中身は実質byte[]で、送信にせよ受信にせよ、byte[]を生成して保持することになります。これを避けるために、ArrayPoolから出し入れしてごまかしてゼロアロケーションにする、みたいなのはありがちではありますが、それでもコピーのコストが発生していることには代わりありません。ReadにReadOnlySequence<byte>を要求します。C#のSerializeメソッドにはIBufferWriter<byte>を受け取るオーバーロードが用意されています。IBufferWriter<byte>経由でSocketに書き込むために用意しているバッファーにシリアライザが直接アクセスし、書き込みすることで、Socketとシリアライザ間でのbyte[]のコピーをなくします。Read側では、ReadOnlySequence<byte>を要求します。Socketからのデータの受信は断片的な場合も多く、それをバッファのコピーと拡大ではなく、連続した複数のバッファを一塊として扱うことでゼロコピーで処理するために用意されたクラスがReadOnlySequence<T>です。簡単に行えるように設計されたライブラリ」であるSystem.IO.PipelinesのPipeReaderで読み取ったものを扱うのが、よくあるパターンとなります。AlterNatsではPipelinesは使わずに独自の読み取り機構とReadOnlySequence<byte>を使用しました。C#のSerializeメソッドにはIBufferWriter<byte>を受け取るオーバーロードが用意されているため、それを直接渡すことができます。現代的なシリアライザはIBufferWriter<byte>とReadOnlySequence<byte>のサポートは必須です。これらをサポートしていないシリアライザはそれだけで失格です。まとめプロトコルが単純で少ないのでちゃちゃっと作れると思いきや、まあ確かに雑にTcpClientとStreamReader/Writerでやれば秒殺だったのですが、プロトコルって量産部分でしかないので、そこがどんだけ量少なかろうと、基盤の作り込みは相応に必要で、普通に割と時間かかってしまった、のですが結構良い感じに作れたと思います。コード的にも例によって色々な工夫が盛り込まれていますので、是非ソースコードも読んでみてください。クライアント側の実装によってパフォーマンスが大きく違うというのはシリアライザでもよくあり経験したことですが、NATSのパフォーマンスを論じるにあたって、その言語のクライアントは大丈夫ですか?というところがあり、そして、C#は大丈夫ですよ、と言えるものになっていると思います。NATSの活用に関してはこれからやっていくので実例あるんですか?とか言われると知らんがな、というところですが(ところでMagicOnionはこないだのプリコネ!グランドマスターズだけではなく最近特によくあるので、実例めっちゃあります)、これから色々使っていこうかなと思っているので、まぁ是非AlterNatsと共に試してみてください。https://github.com/neuecc/Blog2。