QUICを利用するためのプログラミングAPI

Jun-ya Kato
nttlabs
Published in
22 min readJun 16, 2021
図1: QUICスタックとアプリケーション

2021年5月末にQUICと周辺仕様を規定したRFC 4編 (RFC8999, RFC9000, RFC9001, RFC9002) が発行されました。この記事では、“アプリケーションを開発するプログラマ視点から、QUICをどのように利用するのか?” について取り上げます。3つのQUICスタックについて

  1. Cloudflare quiche
  2. Apple macOS 12 Monterey, iOS 15
  3. Microsoft QUIC (msquic)

クライアントを実装するためのAPIを見ていきます。

なお、2. については、2021年6月にAppleの開発者会議 WWDC2021 (World Wide Developer Conference 2021)にて発表された、AppleのQUIC実装とプログラミングAPIです。

古典的なソケットAPIを使ったプログラミング

TCPを利用するベーシックなプログラムをC言語で書く場合、ソケットAPIもしくは、WinSockのようなソケットを模したAPIが使われることが多いです。下記は同期型のソケットを使った単純なTCPクライアントの例です。TCPソケットを生成、サーバに接続、I/Oを行うのが基本的な流れまでが記述範囲で、TCPコネクション管理や、ストリームデータの分割、再構成の処理はソケットAPIより下層のカーネル空間のTCPスタックが担うため、アプリケーションのコードに現れません

#include <sys/socket.h>
#include <netdb.h>
/*
* ソケットのディスクリプタを生成する
*/
sock = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);
/*
* 名前解決:接続先のホスト名とサービス名からIPアドレスと
* ポート番号を解決しソケットアドレスへ変換しaiへ格納
*/
n_err = getaddrinfo("www.example.com", "https", ....., ai);
/*
* TCP 3ウェイハンドシェイクを開始し接続完了を待つ
*/
c_err = connect(sock, ai_addr, 0);
/*
* TCPコネクションが確立したら、以降はディスクリプタ
* sockを通じて、ストリームデータの受送信を行う
*/
w_len = write(sock, sendbuf, len. ....);
r_len = read(sock, recvbuf, len, ....);

QUICはUDPの上に構築されるトランスポートプロトコルですが、単純に、IPPROTO_TCPIPPROTO_UDP に置き換えて利用するものではありません。

どこかで、UDPソケット(SOCK_DGRAM)を使って受送信を行うことになりますが、コネクションだけでなくストリーム処理、TLSなど、TCPにはない機能の制御とあわせて記述する必要があります。QUICはTCPに比べて機能が多く、例えば、IPアドレスやポート番号の変更に動的に追従するコネクションマイグレーションはTCPとはコネクションの概念が異なるため、制御のコードも変わってきます。

以下の節でいくつかのQUICスタックに添付されるサンプルを例に取り、クライアント実装の視点からAPIの使われ方を見ていきます。スタックごとにソケットAPIの使い方や各種の制御方法が異なるのが興味深い点です。

1. Cloudflare quiche

Cloudflareによるquicheは、Rust言語で書かれたQUICスタックですが、C言語からAPIを利用する言語バインディングとサンプルコードも提供しています。ソケットAPIの使い方が見えやすいので、C言語によるクライアントのサンプルコードを見ていきます。

アプリケーションとquicheのおおまかな役割分担は以下のようになっています。

  • UDPソケットを使ったI/Oはアプリケーション側で実装する。
  • 受信したUDPペイロード(QUICパケット)をquicheに渡し、QUICプロトコル処理の解釈をして内包しているデータを取り出す。送信については、quicheにデータを渡してUDPパケットのペイロードをフォーマッティングしてもらう。

もう少し詳細にクライアント実装の流れを見てい行きます。

  1. ノンブロッキングのUDPソケットの生成をアプリケーション側で(プログラマが)作っておきます。
  sock = socket(peer->ai_family, SOCK_DGRAM, 0);
fcntl(sock, F_SETFL, O_NONBLOCK);
connect(sock, peer->ai_addr, peer->ai_addrlen);

ここで、UDPソケットに対してconnect()を呼び出していますが、宛先のIPアドレスとポート番号を固定するだけの操作です。ここでネゴシエーションを開始するInitialパケットが送信されることはありません。

2. QUICスタックの設定を行う

主にネゴシエーション時に使用する、ALPNやトランスポートパラメータなどスタック設定を行います。

  config = quiche_config_new();
quiche_config_set_application_protos(config, ALPN設定情報, .....);
quiche_config_set_PARAMETER_XXXXX(config, .....)
quiche_config_set_PARAMETER_YYYYY(config, .....)
quiche_config_set_PARAMETER_ZZZZZ(config, .....)

3. Initialパケットを作って送信バッファに積む

