DUICUO

Netty アーキテクチャの原則。理解できなくても心配しないでください。

[51CTO.comからのオリジナル記事] 分散システムが広く普及している今日の時代では、サービスはネットワーク内の複数のノードに分散される可能性があります。そのため、分散システムではサービス間の通信が特に重要です。

[[284939]]

Pexelsからの画像

高性能RPCフレームワークにとって、非同期通信フレームワークとしてのNettyはほぼ不可欠なものとなっています。例えば、Dubboフレームワークの通信コンポーネントやRocketMQのプロデューサー・コンシューマー通信はどちらもNettyを使用しています。本日は、Nettyの基本的なアーキテクチャと原理について見ていきます。

Nettyの特徴とNIO

Netty は、高性能なサーバーとクライアントの開発に使用できる非同期のイベント駆動型ネットワーク アプリケーション フレームワークです。

以前は、ネットワーク呼び出しプログラムを作成するときは、クライアント側でソケットを作成し、このソケットを介してサーバーに接続していました。

サーバーはこのソケットに基づいてスレッドを作成し、リクエストを送信します。呼び出しを開始した後、クライアントはサーバーの処理が完了するまで待機する必要があります。その後、次の操作に進む前に、スレッドは待機状態になります。

クライアントのリクエストが増えるほど、サーバーが作成する処理スレッドの数が増えますが、JVM がこれほど多くのスレッドを持つことは容易ではありません。

ブロッキングI/Oを使用して複数の接続を処理する

上記の問題に対処するために、NIO(ノンブロッキングI/O)の概念が導入されました。セレクタ機構はNIOの中核を成しています。

クライアントがリクエストを行うたびに、ソケット チャネルが作成され、セレクタ (マルチプレクサ) に登録されます。

次に、セレクターはサーバー側のIO読み取りおよび書き込みイベントを監視します。この時点で、クライアントはIOイベントの完了を待つ必要がなくなり、後続の作業を続行できます。

サーバーが IO 読み取り/書き込み操作を完了すると、セレクターは通知を受信し、同時にクライアントに IO 操作が完了したことを通知します。

クライアントが通知を受信すると、SocketChannel を通じて必要なデータを取得できます。

NIOメカニズムとセレクタ

上記のプロセスはある程度非同期的な意味を持ちますが、Selector は真の非同期操作を実装しません。

セレクタはスレッドブロッキングを通じてIOイベントの変化をリッスンする必要がありますが、この方法ではクライアントを待たせる必要がありません。セレクタはIOが返ってくるまで待機し、その後クライアントにデータ取得を通知します。真の「非同期IO」(AIO)についてはここでは詳しく説明しません。興味のある方はご自身で調べてみてください。

NIOについて説明したので、Nettyについてお話しましょう。NIOの実装であるNettyは、TCPプロトコルを用いたサーバー/クライアント通信シナリオや高並列アプリケーションに適しています。

開発者にとっては、次のような特徴があります。

  • NIO をカプセル化することで、開発者は NIO の基本的な原理を気にする必要がなくなり、Netty コンポーネントを呼び出すだけでタスクを完了できるようになります。
  • これはネットワーク呼び出しに対して透過的であり、ソケットとの TCP 接続を確立する際のネットワーク例外の処理をラップします。
  • Netty は柔軟なデータ処理機能を提供し、複数のシリアル化フレームワークをサポートし、「ChannelHandler」メカニズムを通じてエンコーダー/デコーダーのカスタマイズを可能にします。
  • パフォーマンス チューニングのために、Netty はスレッド プール モデルとバッファ再利用メカニズム (オブジェクト プーリング) を提供し、複雑なマルチスレッド モデルや操作キューを構築する必要性を排除します。

簡単な例から始めましょう。

冒頭で述べたように、NIOの概念は、高並列ネットワークリクエストの需要を満たすために導入されました。NettyはNIOの実装であり、NIOのカプセル化、ネットワーク呼び出し、データ処理、パフォーマンス最適化において非常に優れたパフォーマンスを発揮します。

アーキテクチャを学ぶ最も簡単な方法は、クライアントサイドのコードがサーバーサイドのコードにアクセスするといった例から始め、Nettyがどのように動作するかを確認することです。コード内で呼び出されるコンポーネントとその動作を改めて確認します。

クライアントがサーバーを呼び出すとします。サーバーの名前はEchoServer、クライアントの名前はEchoClientとします。Nettyアーキテクチャを用いたコード実装は以下のとおりです。

