対象読者
この記事は、PHPからQUICやHTTP/3を扱う方法や、Sans-I/Oによるプロトコル実装に関心がある人を対象としています。
PHP拡張やngtcp2の知識は前提としません。自作PHP拡張を題材に、TCPとQUICの違い、ソケットとプロトコル処理の分離、QUICとHTTP/3を別の層として設計した理由を説明します。
はじめに
QUICは、しばしば「UDP上で動く新しいTCP」のように説明されます。
実際、QUICはTCPと同じく、アプリケーションへ信頼できる通信路を提供します。しかし、自作したngtcp2のPHP拡張を設計してみると、QUICをTCPソケットと同じ形で公開するのは難しいことが分かりました。
この記事では、私が開発したext-ngtcp2を題材に、次の疑問を考えます。
- QUICはTCPと何が違うのか
- なぜ
read()とwrite()だけでは扱えないのか - なぜUDPソケットをPHP拡張の外に出したのか
- なぜQUICの標準APIを決めにくいのか
- HTTP/3を別の拡張として分けた理由は何か
TCPなら、ソケットを1本のストリームとして扱える
TCPを使うアプリケーションでは、接続したソケットを1本のバイトストリームとして扱えます。
PHPでも、概念的には次のような処理になります。
$socket = stream_socket_client('tcp://example.com:80');
fwrite($socket, $request);
$response = fread($socket, 8192);
実際にはタイムアウトや部分読み書きなどを考える必要がありますが、基本的なモデルは単純です。
- 接続する
- 書き込む
- 読み込む
- 閉じる
OSのカーネルが、パケットの順序制御、再送、輻輳制御などを担当します。
アプリケーションから見ると、TCPパケットを直接意識する必要はありません。
QUICには複数のストリームがある
QUICも信頼できる通信を提供しますが、TCPとは構造が異なります。
大きな違いの一つは、一つのQUIC接続の中に複数のストリームを持てることです。
QUIC Connection
├─ Stream 0
├─ Stream 4
├─ Stream 8
└─ Stream 12
それぞれのストリームは独立してデータを送受信できます。
そのため、単純なread()やwrite()だけでは、次の情報を表現できません。
- どのストリームへ書くのか
- 新しいストリームをいつ作るのか
- どのストリームからデータを読むのか
- 接続全体が閉じたのか
- 一つのストリームだけが終了したのか
- 相手からストリームをリセットされたのか
QUICをTCP風の単一ストリームへ押し込むと、QUICが持つ構造を隠すことになります。
QUICはパケットが届かなくても進める必要がある
もう一つの違いは、タイマーです。
TCPの再送処理は、通常はカーネルが管理します。
一方、ngtcp2のようなQUICライブラリは、ユーザー空間で次の処理を管理します。
- 再送
- ACK
- ハンドシェイク
- idle timeout
- closing状態
- draining状態
そのため、UDPパケットを受信したときだけ処理すればよいわけではありません。
パケットが届かなくても、期限が来たらQUICの状態を進める必要があります。
ext-ngtcp2では、タイマー処理を明示的なAPIとして公開しています。
$expiry = $connection->getNextExpiry();
if ($expiry !== null && $expiry <= $now) {
$connection->onTimeout();
}
QUICのAPIには、読み書きだけでなく、「次にいつ処理を進めるか」も必要になります。
UDPソケットをPHP拡張の外に出した
ext-ngtcp2では、UDPソケットを拡張内部で作成していません。
PHP側でUDPソケットを作り、受信したデータグラムを拡張へ渡します。
$packet = stream_socket_recvfrom($udp, 65535, 0, $peer);
$connection->recv(
new Datagram(
$packet,
Address::fromString($peer)
)
);
拡張が送信すべきQUICパケットを生成したら、PHP側がUDPソケットへ送信します。
foreach ($connection->drainOutgoingDatagrams() as $datagram) {
stream_socket_sendto(
$udp,
$datagram->getPayload(),
0,
(string) $datagram->getPeerAddress()
);
}
イベントも、PHP側から取得します。
foreach ($connection->drainEvents() as $event) {
// HandshakeCompleted
// StreamReadable
// StreamClosed
// ConnectionClosed
}
この構成では、役割が次のように分かれます。
PHPユーザーランド
├─ UDPソケット
├─ stream_select
├─ イベントループ
└─ 複数接続の管理
ext-ngtcp2
├─ QUIC接続状態
├─ ストリーム管理
├─ 暗号処理との連携
├─ 再送・ACK
├─ タイマー
└─ イベント生成
拡張は、UDPパケットを受け取ってQUICの状態を更新し、送信すべきUDPパケットを返します。
このように、プロトコル処理と実際の入出力を分離する設計は、Sans-I/Oと呼ばれます。
Sans-I/Oは「通信しない」という意味ではない
Sans-I/Oという名前から、I/Oを扱わない特殊なライブラリを想像するかもしれません。
しかし、通信を行わないわけではありません。
Sans-I/Oが分離するのは、次の二つです。
- プロトコルの状態を進める処理
- OSのソケットから読み書きする処理
ext-ngtcp2は、QUICパケットを解釈し、送信するQUICパケットを生成します。
一方で、次の処理は行いません。
- UDPソケットの作成
-
recvfrom()の実行 -
sendto()の実行 - 特定のイベントループの採用
この分離によって、同じQUIC処理を異なる実行環境へ接続できます。
例えば、将来的には次のような構成が考えられます。
stream_select- ReactPHP
- Revolt
- Swoole
- 独自イベントループ
- テスト用の仮想ネットワーク
ソケットを拡張内部へ固定すると、イベントループや実行環境の選択まで拡張が抱え込むことになります。
HTTP/3を別の層として載せる
私はngtcp2拡張に加えて、nghttp3のPHP拡張も開発しました。
HTTP/3はQUIC上で動作しますが、QUICそのものではありません。
そこで、QUICとHTTP/3を一つの拡張へまとめず、別の層として分けました。
$quic = new Varion\Ngtcp2\Connection($remote);
$http3 = new Varion\Nghttp3\Http3Connection($quic);
HTTP/3側では、リクエストストリームを作成し、ヘッダーや本文を送信します。
$request = $http3->createRequestStream();
$request->submitHeaders([
[':method', 'GET'],
[':scheme', 'https'],
[':authority', 'example.com'],
[':path', '/'],
]);
$request->end();
二つの拡張の役割は異なります。
UDPソケット
↓
ext-ngtcp2
QUIC接続
暗号化
再送
ACK
タイマー
QUICストリーム
↓
ext-nghttp3
HTTPヘッダー
DATA
リクエスト状態
GOAWAY
↓
アプリケーション
ext-nghttp3も、UDPソケットやイベントループを所有しません。
QUICストリームを入力として受け取り、HTTP/3のイベントを返します。
重要なのは、Sans-I/Oの実装が二つあるということではありません。
各層が、自分の責務に必要な入力を受け取り、自分の責務に対応する出力を返している点です。
UDPデータグラム
↓
QUICの状態変化
↓
QUICストリーム
↓
HTTP/3の状態変化
↓
HTTPイベント
ext-nghttp3を別の拡張として実装できたことは、ext-ngtcp2のSans-I/O設計が、単なる低水準化ではなく、上位プロトコルを載せるための境界として機能したことを示しています。
すべての内部状態を公開するわけではない
Sans-I/Oは、低水準の情報をすべてアプリケーションへ公開する設計ではありません。
例えば、HTTP/3には通常のリクエストストリーム以外に、次のような内部ストリームがあります。
- コントロールストリーム
- QPACKエンコーダーストリーム
- QPACKデコーダーストリーム
これらはHTTP/3を動かすために必要ですが、一般的なアプリケーションが直接管理する対象ではありません。
そのため、ext-nghttp3では内部に隠し、公開APIではリクエストストリームを中心に扱います。
低水準APIを設計するときにも、次の区別が必要です。
- 呼び出し側が判断するために必要な情報
- 実装内部で完結させるべき情報
単に内部構造をそのままPHPへ公開しても、使いやすいAPIにはなりません。
なぜQUICの標準APIを決めにくいのか
「QUICをPHPの標準APIとして提供する」と考えたとき、最初に問題になるのは、どこまでをAPIに含めるかです。
例えば、次のAPIは簡単に見えます。
$response = http3_request('https://example.com/');
しかし、このAPIが担当するのはQUICだけではありません。
- DNS名前解決
- UDPソケット
- TLS証明書検証
- QUIC接続
- QUICストリーム
- HTTP/3
- QPACK
- リダイレクト
- Cookie
- プロキシ
- 接続プール
これは、QUIC APIというよりHTTPクライアントAPIです。
一方で、ext-ngtcp2のような低水準APIでは、呼び出し側が次の処理を管理します。
- UDPデータグラムの送受信
- タイマー
- イベントループ
- 複数接続の管理
- 上位プロトコルとの接続
高水準APIは使いやすい一方で、利用できる実行環境や用途を限定します。
低水準APIは組み合わせやすい一方で、呼び出し側の責任が増えます。
QUICの標準APIが決めにくい理由は、単にQUICの仕様が複雑だからではありません。
QUIC、ソケット、イベントループ、HTTP/3のうち、どこまでを一つのAPIの責務に含めるかで、まったく異なる設計になるからです。
Debian 12ではGnuTLSを選んだ
ext-ngtcp2はDebian 12で開発したため、暗号バックエンドにはGnuTLSを採用しました。
ビルドでは、次のライブラリを利用します。
libngtcp2
libngtcp2_crypto_gnutls
gnutls
これは、開発環境で利用できるパッケージ構成を優先した結果です。
QUICではTLS 1.3がトランスポート処理と密接に結びついているため、ngtcp2とTLSライブラリの間には専用の接続処理が必要です。
Debian 13以降ではOpenSSL 3.5系を利用できるため、新しく設計するならOpenSSLバックエンドも有力な候補になります。
ただし、公開するQUIC APIと暗号バックエンドは分離しておくべきです。
QUIC共通処理
├─ GnuTLSバックエンド
└─ OpenSSLバックエンド
利用するTLSライブラリが変わっても、PHP側のConnectionやStreamの使い方まで変わる構造は避ける必要があります。
GnuTLSからOpenSSLへの変更は、QUIC APIの変更ではなく、内部バックエンドの差し替えとして扱うのが望ましいでしょう。
まとめ
ext-ngtcp2では、QUICをTCPソケットの代用品として公開しませんでした。
代わりに、次の要素を分けて扱っています。
- UDPデータグラム
- QUIC接続
- QUICストリーム
- イベント
- タイマー
- HTTP/3
UDPソケットとイベントループをPHP側に残し、拡張はQUICの状態機械として動作します。
その結果、ext-nghttp3を上位層として独立させることができました。
QUICをTCP風のAPIに押し込まなかったからこそ、QUICとHTTP/3の責務を分けられたともいえます。
Sans-I/Oは、単にI/Oを外へ出す設計ではありません。異なるプロトコル層と実行環境を組み合わせるための責務分離です。