quiche_connect()が、Initialパケットのビット列を形成してquicheの内部に用意した送信バッファに積みます。ここでの処理は送信バッファに積むまでで、send()はまだ呼ばれません。Initialパケットがワイヤ上に送信されるのは次のステップです。

  conn = quiche_connect(host, (const uint8_t *) scid,
sizeof(scid), config);

4. libevによるイベントループ設定とInitialパケット送信処理

ここで、1. で作ったUDPソケットへのデータ到着を監視するオブジェクトと、それに対応したコールバック関数recv_cb()を設定してイベントループに入ります。

  ev_io watcher;
ev_io_init(&watcher, recv_cb, conn_io->sock, EV_READ);
ev_io_start(loop, &watcher);
ev_init(&conn_io->timer, timeout_cb);
flush_egress(loop, conn_io);
ev_loop(loop, 0);

ev_loop()でイベントループに入る直前で、flush_egress()を呼び出し、送信バッファにデータがあればsend()を呼び出してワイヤ上にUDPパケットを送信します。先の2と3のステップで、Initialパケットがバッファに積まれてますのでsend()による送信処理が行われます。

5. サーバからの到着するInitialパケットの受信とストリームデータの送信

サーバからのInitialパケットがUDPソケットのバッファ到着すると、イベントループの中でコールバック関数recv_cb()が呼び出されますので、この内部でrecv()を呼び出してUDPパケットを受信します。そしてUDPペイロードをquiche_conn_recv()関数を使ってquicheへ渡します。サーバからのInitialパケットが含まれていますので、quicheに解釈(プロトコル処理を)させます。サーバからのInitialパケットの解釈の結果、接続が完了するとコネクションの状態がestablishedに遷移します。

recv_cb()
{
ssize_t read = recv(conn_io->sock, buf, sizeof(buf), 0);
ssize_t done = quiche_conn_recv(conn_io->conn, buf, read);
.
.
}

次に、quiche_conn_stream_send()を使って送信したいユーザデータを含むストリームフレームを作ります。サンプルコードではストリームID (第2引数)が4で決め打ちになっていますが、コネクション内で異なるストリームに対しては重複がないよう管理する必要があります。quiche_conn_stream_send()で作ったストリームフレームを送信バッファに積み、最後にflush_egress()を呼びsend()でワイヤ上にUDPパケットを送り出します。

  /*
* サーバからのInitialパケットのの解釈の結果、接続が完了すると
* コネクションが established 状態となっている
* req_sent変数は初回のGETリクエストを出したら、falseからture
* へ変わり、以降はGETのレスポンス受信処理へ分岐する
*/
if (quiche_conn_is_established(conn_io->conn) && !req_sent) {
/* 初回のGETリクエスト送信処理。サンプルコードではリクエストは1回だけ */
const static uint8_t r[] = "GET /index.html\r\n";
quiche_conn_stream_send(conn_io->conn, 4, r, sizeof(r), true);
req_sent = true;
}
.
.
flush_egress(loop, conn_io); .
.
}

なお、ストリームIDの番号付はクライアント主導で生成する場合は偶数番、サーバ主導で生成する場合は奇数を使うことを規定しています。

6. GETメソッドに対する応答の受信処理

最後に、アプリケーションがGETメソッドに対する応答データを読み取るまでの流れを見ます。サーバが正しく応答した場合、GETしたデータがUDPソケットのバッファに到着します。libevのイベントループ中で再度、受信コールバック関数recv_cb()が呼び出されます。