サーバーコード

サーバー側を構築します。サーバーはクライアントから情報を受け取り、それをコンソールに出力するものと仮定します。まず、EchoServerを作成し、コンストラクタでリッスンするポート番号を渡します。

コンストラクターは、リッスンするポート番号を例として取ります。

次のステップはサービスを開始することです。

Start メソッドを使用して NettyServer を起動します。

サーバーの起動プロセスでは、EventLoopGroupやChannelといった複数のコンポーネントが呼び出されます。これらについては後ほど詳しく説明します。

ここで概要を把握してください:

  • EventLoopGroup を作成します。
  • ServerBootstrap を作成します。
  • 使用する NIO トランスポート チャネルを指定します。
  • 指定されたポートを使用してソケット アドレスを設定します。
  • チャネルの ChannelPipeline に ServerHandler を追加します。
  • サーバーに非同期的にバインドします。sync() メソッドを呼び出すとブロックされ、バインドが完了するまで待機します。
  • チャネルの CloseFuture を取得し、完了するまで現在のスレッドをブロックします。
  • すべてのリソースを解放するには、EventLoopGroup を閉じます。

NettyServer が起動すると、特定のポートでリクエストをリッスンし、受信したリクエストを処理します。Netty では、サーバーへのクライアントリクエストは「インバウンド」操作と呼ばれます。

これは、以下に詳述するように、ChannelInboundHandlerAdapter を使用して実現できます。

クライアントからのリクエストを処理します。

上記のコードからわかるように、サーバー側の処理コードには3つのメソッドが含まれています。これら3つのメソッドはすべて、イベントに基づいて実行されます。

彼らです:

  • メッセージを受信したときの操作はchannelReadです。
  • メッセージの読み取りを完了するメソッドは channelReadComplete です。
  • 例外を処理するメソッドは、exceptionCaught です。

クライアントコード

クライアントとサーバーのコードは基本的に似ており、初期化時にサーバーの IP アドレスとポートを入力する必要があります。

クライアントの起動機能には次のものも含まれます。

クライアント プログラムが起動される順序:

  • Bootstrap を作成します。
  • イベントをリッスンする EventLoopGroup を指定します。
  • チャネルの送信モードを NIO (非ブロッキング入力/出力) として定義します。
  • サーバーの InetSocketAddress を設定します。
  • チャネルを作成するときは、ChannelPipeline に EchoClientHandler インスタンスを追加します。
  • リモート ノードに接続し、接続が完了するまでブロックします。
  • チャネルが閉じられるまでブロックされます。
  • スレッド プールをシャットダウンし、すべてのリソースを解放します。

上記の操作が完了すると、クライアントはサーバーとの接続を確立し、データを送信します。同様に、クライアントはチャネルでトリガーされたイベントを受信すると、対応するイベント操作をトリガーします。

たとえば、チャネルのアクティブ化、クライアントによるサーバーからのメッセージの受信、例外のキャプチャなどです。

コード構造は比較的シンプルです。サーバーとクライアントはそれぞれリスナーと接続を初期化し、作成します。そして、それぞれが相手からのリクエストを処理するためのハンドラーを定義します。

サーバー/クライアントの初期化とイベント処理

Nettyコアコンポーネント

上記の簡単な例から、サービスの初期化と通信の際にいくつかのNettyコンポーネントが使用されていることがわかります。以下では、これらのコンポーネントの目的と関係性について説明します。

①チャンネル

上記の例からわかるように、クライアントとサーバーが接続するとチャネルが確立されます。

このチャネルは、bind()、connect()、read()、write() などの基本的な I/O 操作を担当するソケット接続と考えることができます。

簡単に言えば、チャネルとは接続、つまりエンティティ、プログラム、ファイル、デバイス間の接続を表します。また、データの受信トラフィックと送信トラフィックの搬送役としても機能します。

②EventLoopとEventLoopGroup

チャネル接続サービスが存在し、それらの間で情報のやり取りが可能になったため、サービスから送信されるメッセージは「送信」メッセージ、サービスから受信されるメッセージは「受信」メッセージと呼ばれます。メッセージの「送信」/「受信」の動きによってイベントが生成されます。

たとえば、接続のアクティブ化、データの読み取り、ユーザー イベント、異常なイベント、リンクのオープン、リンクのクローズなどです。

この考え方に従うと、データの場合、データの流れによってイベントが生成されるため、これらのイベントを監視して調整するメカニズムが必要になります。

