gRPC のチャネルとコールの内側:チャネル生成から RPC 完了まで
前提知識
- ›第 1 回:アーキテクチャとコードベースのナビゲーション
- ›C++ の習熟(継承、テンプレート、ムーブセマンティクス、スマートポインタ)
- ›非同期プログラミングパターンの理解(コールバック、futures)
gRPC のチャネルとコールの内側:チャネル生成から RPC 完了まで
第 1 回では、gRPC コアリポジトリ全体を俯瞰し、CoreConfiguration がすべてのサブシステムをどのように結びつけているかを確認しました。今回は、あらゆる RPC が通過する 2 つの抽象概念、すなわちチャネル(ターゲットへの持続的な接続)とコール(個々の RPC ライフサイクル)の内側へと踏み込みます。gRPC コアのどの部分を読む際にも、この 2 つの概念を押さえておくことが不可欠です。
特に興味深いのは、gRPC が現在大規模な内部移行の真っ只中にある点です。C スタイルのチャネルスタックをベースとした V2 アーキテクチャから、promise ベースのインターセプションチェーンで構成された V3 アーキテクチャへの移行が進んでいます。両システムは現在も共存しており、チャネルの階層構造にはこの二重性が如実に現れています。
チャネルの階層構造
src/core/lib/surface/channel.h で定義されている Channel 基底クラスは、2 つの重要な型を継承しています。
class Channel : public UnstartedCallDestination,
public CppImplOf<Channel, grpc_channel> {
CppImplOf<Channel, grpc_channel> は CRTP パターンの実装で、不透明な C 型 grpc_channel* と C++ の Channel* の間で変換を行う FromC() および c_ptr() メソッドを提供します。このパターンは gRPC 全体に登場します——C API の不透明ポインタを内部の C++ オブジェクトにマッピングする仕組みです。
UnstartedCallDestination は、すべてのチャネルをコールのルーティング先として機能させます。これが V3 のインターフェースです。コールは UnstartedCallHandler オブジェクトとして届き、チャネルの StartCall() メソッドによって処理が開始されます。
具体的なチャネル実装は 3 種類あり、さらにエラー用の特殊なチャネルが存在します。
classDiagram
class Channel {
<<abstract>>
+CreateCall()
+StartCall()
+IsLame()
+target()
+channelz_node()
}
class ClientChannel {
+resolver_
+lb_policy_
+picker_
+call_destination_
}
class DirectChannel {
+transport_call_destination_
+interception_chain_
}
class LegacyChannel {
+channel_stack_
+is_client_
}
Channel <|-- ClientChannel : V3 full-featured
Channel <|-- DirectChannel : V3 lightweight
Channel <|-- LegacyChannel : V2 compatibility
ClientChannel(src/core/client_channel/client_channel.h)は、フル機能を備えた V3 実装です。リゾルバ、LB ポリシー、サブチャネルプール、そしてサブチャネルピッカーの更新を伝播するための PickerObservable を持ちます。新しいスタックにおける dns:/// や xds:/// などの URI ベースターゲット向けのチャネル型です。
DirectChannel(src/core/client_channel/direct_channel.h)は、単一トランスポートへの直接接続向けの軽量な V3 パスです。リゾルバも LB も持たず、TransportCallDestination(ClientTransport を所有)と InterceptionChain を保持します。接続監視メソッドでは明示的にクラッシュするよう実装されている点も特徴的です(70〜76 行目)——これらの機能は直接チャネルには適用されないためです。
LegacyChannel(src/core/lib/surface/legacy_channel.h)は、旧来の V2 フィルタチェーンである grpc_channel_stack をラップします。StartCall() はクラッシュするよう実装されており(69〜71 行目)、V2 のコールは別のパスを通るためです。
チャネル生成のフロー
C API から grpc_channel_create() が呼び出されると、制御は src/core/lib/surface/channel_create.cc の ChannelCreate() に渡されます。この関数が、どのチャネル実装を使用するかを決定する分岐点です。
flowchart TD
API["grpc_channel_create()"] --> CC["ChannelCreate()"]
CC --> Args["Process channel args<br/>canonify target, set authority,<br/>create channelz node"]
Args --> V3{"GRPC_ARG_USE_V3_STACK?"}
V3 -->|false| Legacy["LegacyChannel::Create()"]
V3 -->|true| Type{"channel_stack_type?"}
Type -->|CLIENT_CHANNEL| Client["ClientChannel::Create()"]
Type -->|CLIENT_DIRECT| Direct["DirectChannel::Create()"]
V2/V3 の選択は、チャネル引数 GRPC_ARG_USE_V3_STACK(99 行目)によって決まります。V3 が有効でない場合はすべて LegacyChannel を経由します。V3 が有効な場合、GRPC_CLIENT_CHANNEL(リゾルバ + LB あり)は ClientChannel::Create() へ、GRPC_CLIENT_DIRECT_CHANNEL(接続済みトランスポート)は DirectChannel::Create() へと振り分けられます。
チャネル生成の前段階では、リゾルバレジストリを使ったターゲット URI の正規化、デフォルト authority の設定、そして可観測性のための channelz ノードの生成が行われます。チャネル引数は、呼び出しチェーンの上流で CoreConfiguration::channel_args_preconditioning() によって事前処理されます。
コールのライフサイクルとバッチ操作
gRPC における RPC はすべて、Channel::CreateCall() によるコール生成から始まります。C API のエントリーポイントは grpc_channel_create_call() で、メソッドパス、ホスト、デッドライン、コンプリーションキューを受け取ります。
コールは Arena——コール単位のリージョンベースアロケータ——から確保されます。コールに関するすべてのメモリ確保はこの Arena に紐付けられ、コール終了時に一括で解放されます。ホットパスにおける個別アロケーションのオーバーヘッドがゼロになるため、パフォーマンス上の重要な設計です。
コールが生成されたあと、アプリケーションは grpc_call_start_batch() を通じてバッチ操作でコールを進めます。各バッチには 1 つ以上の操作が含まれます。
| 操作 | 方向 | 目的 |
|---|---|---|
GRPC_OP_SEND_INITIAL_METADATA |
クライアント → サーバー | リクエストヘッダーを送信する |
GRPC_OP_SEND_MESSAGE |
双方向 | protobuf メッセージを送信する |
GRPC_OP_SEND_CLOSE_FROM_CLIENT |
クライアント → サーバー | ストリームをハーフクローズする |
GRPC_OP_RECV_INITIAL_METADATA |
サーバー → クライアント | レスポンスヘッダーを受信する |
GRPC_OP_RECV_MESSAGE |
双方向 | protobuf メッセージを受信する |
GRPC_OP_RECV_STATUS_ON_CLIENT |
サーバー → クライアント | 最終ステータスとトレーラーを受信する |
GRPC_OP_SEND_STATUS_FROM_SERVER |
サーバー → クライアント | 最終ステータスとトレーラーを送信する |
バッチ内の操作間に実行順序の保証はありませんが、バッチ全体はアトミックに扱われます。バッチ内のすべての操作が完了して初めて、コンプリーションキューにイベントが通知されます。
ヒント: よく使われるパターンとして、送信用(initial metadata + メッセージ + close)と受信用(initial metadata + メッセージ + ステータス)の 2 つのバッチを並行して実行する方法があります。これにより、送受信のパスを独立して進めることができます。
CallSpine:promise ベースのコール基盤
V3 のコール処理アーキテクチャの中心にあるのが CallSpine です。src/core/call/call_spine.h で定義されており、Party(第 3 回で取り上げる promise エグゼキュータ)を継承してコールのフィルタパイプラインを保持します。
class CallSpine final : public Party, public channelz::DataSource {
CallSpine はクライアントの initial metadata と arena を受け取って生成されます。CallFilters を通じてメタデータとメッセージのフローを管理し、各フェーズに型付きのアクセサを提供します。
flowchart LR
subgraph CallSpine
CIM["PullClientInitialMetadata()"] --> Filters["CallFilters"]
Filters --> SIM["PushServerInitialMetadata()"]
C2S["Push/PullClientToServerMessage()"] --> Filters
Filters --> S2C["Push/PullServerToClientMessage()"]
Filters --> STM["PushServerTrailingMetadata()"]
end
CI["CallInitiator"] --> CallSpine
CallSpine --> CH["CallHandler"]
CallSpine は 2 つのビューで共有されます。CallInitiator(クライアント側、375 行目 から)と CallHandler(サーバー/トランスポート側、514 行目 から)です。両者は RefCountedPtr<CallSpine> を保持しています。initiator はクライアントからサーバーへのメッセージを push し、サーバーからクライアントへのメッセージを pull します。handler はその逆を担います。
また、UnstartedCallHandler(613 行目)は、フィルタスタックがまだ開始されていないコールを表します。UnstartedCallDestination インターフェースを通じて流れる型がこれです——チャネルやインターセプターがこれを受け取り、StartCall() を呼び出してフィルタ処理を開始します。
CallSpine は順序制御のために SpawnSerializer インスタンスを使用しています。client_to_server_serializer_ と server_to_client_serializer_ により、各方向の操作が順番に処理されつつ、送受信の 2 方向は並行して進むことができます。
コールデスティネーション抽象化
システム内でのコールのルーティングは、src/core/call/call_destination.h で定義された 2 つのインターフェースによって制御されます。
UnstartedCallDestination:フィルタがまだ開始されていないコール(UnstartedCallHandler)を受け取ります。チャネル、インターセプター、ロードバランシングされたコールデスティネーションが使用します。CallDestination:すでに開始済みのコール(CallHandler)を受け取ります。トランスポートが使用します。
どちらも DualRefCounted(強参照 + 弱参照の参照カウント)を継承しています。このパターンは第 6 回で改めて取り上げます。
この 2 層設計により、「このコールにフィルタスタックを追加する必要がある」(UnstartedCallDestination)と「このコールをワイヤ越しに送信する必要がある」(CallDestination)が明確に分離されています。
コンプリーションキューと V2→V3 移行
コンプリーションキューは、処理結果をアプリケーションに返すための仕組みです。C API では include/grpc/grpc.h のヘルパー関数を通じて、3 種類のコンプリーションキューを生成できます。
- NEXT(
grpc_completion_queue_create_for_next):ポーリングベース。Next()が次の利用可能なイベントを返します。 - PLUCK(
grpc_completion_queue_create_for_pluck):特定のタグを待ち受けます。 - CALLBACK(
grpc_completion_queue_create_for_callback):ファンクタコールバックでイベントを受け取ります。
進行中の V2→V3 移行は、src/core/lib/surface/channel_init.h の ChannelInit における Version enum で管理されています。
enum class Version : uint8_t {
kAny, // Filter applies to both V2 and V3 stacks
kV2, // Filter only applies to V2 (channel stack)
kV3, // Filter only applies to V3 (interception chain)
};
フィルタ登録には .SkipV3()(V2 専用)や .SkipV2()(V3 専用)のタグを付けることができます。たとえば grpc_plugin_registry.cc では、LameClientFilter が V2 専用として登録されており、Server::kServerTopFilter も同様に V2 専用(.SkipV3())です。
flowchart TD
subgraph V2["V2: Channel Stack"]
CS["grpc_channel_stack"] --> F1["Filter 1"]
F1 --> F2["Filter 2"]
F2 --> Terminal["Terminal Filter<br/>(connected_channel)"]
end
subgraph V3["V3: Interception Chain"]
IC["InterceptionChain"] --> CF["CallFilters Stack"]
CF --> CD["CallDestination<br/>(transport)"]
end
Decision{"GRPC_ARG_USE_V3_STACK"} -->|false| V2
Decision -->|true| V3
V2 では、フィルタは grpc_channel_filter 構造体のリンクリストを形成し、コール単位の CallCombiner で同期を取ります。V3 では、フィルタは型付きのインターセプションポイントを持つ CallFilters スタックとなり、コストゼロの NoInterceptor でオプトアウトできます。V3 システムでは InterceptionChain——トランスポートに到達する前にコールを処理する UnstartedCallDestination オブジェクトのチェーン——が使用されます。V3 フィルタシステムの詳細は第 6 回で解説します。
ヒント: V2 か V3 かを見分けるには、
grpc_channel_stack(V2)かCallFilters/InterceptionChain/CallSpine(V3)かを確認するのが手っ取り早い方法です。両方のパスが共存しているため、どちらを読んでいるかを意識することで混乱を防げます。
次回予告
チャネルとコールは gRPC の構造的な基盤ですが、実際の実行の魔法は gRPC 独自の promise ランタイムの中にあります。次回は Poll ベースの非同期モデルを深く掘り下げます。Rust にインスパイアされた gRPC の Poll<T> 型、コンビネータライブラリ(Seq、TrySeq、Join、Race、Loop)、そして最大 16 の並行(ただし並列ではない)promise 参加者を実行する Party エグゼキュータを取り上げます。これが CallSpine と V3 コール処理パイプライン全体を動かすエンジンです。