recv_cb()
{
ssize_t read = recv(conn_io->sock, buf, sizeof(buf), 0);
ssize_t done = quiche_conn_recv(conn_io->conn, buf, read);
if (quiche_conn_is_established(conn_io->conn)) {
while (quiche_stream_iter_next(readable, &s)) {
recv_len = quiche_conn_stream_recv(conn_io->conn, .....
buf, sizeof(buf), .....);
/* ここでアプリケーションはデータを参照可能 */
}
}

受信したフレーム含むUDPパケットをrecv()で受信して、コネクションが確立しているか判定することまでは同じですが、ストリームデータの受信処理では、quiche_conn_stream_recv()を使ってデータを取り出します。この段階でアプリケーションはデータ(GETメソッドの応答)を参照することができるようになりました。

1~6の手順を見ると、プロトコル処理のうちQUICパケットのビット列の解釈をquiche側で行いつつ、UDPパケット受送信に必要なソケットAPI処理はアプリケーション側に記述する分担です。性能に直結するソケットI/Oの処理をプログラマ側に委ねており、チューニングのための工夫の余地が与えられているとも言えます。

2. macOS 12 Monterey, iOS 15

Appleが開催したWorld Wide Developer Conference 2021 (WWDC2021) の中で発表した、“Accelerate networking with HTTP/3 and QUIC” では、macOS 12 MontereyやiOS 15から Network.framework にQUICが統合されたことが発表されました。コードの断片を紹介すると、

図2: WWDC2021でのAppleからの発表内容 (Network.frameworkへの統合)

NWConnectionの下回りプロトコルとしてNWProtocolQUICを使うように設定します。アプリケーションから見たデータ受送信の作法はTCPのそれと同じとなります(図2)。

単一のコネクションを扱うだけでは、マルチストリームに対応できないのでは?と疑問が湧きますが、NWMultiplexGroupを用意しており、NWConnectionのアンダーレイとして入れることで、

図3: WWDC2021でのAppleからの発表内容 (マルチストリームへの対応)
 connection = NWConnection(from: group)

ストリームの生成をコネクション生成に対応付けることで、TCPコネクションを扱う方法と同じ作法で、ストリームを扱えるコードが示されていました(図3)。

Cloudflare quicheと比べて、UDPパケットの受送信に関するソケットAPIはプログラマからは完全に隠蔽され見えません。AppleのAPIは、OSが従前より提供かつ抽象度の高いフレームワークをそのまま使うことができ、QUICで新たに生じる項目の学習コストが小さいと言えそうです。一方、ローレベルのI/Oが見えなくなる分、性能を突き詰めるチューニングの余地が小さい可能性はあります。

3. Microsoft QUIC (msquic)

msquicは、Microsoftが提供するQUICスタックで、ターゲットプラットフォームはWindows, Linux です。プログラミングインターフェイスとしてコールバックによる非同期処理を提供しています。ソケットI/Oなどのローレベルなデータパスはプログラマからは見えないように抽象化されていますが、比較的プロトコル仕様に忠実な設計の印象です。既存のAPIを踏襲するわけではなく、新規設計(学習コストは発生する)APIとなっています。

興味深い点は、QUICスタックの機能をユーザ空間でライブラリとして呼び出す形態だけでなく、Windowsではカーネルへの統合も想定しており、データパスをカーネルに組み込む形態も取れます。後者の場合、データパスとしてWinSock APIは使用せずカーネル内のI/Oルーチンを直接呼び出します。Microsoftの実装を見て、QUICスタックは必ずしもユーザ空間だけに実装されるものではないと認識しました。SMB over QUIC のようなストレージトラフィックの転送も想定しているので、Web用途以上にパフォーマンスを重要視しているのかもしれません。

それでは、サンプルコードのクライアントの基本的な動きを順を追って見ていきます。

  1. 初期化とスタックの設定実施
図4: MsQuicのAPIモデル

まずライブラリ初期化処理をおこないます。APIの初期化(関数アドレスの初期化)と、レジストレーションと呼ばれるアプリ内でスタックを管理するオブジェクトの生成です(図4)。その後、スタック設定を実施します。設定の主な内容はバッファサイズやALPNなどのトランスポートパラメータに反映される内容です。

  /*
* ライブラリの初期化
* 以降、すべてのオペレーションは MsQuic ハンドルを通じて行う
*/
Status = MsQuicOpen(&MsQuic);
/*
* QUICスタックを管理するオブジェクトを生成&登録する
*/
Status = MsQuic->RegistrationOpen(&RegConfig, &Registration);
/*
* QUICスタックの設定を行う
*/
Status = MsQuic->ConfigurationOpen(Registration, &Alpn, 1, &
Settings, sizeof(Settings),
nullptr, &Configuration);

2. コネクション開始の下準備とコネクションイベントハンドラの設定

次に、サーバに対してコネクションを張るための下準備をします。コールバック関数 ClientConnectionCallback を設定するところがポイントで、コネクションの接続完了、切断などのイベントが生じた際に非同期で呼び出されます。

  /*
* コネクション接続イベントを処理するコールバックを仕掛ける
*/
Status = MsQuic->ConnectionOpen(Registration,
ClientConnectionCallback,
nullptr, &Connection);
/*
* TLSで使う証明書 (サーバ証明書を検証するか?など) の設定です
*/
Status = MsQuic->ConfigurationLoadCredential(Configuration,
&CredConfig, ……);

3. サーバへのコネクションの開始

コネクションを張る下準備が整ったら、サーバのIPアドレスとポート番号を指定してコネクションを開始します。MsQuic->ConnectionStart()を呼び出すと、クライアントからサーバ向けてInitialパケットが投げられ、サーバからの応答を待つイベントループに入ります。

 /*
* 接続先のTarget IPアドレスやポート番号を指定してコネク
* ションを開始する。ワイヤ上にInitialパケットが送信される
*/
Status = MsQuic->ConnectionStart(Connection, Configuration,
QUIC_ADDRESS_FAMILY_UNSPEC,
Target, UdpPort);

4. サーバから到着するInitialパケット受信と、ストリーム開始までの処理

サーバからのInitialパケットをクライアントが受信するとAPI内部で接続処理が行われます。無事に接続が完了していると、QUIC_CONNECTION_EVENT_CONNECTED イベントが発生をステップ2で設定したコールバック関数ClientConnectionCallback()で通知します。本イベントが発生した時点では、Initialパケットを含むUDPパケット受信処理や、内包するCRYPTOフレームの検証はAPIの内側で処理されており、プログラマには接続完了が通知されるだけでコネクションは開設済みの状態です。例外時は別のイベントが発生することなります。ここでは正常系を想定して、続いてコールバック関数ClientConnectionCallback()の中でストリームを生成に移ります。

まず、MsQuic->StreamOpen() を使いストリーム開設の下準備を始めます。続けてストリームを生成するMsQuic->StreamStart()を呼び出します。コネクションを作る段階で必要なネゴシエーションは完了しているので、ストリームを設定するための新たなネゴシエーションは行われません。MsQuic->StreamOpen()で設定するコールバック関数ClientStreamCallback()を設定していますが、これはストリーム上でのデータの到着や切断等のためのイベント処理を行うものです。

  /*
* ストリームを生成するための下準備として、ストリーム
* イベント処理するコールバックを仕掛ける。
*/
Status = MsQuic->StreamOpen(Connection,
QUIC_STREAM_OPEN_FLAG_NONE,
ClientStreamCallback, nullptr,
&Stream);
/*
* ストリームを生成する
*/
Status = MsQuic->StreamStart(Stream,
QUIC_STREAM_START_FLAG_NONE);

5. ストリーム上へのユーザデータの送信処理

アプリケーションがストリーム上にユーザデータを送信するにはMsQuic->StreamSend() 関数を使います。送信はブロックされません。送信結果に応じたイベント(送信完了時はQUIC_STREAM_EVENT_SEND_COMPLETE)が発生しコールバック関数が呼ばれますので、データ送信の完了確認やエラー処理を行います。

  /*
* ストリーム上にユーザデータ (SendBuffer) を送信する
*/
Status = MsQuic->StreamSend(Stream, SendBuffer, 1,
QUIC_SEND_FLAG_FIN, SendBuffer));6.