このメカニズム(コンポーネント)がEventLoopです。Nettyでは、各チャネルにEventLoopが割り当てられます。1つのEventLoopは複数のチャネルに対応できます。

各 EventLoop は 1 つのスレッドを占有し、このスレッドは EventLoop で発生するすべての I/O 操作とイベントを処理します (Netty 4.0)。

EventLoopとChannelの関係

EventLoop を理解すれば、EventLoopGroup について説明しやすくなります。EventLoopGroup は EventLoop を作成するために使用されます。サンプルコードの最初の行で新しい EventLoopGroup オブジェクトを作成したことを思い出してください。

EventLoopGroup には複数の EventLoop オブジェクトが含まれます。

EventLoopGroup を作成する

EventLoopGroup は、新しい Channel を作成し、それに EventLoop を割り当てます。

EventLoopGroup、EventLoop、Channelの関係

非同期転送では、EventLoop は複数のチャネルで生成されたイベントを処理でき、その主な機能はイベントの検出と通知です。

各チャネルがスレッドを占有していた以前のアプローチと比較すると、Netty のアプローチははるかに合理的です。

クライアントがサーバーにメッセージを送信すると、EventLoop がそれを検出し、クライアントが他のタスクを実行している間にサーバーに「メッセージを取得してください」と指示します。

EventLoopはサーバーから返されたメッセージを検出すると、クライアントに「メッセージが返されました。取得してください」と通知します。クライアントはメッセージを取得します。このプロセス全体を通して、EventLoopはモニターとトランスミッターの両方の役割を果たします。

③ChannelHandler、ChannelPipeline、ChannelHandlerContext

EventLoop がイベント通知者であれば、ChannelHandler はイベント ハンドラーです。

データ変換、論理演算などのビジネス ロジック コードを ChannelHandler に追加できます。

上記の例に示すように、サーバーとクライアントの両方に、ネットワークの可用性やネットワーク エラーなどの情報を処理および読み取る ChannelHandler があります。

さらに、送信イベントと受信イベントにはそれぞれ異なる ChannelHandler があります。

  • ChannelInBoundHandler (受信イベント ハンドラー)
  • ChannelOutBoundHandler (送信イベント ハンドラー)

各リクエストはChannelHandlerによって処理されるイベントをトリガーすると仮定します。これらのイベントの処理順序はChannelPipelineによって決定されます。

ChannelHandler は送信/受信イベントを処理します。

ChannelPipelineはChannelHandlerチェーンのコンテナを提供します。Channelが作成されると、Nettyフレームワークによって自動的にChannelPipelineに割り当てられます。

ChannelPipeline は、ChannelHandler が特定の順序でイベントを処理することを保証します。イベントがトリガーされると、データは ChannelPipeline と ChannelHandler を特定の順序で通過します。

簡単に言うと、ChannelPipeline は「キューイング」を担当します。ここでの「キューイング」とは、イベントが処理される順序を指します。

さらに、ChannelPipeline を使用すると、ChannelHandler を追加または削除してキュー全体を管理できます。

上図に示すように、ChannelPipeline は ChannelHandler を順番に配置し、矢印の方向に情報が流れて ChannelHandler によって処理されます。

ChannelPipeline と ChannelHandler について説明しましたが、前者は後者の配置順序を管理し、それらの関係は ChannelHandlerContext によって表されます。

ChannelHandler が ChannelPipeline に追加されるたびに、ChannelHandlerContext が同時に作成されます。

ChannelHandlerContext の主な機能は、ChannelHandler と ChannelPipeline 間の相互作用を管理することです。

最初の例では、ChannelHandler のイベント処理関数が ChannelHandlerContext にパラメーターとして渡されたことに気付きましたか?

ChannelHandlerContext パラメータは ChannelPipeline 全体で実行され、各 ChannelHandler に情報を渡し、それを適格な「コミュニケータ」にします。

ChannelHandlerContext はメッセージを渡す役割を担います。

上記のコアコンポーネントは、関係を簡単に記憶できるように、以下の図にまとめられています。

Nettyコアコンポーネント関係図

Nettyのデータコンテナ

前のセクションでは、Nettyのコアコンポーネントをいくつか紹介しました。データ転送中、サーバーはイベントを生成し、それらのイベントを監視および処理します。

次に、データがどのように保存され、読み書きされるかを見てみましょう。Nettyは、データ保存のためのデータコンテナとしてByteBufを使用します。

ByteBufの仕組み

構造的には、ByteBuf はバイト配列の文字列で構成されます。配列内の各バイトは情報を格納するために使用されます。

