メインコンテンツへスキップ
  1. Posts/

[Network] HTTP、gRPC、多重化の整理 — ステートレス性、HTTP/2、Java NIO

NineKoo9
著者
NineKoo9
目次

このドキュメントは疑問に思っていた点をAIエージェントと対話しながらまとめたメモです。HTTPのステートレス性と接続の再利用、gRPCのHTTP/2使用理由、HTTP/2多重化とJava NIO多重化の違いをまとめています。

HTTP / gRPC / 多重化の整理
#


1. HTTPのステートレス性とTCP接続管理の関係
#

HTTPのステートレス性とは?
#

RFC基準でHTTPの核心的な性質は**ステートレス性(statelessness)**です。
つまり、各リクエストは前のリクエストとは独立して理解できるものでなければならず、サーバーが前のリクエストのコンテキストを必ず覚えている必要はありません。

これはTCPを毎回切断するという意味ではありません。
HTTPのステートレス性はアプリケーションのセマンティクスであり、TCP接続の再利用有無は**接続管理(connection management)**の問題です。


TCP接続のライフサイクル — HTTPバージョン別
#

HTTP/1.0(初期)
#

クライアント            サーバー
    |-- TCP 3-way handshake -->|
    |-- HTTP リクエスト ------->|
    |<-- HTTP レスポンス --------|
    |-- TCP 終了 (4-way) ------>|   ← リクエストごとに接続/切断

リクエスト/レスポンスのペアごとにTCPを新たに確立して切断していました。非効率。


HTTP/1.1 — Persistent Connectionがデフォルト
#

クライアント            サーバー
    |-- TCP 3-way handshake -->|
    |-- リクエスト1 ----------->|
    |<-- レスポンス1 ------------|
    |-- リクエスト2 ----------->|  ← 同じTCP接続を再利用
    |<-- レスポンス2 ------------|
    |-- リクエスト3 ----------->|
    |<-- レスポンス3 ------------|
    |-- TCP 終了 ------------->  |  ← 一定時間後または明示的な終了

HTTP/1.1からはpersistent connectionがデフォルトです。
別途Connection: closeを送らない限りTCP接続を維持します。


HTTP/2、HTTP/3
#

  • HTTP/2: 1つのTCP接続で多重化(複数のリクエストを並列に)
  • HTTP/3: TCPの代わりにQUIC(UDP基盤)を使用し、接続のオーバーヘッドをさらに削減

バージョン別比較
#

HTTP/1.0HTTP/1.1HTTP/2
デフォルト動作リクエストごとにTCPを再生成TCPを再利用(keep-aliveがデフォルト)1つのTCPで複数リクエスト
Keep-Aliveオプション(ヘッダーの明示が必要)デフォルト値別途のConnection: keep-aliveという概念ではなく、持続接続がデフォルト

核心ポイント
#

  • ステートレス性 = サーバーが各リクエストを独立して処理する(stateless)
  • Keep-Alive = TCPの物理接続を再利用する(persistent connection)
  • 両者は異なる次元の概念です。ステートレス性(HTTPのセマンティクス)とKeep-Alive(TCP接続管理)は互いに独立しています。

2. gRPCの通信プロトコル
#

レイヤー構造
#

┌─────────────────────────────┐
│        gRPC(アプリケーション)│  ← サービス/メソッド定義(proto)
├─────────────────────────────┤
│        HTTP/2               │  ← gRPCを運ぶアプリケーションプロトコル
├─────────────────────────────┤
│        TLS(任意、通常推奨)  │  ← 暗号化
├─────────────────────────────┤
│        TCP                  │  ← 接続
└─────────────────────────────┘

標準gRPCはHTTP/2上にマッピングされるRPC(Remote Procedure Call)フレームワークです。
TLSは実務では非常に一般的ですが、gRPC自体がTLSを強制しているわけではありません。


gRPCがHTTP/2を使う理由
#

① 多重化

1つのTCP接続で:
Stream 1: リクエストA ─────────────► レスポンスA
Stream 3: リクエストB ──────────────────────► レスポンスB
Stream 5: リクエストC ───────────────────────────► レスポンスC

複数のRPC呼び出しが同時に1つの接続を共有します。

参考として、クライアントライブラリのchannelは実装によって複数のHTTP/2接続を管理することがあります。
ただし個々のRPC呼び出しはHTTP/2のstreamとして配信されると理解しておくとよいです。

② ストリーミングのサポート
HTTP/2のストリームの概念のおかげで、gRPC固有の4種類の通信パターンが可能になります。


gRPCの4種類の通信パターン
#

1. Unary(通常のリクエスト/レスポンス)
#

クライアント ──リクエスト1件──► サーバー
            ◄─レスポンス1件──

形式的に最も馴染みのあるリクエスト/レスポンスパターンです。