6. ストリーム上での受信処理

ストリーム上でのデータの受信時は、サーバからのデータがバッファに到着すると、QUIC_STREAM_EVENT_RECEIVE イベントが発生しコールバック関数 ClientStremCallback()が呼ばれます。受信は非同期に行われブロックは起きません。

ClientStreamCallback()
{
switch (Event->Type) {
case QUIC_STREAM_EVENT_RECEIVE:
//
// Data was received from the peer on the stream.
//
/* RECEIVE.Buffers に受信データが置かれている */
Event->RECEIVE.Buffers[x].Buffer /* 受信データチャンク */
Event->RECEIVE.Buffers[x].Length /* 受信データチャンクの長さ */
Event->RECEIVE.BufferCount
break;
.
.
}
}

ソケットバッファへのデータが到着したことを示す通知ではなく、アプリケーションが参照可能な領域にデータが到着したことを通知する仕組みです。プログラマが明示的にrecv()に相当する受信処理を行う必要はありません。なお、アプリケーションが受信バッファを読み取り後、不要となったら、MsQuic->StreamReceiveComplete() でバッファを開放する必要があります。

まとめ

3つのQUICスタックについて、サンプルコードとプログラミングAPIを見てきましたが、それぞれAPIも背景にあると思われる設計思想も異なっていました。多数あるQUICスタックはそれぞれ開発目的が異なっています。汎用性を重視した実装もあれば、モバイルデバイスようなクライアント利用を想定したもの、ハイパフォーマンスなWebサーバのために開発したものなど目的は様々です。

言語、OSのバリエーションも豊富で、スタックを利用するプログラマからすれば、様々な選択肢があり幸運な状況です。一方、APIの共通性は低いためスタックをまたぐポーティングには手間を要しそうです。利用しようとしているスタックが、開発したいアプリケーションの目的に見合うかは、十分に評価する必要があります。

QUICはTCPと比べて巨大な機能の集合体であり、かつどんな用途にも適合するAPIを設計して標準化することは容易ではあありません。しかし、筆者としては、RFC6458 “Sockets API Extensions for the Stream Control Transmission Protocol (SCTP)” のようなAPIやデータ構造の大まかな指針がInformationalでも整備されると、プログラマの負担軽減につながると感じています。

参考資料

Postscript

NTT研究所は、新しいトランスポートプロトコルでネットワークを(ハイパフォーマンスなサーバからクライアントまで含めてE2Eで)高速化することに関心のある仲間を募集中です。ご連絡をお待ちしています。

--

--