ByteBuf は、データの読み取り用と書き込み用の2つのインデックスを提供します。これらのインデックスは、バイト配列内を移動することで、読み取りまたは書き込みを行う情報の位置を特定するために使用されます。

ByteBuf から読み取る場合、readerIndex は読み取られたバイト数に基づいて増加します。

同様に、ByteBuf に書き込む場合、書き込まれたバイト数に基づいて writerIndex が増加します。

ByteBuf 読み取り/書き込みインデックス凡例

極端なケースでは、readerIndex は writerIndex が書き込む場所とまったく同じ場所を読み取ることに注意してください。

readerIndex が writerIndex を超えると、Netty は IndexOutOf-BoundsException をスローします。

ByteBufの使用パターン

ByteBuf の仕組みについて説明した後、その使用パターンを見てみましょう。

異なるストレージ バッファーに基づいて、次の 3 つのカテゴリに分類されます。

  • ヒープ バッファ ByteBuf は、配列を使用して JVM ヒープにデータを保存し、高速な割り当てを可能にします。

JVMによってヒープ上で管理されるため、使用されていないときはすぐに解放できます。バイト配列データはByteBuf.array()を使用して取得できます。

  • ダイレクトバッファは、JVMヒープの外側に直接メモリを割り当ててデータを格納します。ヒープスペースを占有しませんが、使用時にはメモリ容量を考慮する必要があります。

ソケットを使用して送信する場合、バッファから間接的にデータを送信するため、パフォーマンスが向上します。送信前に、JVM はデータを直接バッファにコピーします。

ダイレクト バッファはヒープ外部にデータを割り当て、JVM によってガベージ コレクションされ、割り当て時にコピーが必要となるため、使用コストが比較的高くなります。

  • 複合バッファは、その名の通り、前述の2種類のバッファを組み合わせたものです。Nettyは、ヒープバッファとダイレクトバッファのデータをまとめて保存できるCompsiteByteBufを提供しており、より便利に使用できます。

ByteBufの割り当て

構造と使用パターンについて説明したので、ByteBuf がバッファーにデータを割り当てる方法を見てみましょう。

Netty は、ByteBufAllocator の実装を 2 つ提供します。

  • PooledByteBufAllocator は ByteBuf オブジェクトのプールを実装し、パフォーマンスを向上させ、メモリの断片化を減らします。
  • Unpooled-ByteBufAllocator はオブジェクト プーリングを実装せず、毎回新しいオブジェクト インスタンスを生成します。

オブジェクトプーリングはスレッドプーリングに似ており、メモリ利用率の向上を主な目的としています。プーリングのシンプルな実装は、JVMヒープメモリ上にメモリプールを構築し、`allocate`メソッドを使用してプールから領域を取得し、`release`メソッドを使用してプールに領域を返却するというものです。

オブジェクトの作成と破棄は、allocateメソッドとreleaseメソッドを頻繁に呼び出します。そのため、メモリプールは断片化された空間の再利用という問題に直面します。空間の割り当てと解放が頻繁に行われるため、メモリプールはオブジェクトの割り当てのために連続したメモリ空間を確保する必要があります。

この要件に基づいて、この領域でのメモリ割り当てを最適化するために、バディ システムとスラブ システムの 2 つのアルゴリズムが使用されます。

バディシステムは、メモリ領域を管理するために完全な二分木を使用します。左右のノードはパートナーであり、各ノードはメモリブロックを表します。メモリ割り当ては、可能な限り小さなメモリパーティションが見つかるまで、大きなメモリブロックを2つの部分に分割し続けることで行われます。

メモリ解放プロセスでは、解放対象のメモリフラグメントの左右のノードが空いているかどうかを確認します。空いている場合は、左右のノードが結合されて、より大きなメモリブロックが形成されます。

スラブ システムは、主に、メモリの大きなブロックを同じサイズの均等な部分に分割し、同じサイズのメモリ チップのセットを形成することで、メモリの断片化の問題に対処します。

要求されたメモリ空間のサイズに基づいて、可能な限り小さい、またはその倍数のメモリブロックを要求します。メモリを解放すると、メモリブロックはメモリセットに戻されます。

Nettyのメモリプール管理は、Allocateオブジェクトの形式で行われます。Allocateオブジェクトは複数のArenaで構成され、各Arenaはメモリブロックの割り当てと解放を実行できます。

Arena には、次の 3 種類のメモリ ブロック管理ユニットが含まれています。

  • タイニーサブページ
  • スモールサブページ
  • チャンクリスト

