メインコンテンツまでスキップ

Netty を深く学ぶ

· 約11分

分散システムやマイクロサービスアーキテクチャにおいて、ネットワーク通信は最も基礎的で、かつ最も重要な部分です。
多くの高性能フレームワーク(Dubbo、gRPC、RocketMQ、Elasticsearch など)は、その基盤のネットワーク通信を Netty に依存しています。

私が最初に Netty に触れたとき、いきなり API とサンプルコードを読み始めたのですが、読めば読むほど混乱しました。なぜ EventLoop が必要なのか?なぜ接続の受け入れと読み書きを 2 つのスレッドグループに分けるのか?後になって気づいたのですが、これらの設計は何もないところから生まれたのではなく、従来のネットワークプログラミングが抱える根本的な問題を解決するためのものでした。この進化の歴史を飛ばして Netty を学ぶのは、問題文を見ずに答えを丸暗記するようなものです。

そこでこのノートでは、コードを書くのを急がず、まず Netty の背後にある NIO ネットワークモデルの設計思想 を整理します。この本筋を理解すれば、Netty の各コンポーネントがすべて腑に落ちるはずです。

従来のネットワークプログラミングの問題

初期の Java ネットワークプログラミングでは、ほとんどのプログラムが BIO(Blocking IO) モデルを使っていました。

BIO の「ブロッキング」は 2 か所に現れます。accept() が新しい接続を待つときにブロックし、read() がデータの到着を待つときにもブロックします。スレッドがある接続の読み書きでブロックされると、他のことは何もできなくなります。

例えば:

サーバーはクライアント接続を 1 つ受け入れるたびに、スレッドを 1 つ作成します。

一个连接 = 一个线程

このモデルの利点は、プログラミングがシンプルで直感的なことです。各スレッドは自分の担当する 1 つの接続だけを面倒見て、最初から最後まで読み続ければよいのです。接続数が少ないうちはこれで十分です。

しかし接続が非常に多くなると、例えば:

1万连接 = 1万个线程

これはいくつかの深刻な問題を引き起こします:

スレッドリソースの消費が膨大

スレッド自体にメモリとスケジューリングのコストがかかります。各 Java スレッドは独立したスタックメモリを占有するため、接続数が 1 万を超えると、スレッドスタックだけで大量のメモリを食いつぶします。OS がスレッド構造を維持するオーバーヘッドはまだ含めていません。

スレッドのコンテキストスイッチのオーバーヘッドが大きい

CPU は異なるスレッド間で頻繁に切り替えを行う必要があります。切り替えのたびにレジスタの保存と復元、キャッシュのフラッシュが発生し、スレッド数が CPU コア数を大幅に超えると、CPU 時間のかなりの部分が業務処理ではなく切り替え自体に費やされてしまいます。

システムのスケーラビリティが低い

接続数が増えると、システムはクラッシュしやすくなります。さらに厄介なのは、大量の接続が実際にはアイドル状態である(例えば長時間接続の場面では、多くのクライアントは常にデータを送っているわけではない)にもかかわらず、それぞれがスレッドを 1 つ占有してただ待っていることです。リソースが「待機」に浪費されているのです。

したがって、従来の BIO は 高並行なネットワークサービス には適していません。

NIO の核心となる設計思想

BIO の問題を解決するために、Java は NIO(Non-Blocking IO) を打ち出しました。

NIO の核心となる考え方は非常にシンプルです:

少数のスレッドで、大量の接続を管理する

発想の転換はこうです。ほとんどの接続がほとんどの時間アイドル状態なのであれば、スレッドに接続を見張らせてただ待たせるのではなく、「どの接続にデータが来たか」の監視を OS に任せ、スレッドは本当にイベントが発生したときだけ処理に出動する。これが IO 多重化(IO マルチプレクシング) の考え方であり、Linux では select/poll/epoll といったシステムコールに対応します。

NIO は 3 つの中核コンポーネントに依存しています:

Channel
Buffer
Selector

この 3 つが NIO のコアアーキテクチャを構成しています。順番に見ていきましょう。

Channel(チャネル)

Channel は次のように理解できます:

データ転送のパイプ

従来の IO と異なるのは:

従来の IO:

输入流 / 输出流

一方 Channel は:

是双向的

読み込みも書き込みもできます。Channel の本質は ネットワーク接続の抽象化 です。

Java NIO でよく使われる実装には、ServerSocketChannel(ポートを監視して接続を受け入れる)と SocketChannel(具体的な 1 本の TCP 接続を表す)があります。Channel はノンブロッキングモードに設定できます。これが Selector によって一元管理されるための前提です。データが読めないときはスレッドを固まらせず、即座に戻ります。

Buffer(バッファ)

NIO では、すべてのデータはまず Buffer に入らなければなりません。

次のように理解できます:

データの一時的な格納領域

データの流れ:

网络 -> Buffer -> 程序
程序 -> Buffer -> 网络

Buffer の本質は状態を持つメモリ領域で、内部では position、limit、capacity といったポインタで「どこまで書いたか、どこまで読めるか」を記録しています。読み書きモードの切り替えは flip() のようなメソッドで行う必要があり、これが NIO ネイティブ API で間違えやすいと悪名高いポイントの一つです。Netty が後に独自の ByteBuf を実装したのは、大部分はこの扱いにくい操作から解放されるためでした。