2. Server Streaming
#

クライアント ──リクエスト1件──► サーバー
            ◄─レスポンス1──
            ◄─レスポンス2──
            ◄─レスポンス3──
            ◄─END──

例: リアルタイム株価、ログのストリーミング


3. Client Streaming
#

クライアント ──リクエスト1──► サーバー
            ──リクエスト2──►
            ──リクエスト3──►
            ◄─レスポンス1件──

例: ファイルアップロード、センサーデータのバッチ送信


4. Bidirectional Streaming
#

クライアント ──リクエスト1──► サーバー
            ◄─レスポンス1──
            ──リクエスト2──►
            ◄─レスポンス2──
            (双方向自由に)

例: チャット、リアルタイムゲーム、自動運転のテレメトリー


データのシリアライズ: Protocol Buffers
#

実務でRESTがJSONとよく使われるように、gRPCは基本的にProtobufを使用します。
ただしRESTがJSONだけに縛られないのと同様に、gRPCも他のデータ形式に拡張できます。

service TelemetryService {
  rpc SendData (stream TelemetryData) returns (Ack);
}

message TelemetryData {
  float speed = 1;
  float latitude = 2;
  float longitude = 3;
}

JSON比での一般的なメリット:

  • サイズが小さくなる可能性が高い(バイナリ)
  • パース/シリアライズのパフォーマンスが有利なケースが多い
  • スキーマベースなので型安全性とコード生成に有利

REST vs gRPC 比較
#

注意: RESTはプロトコル名ではなくアーキテクチャスタイルです。以下の表は厳密な定義の比較というよりも、実務でよく見かける組み合わせを比較したものです。

RESTgRPC
プロトコルHTTPセマンティクス上(HTTP/1.1、HTTP/2、HTTP/3など)標準gRPCはHTTP/2
データ形式JSONが一般的Protobufがデフォルト
通信パターンリクエスト/レスポンス中心4種類のパターン
インターフェース定義OpenAPIなど任意.protoを使うのが一般的
ブラウザのサポート広いオリジナルgRPCは制限的、通常grpc-webを使用
用途公開API、汎用連携サービス間通信、低レイテンシ/ストリーミング

3. HTTP/2 多重化の詳細
#

HTTP/1.1の問題: HOL Blocking
#

HTTP/1.1では同じ接続上でレスポンスをリクエストの順番に合わせて返さなければならないため、前のリクエストが遅延すると後のリクエストも遅延しやすくなります。
過去のpipeliningもこの問題を根本的に解決できませんでした。

TCP接続1: リクエストA ──► [レスポンスAを待ち中...] ──► リクエストB ──► リクエストC

リクエストAのレスポンスが遅れるとB、CがすべてブロッキングされるのがこれがHOL Blockingです。

ブラウザはこれを回避するために通常複数の並列TCP接続を開いて対処してきました。


HTTP/2の核心概念 3つ
#

1. Stream(ストリーム)
#

1つのTCP接続の中に作られる独立した仮想チャネル

  • 各ストリームは固有のStream IDを持ちます(奇数: クライアント開始、偶数: サーバー開始)
  • 同時に複数のストリームが存在できます

2. Frame(フレーム)
#

HTTP/2の最小転送単位。すべてのデータはフレームに分割されて転送されます。

┌──────────────────────────────────────┐
│  Length (3B) │ Type (1B) │ Flags (1B)│
├──────────────────────────────────────┤
│         Stream ID (4B)               │
├──────────────────────────────────────┤
│         Payload ...                  │
└──────────────────────────────────────┘

主なフレームタイプ:

  • HEADERS: HTTPヘッダーを転送
  • DATA: 実際のbodyデータ
  • SETTINGS: 接続設定
  • WINDOW_UPDATE: フロー制御

3. Message(メッセージ)
#

1つのHTTPリクエストまたはレスポンス。複数のフレームの集まりとして表現されます。

リクエスト1件 = HEADERSフレーム + DATAフレーム(群) + 必要に応じてtrailing HEADERS

多重化の動作原理
#

1つのTCP接続でフレームが混在して転送され、Stream IDによってどのリクエストのフレームかを区別します。

時間 →
──[S1:HEADERS]──[S3:HEADERS]──[S1:DATA]──[S5:HEADERS]──[S3:DATA]──[S1:DATA]──►
     ↑               ↑            ↑            ↑
  リクエストA開始  リクエストB開始  リクエストAのbody  リクエストC開始

受信側はStream IDを見て各リクエストに再組み立てします。


HOL Blockingの解決
#

HTTP/1.1:
リクエストA [==============遅いレスポンス==============] リクエストBは待機...