Tiny と Small は Slab システムの経営戦略に準拠しており、ChunkList はパートナー システムの経営戦略に準拠しています。

ユーザーが tinySize と smallSize の間のメモリを要求すると、メモリ ブロックは tinySubPage から取得されます。

メモリ要求が smallSize と pageSize の間の場合、メモリ ブロックは smallSubPage から取得されます。pageSize と chunkSize の間の場合、メモリは ChunkList から取得されます。ChunkSize より大きいメモリ ブロック (割り当てられるメモリのサイズが不明な場合) は、プーリングを通じて割り当てられません。

ネッティのブートストラップ

Nettyのコアコンポーネントとデータストレージについて説明した後、最初のサンプルプログラムに戻りましょう。プログラムの冒頭で、新しいBootstrapオブジェクトが作成され、その後のすべての設定はこのオブジェクトに基づいて行われます。

Bootstrapオブジェクトの生成

Bootstrap の目的は、Netty のコア コンポーネントをアプリケーションに構成し、実行することです。

Bootstrap の継承構造の観点から見ると、Bootstrap と ServerBootstrap の 2 つのカテゴリに分けられます。1 つはクライアント側のブートストラッピングに対応し、もう 1 つはサーバー側のブートストラッピングに対応します。

クライアント側とサーバー側のプログラムブートストラップをサポート

Bootstrapのクライアント側実装では、主にbind()とconnect()という2つのメソッドが使用されます。Bootstrapはbind()メソッドを使用してチャネルを作成します。

bind() の後、connect() メソッドを呼び出して Channel 接続が作成されます。

Bootstrap は、bind メソッドと connect メソッドを使用して接続を作成します。

クライアントサイドのアプローチとは異なり、サーバーサイドのブートストラップはBind()メソッドの後にServerChannelを作成します。新しいChannelを作成するだけでなく、既存のChannelの管理も行います。

ServerBootstrap は、bind メソッドを使用して接続を作成/管理します。

上記の説明に基づくと、サーバー側ブートストラップとクライアント側ブートストラップには 2 つの違いがあります。

  • ServerBootstrapはポートにバインドしてクライアントからの接続リクエストをリッスンします。一方、Bootstrapは接続を確立するためにサーバーのIPアドレスとポート番号のみを必要とします。
  • Bootstrap (クライアント側のブートストラップ) には 1 つの EventLoopGroup が必要ですが、ServerBootstrap (サーバー側のブートストラップ) には 2 つの EventLoopGroup が必要です。

サーバーには2つの異なるチャネルセットが必要です。1つ目のセットであるServerChannelは、ローカルポートソケットをリッスンします。2つ目のセットは、クライアントからのリクエストをリッスンするために使用されます。

ServerBootstrapには2つのEventLoopGroupがある

要約

まずNIOから始め、セレクターのコアメカニズムについて説明しました。その後、Nettyクライアントとサーバーのソースコードの実行フローを紹介することで、Nettyコードの書き方の基礎を参加者に理解してもらいました。

Nettyのコアコンポーネントでは、Channelはソケットへの接続チャネルを提供し、EventLoopはChannelによって生成されたイベントをリッスンしてエグゼキュータに通知します。EventloopGroupは、EventLoopの生成と管理を担うコンテナです。

ChannelHandlerのコンテナとして機能するChannelPipelineはChannelにバインドされ、ChannelHandlerが特定のイベント処理を提供します。さらに、ChannelHandlerContextはChannelHandlerとChannelPipeline間の情報共有を容易にします。

Netty のデータ コンテナーである ByteBuf は、データをバイト配列として保存し、読み取りおよび書き込みインデックスを通じて読み取りおよび書き込み操作をガイドします。

上記のコアコンポーネントはすべてBootstrapを介して設定および起動されます。Bootstrapの起動方法は同じですが、クライアント側とサーバー側では若干の違いがあります。

著者:崔浩

自己紹介:開発とアーキテクチャに関する16年間の経験があり、以前はHPの武漢デリバリーセンターで技術エキスパート、要件アナリスト、プロジェクトマネージャーを務め、その後はスタートアップ企業でテクニカル/プロダクトマネージャーを務めました。学習能力が高く、知識を共有することに熱心です。現在は、技術アーキテクチャと研究開発管理に注力しています。

[これは51CTOからのオリジナル記事です。提携サイトへの転載の際は、原著者と出典を51CTO.comと明記してください。]