Selector(セレクタ)

Selector は NIO の最も中核となるコンポーネント です。

その役割は:

1 つのスレッドで複数の Channel を管理する

つまり:

一个线程
监听多个连接

動作方式は次のようなイメージです:

事件轮询

具体的には、各 Channel を Selector に登録するときに、自分が関心を持つイベントの種類を宣言します。新しい接続の到着(ACCEPT)、データ読み込み可能(READ)、書き込み可能(WRITE)などです。スレッドは select() を呼び出してブロックして待ち、いずれかの Channel が準備完了になると、準備完了の集合が返され、スレッドはそれを順番に処理すればよいのです。

フロー:

Selector
|
监听多个 Channel
|
哪个 Channel 有事件
|
处理哪个

こうすることで、スレッドの時間はすべて「準備完了イベントの処理」に使われ、「特定の接続を待つこと」には使われません。これこそが NIO と BIO の根本的な違いです。

Reactor スレッドモデル

Reactor スレッドモデルは 高並行ネットワークサーバーでよく使われるデザインパターン です。その核心となる考え方は、少数のスレッドで、イベント駆動の方式によって大量のネットワーク接続を処理する ことです。接続ごとにスレッドを作成するのではなく、ネットワークイベント(接続、読み込み、書き込みなど)を監視し、ある接続にデータが到着したときに初めてそれを処理します。これによって大量のスレッドがもたらすリソース消費とコンテキストスイッチの問題を回避し、サーバーの並行処理能力を大幅に向上させます。

Reactor モデルには通常、いくつかの重要な役割が含まれます:

1、イベント監視(Reactor)

ネットワークイベントの監視を担当します。例えば新しい接続の到着、データ読み込み可能、データ書き込み可能などです。

2、接続受け入れ(Acceptor)

新しいクライアント接続があったときに、接続を受け入れて後続の処理スレッドに登録する役割を担います。

3、イベント分配(Dispatcher)

異なるネットワークイベントを対応する処理ロジックへ振り分けます。

4、業務処理(Handler)

実際に業務ロジックを実行します。例えばプロトコルの解析、リクエストの処理、結果の返却などです。

Reactor モデル自体にもいくつかの進化形態があります。単一 Reactor 単一スレッド(1 つのスレッドが監視と処理をすべて担う)、単一 Reactor 複数スレッド(監視は単一スレッド、処理はスレッドプールに委譲)、マスター・スレーブ Reactor(接続の受け入れと読み書き処理をそれぞれ別の Reactor スレッドグループが担当)です。接続量が多くなるほど、受け入れと読み書きを分離して、互いに足を引っ張らないようにする必要があります。

Netty において Reactor モデルは通常 BossGroup + WorkerGroup のスレッド構造 として現れ、これはまさにマスター・スレーブ Reactor 形態に対応します:

  • Boss スレッド:クライアント接続の受け入れを担当
  • Worker スレッド:ネットワークの読み書きと業務ロジックの処理を担当

この設計によって、Netty は 少数のスレッドで数千数万の接続を処理 できます。これこそが Netty が高性能なネットワーク通信を実現できる核心的な理由です。

ハマりどころと注意点

このモデルを学び、使う際に、事前に知っておく価値のあるポイントがいくつかあります:

1)Worker スレッドの中で時間のかかる処理をしないこと。Reactor モデルの前提はイベント処理が十分に速いことです。1 つの Worker スレッドは通常複数の接続を担当するため、Handler の中で遅いクエリや同期的なリモート呼び出しを行うと、そのスレッド上の他の接続まで巻き添えで止まってしまいます。時間のかかる業務処理は独立した業務用スレッドプールに投げるべきです。

2)ネイティブの NIO API を直接使うと非常に間違えやすい。Buffer の読み書き切り替えや Selector のイベント処理の細部には落とし穴が多く、実際のプロジェクトのほとんどが生の NIO ではなく Netty を選ぶ理由もここにあります。Netty はこれらの複雑さをカプセル化して隠してくれるのです。

3)NIO が速いのは「スレッドを節約できる」からであり、単発の IO が速いからではない。接続数が少ない場面では BIO のほうが劣るとは限りません。NIO/Reactor の優位性は大量の接続が並存するときに発揮されます。技術選定は場面を見て判断すべきです。

まとめ

この進化の本筋を振り返ってみましょう:

  • BIO の問題は「1 接続 1 スレッド」で、スレッドがアイドル状態の接続に無駄に占有されること;
  • NIO は Channel + Buffer + Selector で IO 多重化を実現し、少数のスレッドが準備完了イベントだけを処理できるようにした;
  • Reactor モデルはその上に、イベントの監視・分配・処理という責務の分担を定義した;
  • Netty の BossGroup + WorkerGroup は、マスター・スレーブ Reactor のエンジニアリング的な実装である。

この流れを理解してから Netty の EventLoop、Pipeline、ByteBuf といった具体的なコンポーネントを見れば、それらが何もないところから湧いて出た設計だとは感じなくなるはずです。Netty のコアコンポーネントと実践的な使い方については、また別の記事で展開していきます。

COMMENTS