HTTP/2:
Stream1(リクエストA): [==============遅いレスポンス==============]
Stream3(リクエストB): [==速いレスポンス==]  ← リクエストAを待たない!
Stream5(リクエストC): [====レスポンス====]   ← 独立して処理

追加機能
#

優先度シグナル: 以前はストリームごとに重みと依存関係を送ることができましたが、このpriority signalingは最新のHTTP/2 RFCでdeprecatedになりました。

フロー制御(Flow Control): ストリーム単位だけでなく接続全体単位でも受信ウィンドウを調節します。WINDOW_UPDATEフレームで制御します。


限界: TCPレベルのHOL Blockingは依然として存在
#

TCPパケットロスが発生した場合:
パケット1 (Stream1 DATA) ✓
パケット2 (Stream3 DATA) ✗ ← 損失!
パケット3 (Stream5 DATA) ✓ ← 再送信待ち... 全ストリームがブロッキング
  • HTTP/2はHTTPレイヤーのHOL Blockingは解決
  • TCPパケットロス時に全ストリームが止まる問題は依然として存在
  • これを解決したのがHTTP/3 + QUIC(UDP基盤)

4. HTTP/2多重化 vs Java NIO I/O多重化
#

共通した哲学
#

「1つの主体が複数のチャネルを同時に処理する」

名前は同じですが、異なるレイヤーの概念です。


比較
#

HTTP/2 多重化Java NIO I/O 多重化
レイヤーアプリケーションレイヤー(HTTP/2がTCP上で動作)OS/ランタイムレイヤー
多重化の対象1つのTCP接続内のストリーム群複数のTCP接続(ソケット)群
核心的な問題複数のリクエストを1つの接続で処理複数の接続を1つのスレッドで処理

Java NIO I/O多重化
#

従来の方式(Blocking I/O)
#

スレッド1 ──────► ソケットA(接続を待ち中... blocked)
スレッド2 ──────► ソケットB(データを待ち中... blocked)
スレッド3 ──────► ソケットC(データを待ち中... blocked)

接続1本に対してスレッド1本。接続が1万あればスレッドも1万 → メモリ/コンテキストスイッチングが爆発

NIOの方式
#

                  ┌── ソケットA(イベントがあるときだけ通知)
Selector(1個)────┼── ソケットB(イベントがあるときだけ通知)
スレッド1個        └── ソケットC(イベントがあるときだけ通知)

SelectorSelectableChannelを扱うJavaの多重化器です。内部的にはプラットフォームごとのI/O多重化メカニズム(selectpollepollkqueueなど)に依存して動作します。
ソケットを登録しておくと、イベントが発生したソケットだけを選んで通知してくれます。

Selector selector = Selector.open();

socketA.register(selector, SelectionKey.OP_READ);
socketB.register(selector, SelectionKey.OP_READ);
socketC.register(selector, SelectionKey.OP_READ);

while (true) {
    selector.select(); // イベントのあるソケットが出るまで待機

    for (SelectionKey key : selector.selectedKeys()) {
        if (key.isReadable()) {
            SocketChannel ch = (SocketChannel) key.channel();
            ch.read(buffer);
        }
    }
}

スレッド1つで何千ものソケットを処理できます。


2つの概念の位置 — 具体的なシナリオ
#

クライアント3人がサーバーに接続してそれぞれ複数のリクエストを送る状況:

① TCP接続(ソケット)が3つできる

クライアント1 ──────── ソケットA ──┐
クライアント2 ──────── ソケットB ──┤── サーバー
クライアント3 ──────── ソケットC ──┘

② NIO多重化 = ソケット3つを監視する問題

ソケットAにデータが来たか?
ソケットBにデータが来たか?  ← これをスレッド1つで監視
ソケットCにデータが来たか?

NIO多重化の関心事 = 複数のソケット(接続)をいかに効率よく監視するか

③ HTTP/2多重化 = 各ソケット内の問題

ソケットA(クライアント1とのTCP接続)内に:
  ├── Stream 1: GET /user
  ├── Stream 3: GET /posts
  └── Stream 5: GET /images

HTTP/2多重化の関心事 = 1つのソケット内で複数のリクエストをいかに同時に処理するか

④ 全体像(実際のフレームワークでは2つの概念が一緒に動作する)

サーバー

Selector(スレッド1つでソケット3つを監視)  ← NIO多重化
  ├── ソケットA内 → Stream1、Stream3、Stream5  ← HTTP/2多重化
  ├── ソケットB内 → Stream1、Stream3
  └── ソケットC内 → Stream1

一言まとめ
#

対象役割
NIO多重化複数のソケット(接続)スレッド1つで複数のソケットを外から監視
HTTP/2多重化複数のストリーム(リクエスト)1つのソケット内を複数のレーンに分ける

Netty、Spring WebFluxのようなフレームワークはこの2つの概念を合わせて活用しています。

参考資料
#