Re: C#(Unity)でHTTP/3通信してみる その壱 ~OSSの選定からビルドまで~ では quiche の .lib/.dll を作成する方法を紹介しました。
続く Re: C#(Unity)でHTTP/3通信してみる その弐 ~Windowsでquicheのサンプルを動かしてみる~ では Windows 環境で quiche のサンプルを使って HTTP/3 通信の実装を試してみました。
今回はいよいよ最終回、 C# (Unity) から HTTP/3 通信してみるところまでを解説しようと思います。
本題に入る前に …… http3sharp について
今回の記事で紹介している実装及び Unity サンプルを http3sharp (Ver: 0.1.0) として以下のリポジトリにて公開しました。
https://github.com/TakeharaR/http3sharp
CloudFlare の QUIC,HTTP/3 ライブラリ quiche のラッパ層である qwfs と、この qwfs を用いた Unity 向け HTTP/3 クライアントライブラリである http3sharp から成ります。
実験的実装なのでエラー処理などの例外系がかなり甘いですが、 Unity で HTTP/3 通信試してみたいぜ、という方は是非触ってみてください。
当記事における解説の前提条件
- 今回の記事では以下の点について重点的に説明します
- HTTP/3,QUIC ライブラリである quiche の組み込み方法・注意事項
- HTTP/3,QUIC 通信を行うライブラリ実装時の注意事項
- また、以下の内容については紹介しない、または軽く触れるに留めます
- Unity からの Native ライブラリの使い方、及びマーシャリング
- 最適化 (http3sharp の実装も現状最適化は施していません)
本記事の流れ
以下の流れで紹介します。
- quiche アドバンスド (その弐 で触れなかった quiche の設定や関数、その他補足に関する内容)
- 実装方針についての紹介
- 実装でハマった問題の共有(未解決問題含む)
- http3sharp を用いて Unity 上で HTTP/3 通信を行うサンプルの紹介
個人的な備忘録も兼ねているのでかなりの長文になってしまいましたが、必要な個所だけ掻い摘んで参考にして貰えたら幸いです。
バージョン
今回の記事は HTTP/3, QUIC ともに draft version 27 に準拠した仕様で記載を行っています(最新バージョンは 28 です)。
その他、利用・動作確認している関連モジュール・エンジンのバージョンは以下の通りです。
- Unity : 2019.3.14f
- quiche : 0.4.0
- boringssl : quiche の submodule バージョンに準ずる
- aioquic : a38756bab2cd9039e89a06a1f442f330d349eea0 (master)
- http3sharp : 0.1.0
注意事項
その弐 同様に今回の記事も HTTP/3, QUIC の仕様をある程度理解している前提で進めます。
多少は補足しますが、理解が追いつかない個所がある場合は以下のいずれかで補完することをお勧めします。
- flano_yuki さんが書いている http3-note や ASnoKaze blog の各解説記事
- 筆者の著作物である 『くいっく』HTTP/3編 (宣伝)
- HTTP/3, QUIC の draft - 27
quiche アドバンスド
前書きから長くなってしまいましたがいよいよ本題です。
この項では、その弐 の example で解説では触れなかった quiche のより一層踏み込んだ設定項目について解説します。
qlog の出力
qlog は HTTP3, QUIC の標準ログ形式です。
現在は DQUIC and HTTP/3 event definitions for qlog 及び Main logging schema for qlog にて仕様の策定が進められています。
また、 qlog を使って HTTP3, QUIC の通信結果の可視を行ってくれる qviz というツールも存在しています。
qviz は https://github.com/quiclog/qvis で公開されているソースコードを自分でビルドするか、 https://qvis.edm.uhasselt.be/#/files にて公開されているサービスかのいずれかにより利用可能です。
以下のような感じで多重化通信を可視化してくれる等、これがない開発は考えられないくらいに便利な代物なので HTTP/3, QUIC のライブラリを利用する際には対応を確認するのをお勧めします。
qlog/qviz のより詳細な内容が気になる方は qlog/qvizでQUICの可視化 及び 「Debugging Modern Web Protocols with qlog」の紹介 にて neko-suki さんが解説してくださっているので、そちらを参照ください。
さて、そんな便利 qlog ですが、 その壱 でも触れたように quiche も勿論対応しています1。
ただ、デフォルトのビルドでは qlog の出力は無効になっているので、 Cargo.toml
を修正して有効にしてあげる必要があります。
と言っても、 [dependencies]
に依存関係が設定されているので、 [features]
を以下に書き換えるだけで OK です。
default = ["boringssl-vendored", "qlog"]
上記でビルドした quiche をリンクした上で、 quiche_conn_set_qlog_path
を呼び出すことにより qlog が出力されるようになります。
// Enables qlog to the specified file path. Returns true on success.
bool quiche_conn_set_qlog_path(quiche_conn *conn, const char *path,
const char *log_title, const char *log_desc);
引数に quiche_conn
という quiche の QUIC ハンドルを取ることから分かるように、出力される単位は QUIC のコネクション単位です。
path
に既に存在するパスを指定してもファイルを上書き生成してくれます。
quiche のビルドについては その壱 を参照してください。
輻輳制御について
その壱 でも触れたように、 quiche は輻輳制御アルゴリズムとして NewReno 及び CUBIC に対応しています。
quiche_config_set_cc_algorithm
にて指定可能です。
enum quiche_cc_algorithm {
QUICHE_CC_RENO = 0,
QUICHE_CC_CUBIC = 1,
};
// Sets the congestion control algorithm used.
void quiche_config_set_cc_algorithm(quiche_config *config, enum quiche_cc_algorithm algo);
さらに、最近 HyStart++ にも対応しました。
HyStart++ は RTT を閾値として TCP のスロースタートを改善するアルゴリズムです。
詳細を知りたい方は、日本語の解説では flano_yuki さんの TCP Slow Startを改善する HyStart++について - ASnoKaze blog を、英語であれば本家 CloudFlare の記事である CUBIC and HyStart++ Support in quiche を参考にしてみると良いと思います。
HyStart++ を有効にするには quiche_config_enable_hystart
を呼び出します。
// Configures whether to use HyStart++.
void quiche_config_enable_hystart(quiche_config *config, bool v);
信頼されたルート証明書の一覧について
quiche が依存している BoringSSL は OpenSSL の動きに準拠しています。
よって、何も指定しない場合は システムのデフォルト の信頼されたルート証明書の一覧が使われます。
// SSL_CTX_set_default_verify_paths loads the OpenSSL system-default trust
// anchors into |ctx|'s store. It returns one on success and zero on failure.
OPENSSL_EXPORT int SSL_CTX_set_default_verify_paths(SSL_CTX *ctx);
更に、信頼されたルート証明書の一覧のファイルパスを指定する機能が https://github.com/cloudflare/quiche/issues/418 で追加されました。
システムデフォルトオンリーの動作はデバッグ時や開発時に不便なことがあるので嬉しい限りです。
と喜んだのも束の間、この機能は C ラッパ側にはまだ反映されていないようです。
そのうち追加されると思うので、今回はスルーしてしまおうと思います。
必要な場合は自分で簡単にラッパ層に追加可能だと思うので、自己対応しちゃっても良いと思います。
コネクションクローズとライフタイム
quiche では QUIC コネクションは quiche_conn
で、 HTTP/3 コネクションは quiche_h3_conn
によって管理されます。
// A QUIC connection.
typedef struct Connection quiche_conn;
// A QUIC connection.
typedef struct Http3Connection quiche_h3_conn;
これらのコネクションに紐づける形でリクエストのストリームを quiche_h3_send_request
により生成します。
// Sends an HTTP/3 request.
int64_t quiche_h3_send_request(quiche_h3_conn *conn, quiche_conn *quic_conn,
quiche_h3_header *headers, size_t headers_len,
bool fin);
quiche_conn
及び quiche_h3_conn
はコネクション切断時に各々の終了関数でクローズする必要があります。
対して、ストリームの明示的な解放は不要で、 quiche_h3_conn_poll
で取得したステータスが QUICHE_H3_EVENT_FINISHED
になると自動的に解放されるようです。
コネクションがクローズされた際には quiche_conn
及び quiche_h3_conn
は使いまわすことはできず、新しく生成し直す(socket も開き直す)必要があります。
タイムアウトについて
その弐 ではタイムアウトに関連する処理をばっさりカットして解説を行いました。
しかし、タイムアウトは製品の実運用時にはとても重要な要素です。
よって、今回ではしっかり確認していきたいと思います。
まずは quiche のタイムアウト関連の関数を確認してみます。
// Sets the `max_idle_timeout` transport parameter.
void quiche_config_set_max_idle_timeout(quiche_config *config, uint64_t v);
// Returns the amount of time until the next timeout event, in nanoseconds.
uint64_t quiche_conn_timeout_as_nanos(quiche_conn *conn);
// Returns the amount of time until the next timeout event, in milliseconds.
uint64_t quiche_conn_timeout_as_millis(quiche_conn *conn);
// Processes a timeout event.
void quiche_conn_on_timeout(quiche_conn *conn);
これだけだとよく分からないので、 Rust 側のリファレンスや nginx での組み込み方法2、実動作を確認をしてみた所、大体以下のような仕様のようです。
-
quiche_config_set_max_idle_timeout
でタイムアウト(ミリ秒)を指定する- QUIC トランスポートパラメータである
max_idle_timeout
を設定する
- QUIC トランスポートパラメータである
-
quiche_conn_timeout_as_nanos
/quiche_conn_timeout_as_millis
で 0 が返ってきたらタイムアウトしたと判断してquiche_conn_on_timeout
を呼び出す - 更に
quiche_conn_is_closed
を呼んでコネクションのクローズ処理を行う -
quiche_conn_timeout_as_nanos
/quiche_conn_timeout_as_millis
はquiche_conn_close
を呼び出した後にも 0 を返す ※ポイント1 -
max_idle_timeout
に 0 を指定することでタイムアウトが発生しなくなる ※ポイント2
ポイント1, ポイント2 それぞれにハマり所があるので少し補足します。
ポイント1 : quiche_conn_close
呼び出し時の動作
中断等の処理でクライアントから明示的にコネクションを切断したい場合には quiche_conn_close
を呼び出します。
しかし、 quiche_conn_close
を呼び出した後に quiche_conn_timeout_as_nanos
/quiche_conn_timeout_as_millis
を呼び出すと何故か 0 が返ってきます。
さらにこのタイミングできちんと quiche_conn_on_timeout
を呼び出してあげないと quiche_conn_is_closed
が true を返してくれず、切断判定ができません。
また、ソケットエラーで通信が失敗した際等も quiche_conn_timeout_as_nanos
/quiche_conn_timeout_as_millis
が 0 を返すタイミングがありました。
上記の流れについては quiche のソースコードまで確認できていないので、認識や理解が怪しい部分があります。
現状動作検証の結果ベースで実装していますが、このままだと事故りそうなので、しっかりと仕様を理解した後に当記事へ追記し、 http3sharp 側も修正しようと思います。
ポイント2 : max_idle_timeout
への 0 指定時の動作とユーザタイムアウトの実装
QUIC のトランスポートパラメーターである max_idle_timeout
には、 0 を指定するとタイムアウトが無制限になるという仕様があります。
quiche もこの仕様に準拠している為、 quiche_config_set_max_idle_timeout
に 0 を設定するとタイムアウトが発生しなくなります。
以下は QUIC の draft にある max_idle_timeout
の定義です。
max_idle_timeout (0x01):
The max idle timeout is a value in milliseconds that is encoded as an integer; see (Section 10.2).
Idle timeout is disabled when both endpoints omit this transport parameter or specify a value of 0.
実際の運用でタイムアウトが発生しないのは困るので、例えば、ゲームにおいては quiche_config_set_max_idle_timeout
には 5 ~ 10 秒程度の値を設定したいケースが発生しそうです。
しかし、 quiche では、リクエストを発行していない状態で quiche_config_set_max_idle_timeout
に設定した時間が経過するとタイムアウトが発生してしまいます(ハンドシェイク時は除く)。
これは、 quiche が PING フレームを用いたコネクションの維持3を実装していないことに起因します。
つまり、現状の quiche では、HTTP/1.1 時代の Keep-Alive のように、リクエストを発行していない状態でコネクションを維持させておくことができません。
対策として以下の実装を行う事でこの問題を回避できそうです。
- 自分で PING フレームを送る実装を行う
-
quiche_config_set_max_idle_timeout
には 0 を指定し、リクエスト通信中には受信データが 0 の期間が一定時間経過した場合にタイムアウトとしてクライアント側からコネクションを切断する
http3sharp では現状いずれの実装も入れていませんが、将来的には 2 の実装を導入することになると思います。
ただ、 HTTP/3 には 0-RTT があるのでコネクション切れてもいいやん、という説もあるので入れないかもしれません4。
stream の取り扱い
quiche_h3_send_request
で HTTP/3 のリクエストストリームを生成した後は、 quiche_h3_conn_poll
でステータスを監視する必要があります。
その弐 のサンプル実装時にも言及しましたが、 quiche_h3_conn_poll
は処理を行う必要のあるストリームの ID を返します。
なので、 Stream クラスを作成し、その中で quiche_h3_conn_poll
を呼んでも、自分 以外 のストリーム ID に紐づいたイベントが返ってくることがあり、うまくいきません(最初この設計にしてハマりました……)。
Stream クラスではなく、より上層で quiche_h3_conn_poll
で取得したストリーム ID に対して処理を呼び出す必要がある点に注意してください。
quiche のエラーについて
quiche のエラーは quiche_error
及び quiche_h3_error
にて定義されています。
しかし、「どの関数でどの値が返るのか」、「ある値が返った場合に何をしたらいいのか」が明記されていないので、若干処理に困るケースがありました。
動作未確認のものは多いですが、 qwfs にて quiche エラーコンバート関数を作成しているので参考にしてみてください。
Connection::ConvertQuicheErrorToStatus
及び Stream::ConvertQuicheH3ErrorToStatus
にて処理しています。
実装方針について
quiche についての理解が深まったので、次は自分で作るライブラリの実装方針を考えてみます。
とは言え、ここで細かい実装について説明し出すとより一層長大な解説になってしまうので、今回の記事では実装方針の紹介に留めます。
実装が気になる方は Http3Sharp.cs
に最低限のリファレンスは記載しておいたので、そこから見ていってみてください。
コネクションとリクエストの管理を分ける
まず第一に、多重化を踏まえて HTTP/1.1 までと考え方を変える必要があります。
HTTP/1.1 までは Keep-Alive でコネクションを使いまわすことはあっても、 1 リクエスト 1 コネクションで設計を行う事が普通でした。
しかし、HTTP/3 や QUIC では、コネクションそのものの管理とリクエストの管理は別になります(これは HTTP/2 からの変更でもあります)。
リクエストは失敗したけどコネクションは生きている、という状況もあり得ないとは言い切れません。
この為、コネクション全体のステータスとリクエスト単位のステータス・結果を別に取得できるような設計にする必要があります。
そこで、 http3sharp ではインスタンス全体のステータス Http3Sharp.Status
と、個別のリクエスト結果 Http3Sharp.ResponseParamaters
内のステータスを別々にアプリ側に渡す仕様としました。
また、中断 Http3Sharp.Abort
時にも、コネクションを一度破棄した後に自動で再度ハンドシェイクを行う実装として、次のリクエストをなるべく早く送信可能できるようにしています。
インスタンスの管理
多重化はホストとポートを合わせた Authority
単位で行われます。
よって、 http3sharp のインスタンス管理もこの Authority
単位で行うのが取り回しが良さそうです。
ということで、コンストラクタでホスト名とポート、それからコネクションのオプション設定を受け取る設計としました
public Http3Sharp(string hostName, string port, ConnectionOptions options)
Authority 単位でも別インスタンスにしたい場合もあるかもなので、固有 ID を生成する関数を用意して、その ID でインスタンスを管理する実装の方がいいかもしれません。
しかし、同一ホストへファイルの受信と API 通信を行う、というような場合はポートを分けたりサブドメインを使う場合が多いと思われますので今回はこの対応は行いませんでした。
膨大なリクエスト数の想定
Unity でのゲーム開発はリソース数が膨大になりがちで、データの一括ダウンロード時に 4 ~ 5 桁のリクエストが発行されることはざらにあります。
この為、数千数万のリクエストが発行されたとしても、その処理のオーバーヘッドはなるべく少ない設計にしておきたいところです。
これを踏まえ http3sharp では以下の設計としました。
- C# 側ではなるべくステータスや管理リストは保持せずに Native 側に追いやる
- リクエスト単位で処理経過・結果を舐めるような処理はなるべくしたくないので、C# - Native 間ではコールバック形式で結果を取得する
- C# の for はとても遅いのでなるべく呼ばない設計としておく
- ファイル I/O 等重い処理があり実行ループが処理落ちすると通信バッファを拾いきれず、通信のパフォーマンスが落ちることがあるので、実行ループ部は非同期に処理する
- libev は Android や iOS での動作が不安なので使わない
- C++ のスレッド実装はマルチプラットフォーム対応が面倒なので、とりあえずは C# のスレッドに逃げたい
- 非同期のロックの回数を減らす為にユーザが呼び出す関数はなるべく少なくする
ファイル I/O の負荷分散等できることはまだまだありますが、細部の実装については後から改善できるので、上記を大方針として実装を行いました。
現状マーシャリングとかその他のメモリ管理もかなり無駄のある実装ですが、この辺も将来的にぼちぼち直していければ、という感じです。
string とかもやばいですね。
以下、スレッド側の処理です。
private void UpdateQwfs()
{
while (true)
{
if (_cToken.Token.IsCancellationRequested)
{
break;
}
QwfsResult result;
if (1 == Interlocked.Exchange(ref _requestRetry, 0))
{
lock (_lockObject)
{
result = qwfsRetry(_hostId);
}
Debug.Assert(QwfsResult.Ok == result);
}
if (1 == Interlocked.Exchange(ref _requestAbort, 0))
{
lock (_lockObject)
{
result = qwfsAbort(_hostId);
}
Debug.Assert(QwfsResult.Ok == result);
}
lock (_lockObject)
{
result = qwfsUpdate(_hostId);
// ここの結果は色々返るが status 側で見るのでスルー(要改善)
}
// todo : 処理負荷を見た wait
Thread.Sleep(1);
}
_cToken.Dispose();
}
基本、ロックして Native 実装側の qwfs 内部を更新する qwfsUpdate
を呼び出しているだけです。
Sleep 処理とか雑過ぎるのでさっさと直したいところですね。
以下はアプリ側が呼び出す、レスポンスとステータス、プログレスを取得する関数です。
public List<Http3Sharp.ResponseParamaters> Update(out Http3Sharp.Status status, out ulong progress, out ulong totalWriteSize)
{
// Update スレッドの監視とか本当はした方がいいがとりあえずなし
status = Http3Sharp.Status.Wait;
progress = 0;
totalWriteSize = 0;
_responseForEachHost[_hostId].Clear();
lock (_lockObject)
{
var result = qwfsGetProgress(_hostId, out progress, out totalWriteSize);
Debug.Assert(QwfsResult.Ok == result);
result = qwfsIssueCallbacks(_hostId);
Debug.Assert(QwfsResult.Ok == result);
result = qwfsGetStatus(_hostId, out status);
Debug.Assert(QwfsResult.Ok == result);
}
return _responseForEachHost[_hostId];
}
レスポンスとステータス、プログレスは別々の関数で取得する方が自然ですが、先ほど書いたように lock の回数を減らす為にまとめて取得する仕様にしています。
個の実装だと qwfs の処理負荷が高い時にアプリ側ががっつり固まってしまうので本来であれば Monitor.TryEnter
のようなものを使った方が良いですが、とりあえず雑に実装してあります。
リトライについて
コネクション全体の失敗時には、内部的に失敗したリクエストのリストを保持して、リトライ関数を呼び出すだけで失敗したリクエスト全てを再送信する実装としました。
これもリクエスト数が多い場合に C# 側での処理負荷が上がるのを防ぎたい、リストの二重管理をしたくないという動機からの実装です。
反面、現状では個別のリクエストの失敗に対するリトライについては、前述した Http3Sharp.Update
の戻り値のリストを見てあげないといけない実装です。めんどうです。
改善しようと思います。
リクエストとレスポンスの管理
これまで説明してきたように、
しかし、あまりに仕様を簡略化し過ぎてしまった為に、現状出したリクエストに対して Http3Sharp.Update
で返ってきたレスポンスの紐づけができません。
この仕様では、API 通信を複数同時に行い、個別の対応を行いたい場合等で面倒なケースが発生します。
QUIC DATAGRAM に対応する事になった際にも相性が悪そうです。
そこで、将来的には Http3Sharp.RequestParamaters
にレスポンス取得用のコールバック関数を登録し、Http3Sharp.Update
内でそれを呼ぶ形に変更するのが良さそうです。
やらないことリスト
まずは動かすのが今回の主眼なので、以下の内容は後回しとしました。
- 細かいエラーハンドリング
- socket やコネクションのきちんとしたクローズ・後始末
- メモリや処理負荷の最適化関連の実装 (設計面ではある程度考慮)
- テスト関連
実装でハマった問題の共有(未解決問題含む)
実装を進める中で、 QUIC ならではの内容を含む問題をいくつか踏んだので共有します。
解決に苦戦し(解消できてないものもあります……)、本記事の公開に影響を与えたレベルのものもあるので、皆さんが HTTP/3 の通信実装をする際の参考になれば幸いです。
今回 http3sharp の実装では、
- テストサーバとして aioquic の
examples/http3_server.py
をメインに使用 - 上記の動作がおかしい場合のセカンドとして CloudFlare の
blog.cloudflare.com
にアクセスさせて頂く
という形で動作確認を行いながら実装作業を進めました。
まだまだどこの OSS も枯れていないので、複数の動作検証環境を用意しておくと問題発生時に切り分けが容易になるのでお勧めです。
それでは、個別に問題を見ていきましょう。
Unity Editor で再生 → 終了 → 再生すると Unity Editor ごと落ちる
Unity Editor 内の plugin フォルダに quiche.dll を配置し、 quiche_enable_debug_logging
で quiche 内のデバッグログの出力設定を行ってUnity Editor の 再生 → 終了 → 再生 と実行すると Unity Editor がクラッシュします。
一度目の Unity Editor 終了時に quiche 内の static mut
な変数が解放されるのが原因のようです。
Rust をよく理解できていないので何故解放されてるかも理解できていませんが、 Unity Editor の plugin フォルダに .dll を配置すると Editor 再生後は .dll を握りっぱなしになるので、そのあたりの兼ね合いだとは思います。
LoadLibrary
で動的に dll をロード・アンロードする実装に変更すれば当問題は回避できると思うので、将来的に試して見ようと思います。
その他 Rust と Unity Editor の相性はあまりよろしくないようのか、Visual Studio のデバッガを繋いだ状態でうかつなことをしたり、通信中に Editor 再生を停止すると Editor がそっとお亡くなりになることが多々あります。。
根本的な原因の理解ができていないので、まずは Rust 側の知見を集めるべきかな、と感じています。
129 個目の以降のリクエストが発行できない
開発も終盤、いよいよ多重化の動作確認を作成する段で、
quiche_config_set_initial_max_streams_bidi
で initial_max_streams_bidi
の値を 129 以上に設定しても、129 個目のリクエストの生成を行う quiche_h3_send_request
で必ず処理が失敗してしまう
という問題を踏みました。
この問題の解決には今回一番苦戦したので、少し長くなりますが詳細を紹介しようと思います。
まず、 quiche_h3_send_request
の失敗時の関数の戻り値を確認すると -12 が返ってきており、
// The peer violated the local stream limits.
QUICHE_ERR_STREAM_LIMIT = -12,
どうやらストリームの生成上限に引っかかっているようです。
QUIC のストリームの上限値に関する仕様は複雑で、正しく理解するには initial_max_streams_bidi
及び MAX_STREAMS
フレームの仕様を把握する必要があります。
まず、これらの値はどちらも peer(通信相手) の双方向ストリームの作成上限数5 ですが、以下の違いがあります。
-
initial_max_streams_bidi
- トランスポートパラメータに含める初期の上限値
-
MAX_STREAMS
フレーム-
initial_max_streams_bidi
の値を更新する必要な際になった際に都度送信する上限値
-
更に、これらの値は 現在通信中のストリーム数 ベースではなく、クローズ済みのストリームも含む累計値の上限値 であることにも注意が必要です。
詳細は QUIC draft の 4.5. Controlling Concurrency 及び 19.11. MAX_STREAMS Frames を参照してください。
ちなみに、 quiche_config_set_initial_max_streams_bidi
で設定する値も peer(通信相手) の双方向ストリームの作成上限数 です。
私も完全に勘違いしていましたが、クライアント側が作成可能なリクエストの上限数ではないことに注意してください。
さて、 initial_max_streams_bidi
及び MAX_STREAMS
フレームの仕様を理解したところで、具体的に何が起きていたかを解説します。
- aioquic からは
initial_max_streams_bidi
として 128 が降ってきていた - quiche は 128 個目のリクエストまでは正常に処理を行っていたが、
MAX_STREAMS
による上限値の更新がなかったためにストリーム生成数の上限に達してquiche_h3_send_request
で失敗を返すようになった
動作をまとめると非常にシンプルですが、ここに二つほど問題が潜んでいました。
- サーバから
MAX_STREAMS
フレームが送信されてきていない - クライアントから
STREAMS_BLOCKED
フレームを送信していない
1 については、aioquic のコードを見る限りは未実装のようです。
_handle_max_streams_bidi_frame
という関数で MAX_STREAMS
フレームを作成するようですが、この関数が呼び出されていないようでした。
サンプルサーバなので実装を省いた可能性もあります。
2 については、まず STREAMS_BLOCKED
フレームについて少し補足します。
STREAMS_BLOCKED
はストリームの累計生成数が peer から指定された MAX_STREAMS
に到達してしまった際に更新を要請するフレームです。
詳細は QUIC draft の 19.14. STREAMS_BLOCKED Frames を参照してください。
今回発生している状況もまさしくこのシチュエーションですが、 quiche_h3_send_request
で上限数を超えていた場合にも STREAMS_BLOCKED
フレームを送信せずに処理を継続してしまっているように見受けられます。
aioquic, quiche 共に実装の理解は非常に浅いので、私の勘違いもあるかもしれません。
もう少し詳細を追ってみて、問題が確定したら報告しようと思います。
多重化通信ができない
Unity のサンプルを作成している途中で、何やら 1 リクエストずつ処理が進んでいる気配を感じ取りました。
怪しいときはすぐに qlog/qviz で確認!ということで見てみたところ、以下のように多重化がされていないことがすぐに判明しました。qlog/qviz は神。
色のついた棒が通信の開始と終了を表しているので、上記の画像だと順次リクエストが処理されていることになります。
同じ実装で CloudFlare に接続すると以下のようにきちんと多重化されています。
ということで、これは aioquic のサーバサンプルの問題のようです(その他の実験公開されている HTTP/3 サーバでも同様の動きを示すものが少しだけありました)。
サンプルコードの設定等によって回避可能かまでは追っていませんので、もしかしたらデフォルトの仕様かもしれません。
追加調査したらここに結果を記載しようと思います。
Rust のデバッグ
ハマった問題の共有ではないですが、ハマった時に非常に便利だったので Rust のデバッグについても少し触れます。
その弐 で記載したように、Visual Studio 2019 を使っていると Unity にデバッガをアタッチした状態でそのまま quiche の中にもぐることができます。
しかし、 quiche のデフォルトの設定では debug ビルドでも最適化が働いており、変数や関数そのものが削除されていたりすることが多くあります。
こんな感じでそもそもステップインできなかったりします。かなしい。
そこで、ビルド時に最適化オプションを変更してあげるとデバッグ可能になります。
最適化レベルは [profile.dev]
や [profile.release]
の opt-level
で設定できます。
参考 : https://doc.rust-jp.rs/book/second-edition/ch14-01-release-profiles.html
結果、かなり細かく見れるようになりました!
http3sharp を用いて Unity 上で HTTP/3 通信を行うサンプルの紹介
数々の問題を乗り越え、ようやく Unity での動作サンプルの解説までたどり着くことができました。
今回 http3sharp のサンプルとして以下を用意しています。
- SingleRequestSample
- リクエストを一つ送信し、受信したボディを画面にテキストで表示するサンプル
- MultiRequestSample
- 多重化を用いて同時に複数のリクエストを送信するサンプル
どちらのサンプルも基本的には aioquic のサンプルサーバをローカルに立てて動作確認を行ってますが、パラメータを適切に設定してあげれば外部のサーバとも通信可能です。
QUIC base-drafts の Implementations に掲載されている実験用サーバにも是非接続してみてください。
※多重化のサンプルの方は割と負荷を掛けるので、ローカルでないサーバと通信する場合は同時接続数を減らす等利用にご注意ください
SingleRequestSample
SingleRequestSample は任意の宛先に HTTP/3 リクエストを送信し、受信したレスポンスのボディを表示するサンプルです。
[GameObject]Http3SharpHost
にて宛先や証明書の検証の有無等のパラメータを設定可能です。
特に説明が必要なパラメータもないので、以下で実装の軽い解説をしたいと思います。
Http3SharpManager
Native ライブラリのインスタンス管理の都合上、 http3sharp では初期化・終了関数を必ず一度ずつ呼び出す必要があります。
- 初期化関数 :
Http3Sharp.Initialize
- 終了関数 :
Http3Sharp.Uninitialize
当サンプルでは上記関数を呼び出す Http3SharpManager 実装し、これを DontDestroyOnLoad
指定することで、シーン遷移時ではなくアプリ終了時に Http3Sharp.Uninitialize
を一度呼び出す一般的な作りとしています。
Http3SharpSampleCore
http3sharp の実処理を呼び出し・管理するクラスです。
各サンプルの Update
で Http3SharpSampleCore.Http3.Update
を呼び出すことにより http3sharp のステータス及び完了したリクエストの取得を行います。
実装はシンプルなのであまり解説する個所もないですが、ステータス管理とプログレスについて補足します。
- ステータス管理について
- リトライ
- http3sharp ではリトライ中かどうかというステータスを持っていない為、
Http3.Retry
を呼び出した
どうかのフラグを持たせる、若干煩雑な実装になっています - 実際のゲームではリトライボタンとスタートボタンは共通か、別途ウィンドウ表示することがほとんどなのであまり問題にならないかと思います
- http3sharp ではリトライ中かどうかというステータスを持っていない為、
- アボート
- http3sharp は
Http3.Abort
が呼び出されると内部的に保持しているリクエストを全て破棄し、コネクション切断後に再度コネクションを張り直します -
Http3Sharp.Status.Aborting
中にHttp3.Abort
を呼び出しても何も起きませんが、分かり易さの為に呼び出せない実装としてあります
- http3sharp は
- リトライ
- プログレスについて
- http3sharp では
Http3.Update
呼び出し間でダウンロードしたデータ量と、今まで通信したデータ量を取得できます - 今まで通信したデータ量は、
Http3.Status.Wait
に戻った状態でリクエストを発行すると初期化されます
- http3sharp では
ちなみに、このクラスは MultiRequestSample
でも併せて利用しています。
Http3SharpSampleSingle
リクエストの作成とボディの表示を行うクラスです。
失敗時は Core 側でエラー原因の表示まで行うので、成功時の処理しか実装していません。
……特に解説することがありませんのでサクッと次にいきましょう。
MultiRequestSample
MultiRequestSample は、任意の宛先に複数の HTTP/3 リクエストを多重化して送信し、その進捗を表示するサンプルです。
[GameObject]Http3SharpHost
にて宛先や証明書の検証の有無等のパラメータを設定可能です。
1k ~ 1M のサイズのデータのリクエスト(※)を Download File Num
個行い、 Work Dir
に 0 から連番(拡張子無し)で保存します。
※ aioquic のサンプルサーバの GET/NNNN
機能(NNNN で指定した数 Z
が返ってくる)を利用しています
Max Multiple Download Num
は initial_max_streams_bidi
とは別に http3sharp で独自に管理する同時にリクエスト可能な最大値です。
initial_max_streams_bidi
とは異なり、累計ではなく現存リクエストしている数を指定できます。
前述したようにメインの処理は Http3SharpSampleCore
に任せている為、実装面の解説は割愛します。
結び
以上で「Re: C#(Unity)でHTTP/3通信してみる その参 ~Unityから使ってみる~」は終了です。
HTTP/3 を利用してみるにはまだ時期尚早感はありますが、これを見て「自分も使ってみたい」という方が一人でも増えると嬉しく思います。
また、 その壱 から合わせると結構な長文となってしまいました……。
ここまでお付き合い頂きました方、本当にありがとうございます。
今回公開した http3sharp は今後チマチマ改善していこうと思っていますので、使ってみた方がいましたら是非フィードバックをお願いします。
https://github.com/TakeharaR/http3sharp に Issue を作るでも、 Twitter やここでコメントを頂く形でも構いません。
次回はちょっと毛色を変えて音声認識エンジンである Julius 触ってみた記事でも書こうかなと思っています。
それではまた。
-
最近 qlog 0.3.0 にも対応したようです → https://github.com/cloudflare/quiche/pull/526 ↩
-
QUIC にはコネクションを維持する為に PING フレームというフレームを 送信しても良い という仕様があります。詳細は QUIC draft の 19.2. PING Frame を参照してください。 ↩
-
インスタンスの再生成等の負荷を考えると PING フレームでの維持の方がゲーム的には都合が良さそうではあります。 ↩
-
正確にはタイプ指定により単方向ストリームの上限数も変更可能です ↩