Netty ChannelからOSカーネルまで:TCPコネクション管理とepollの詳細解説

Netty ChannelからOSカーネルまで:TCPコネクション管理とepollの詳細解説

本記事は、NettyのChannelがどのようにOSのTCPコネクションと連携し、特にepollメカニズムによって高効率なI/O処理を実現しているのかを深掘りします。TCPコネクションの実態から、その維持メカニズム、そして大量のコネクションを捌くためのOSカーネルとNettyの内部構造まで、段階的に解説していきます。

1. TCPコネクションの真実:メモリ上の「ファイル」

多くの開発者は「TCPコネクション」を、2つのコンピュータ間に物理的に存在するパイプのようなものだと誤解しています。しかし、これは現実とは異なります。

事実:物理的な「TCPコネクション」は存在しません。

インターネット上を流れる個々のデータパケットは、独立したステートレスな存在です。それらは様々なルータを経由し、異なる物理経路をたどり、最終的にひとつのまとまった情報として再構成されます。

では、なぜ私たちは安定したコネクションが存在すると「感じる」のでしょうか?

それは、OSカーネルがメモリ上に「ファイル」(状態情報)を保持することで、この錯覚を生み出しているからです。

┌─────────────────────────────────────────────────────────────┐
│                 あなたの「認識」 vs 「現実」                 │
├───────────────────────────┬─────────────────────────────────┤
│        あなたが思うこと         │         実際に起こっていること        │
├───────────────────────────┼─────────────────────────────────┤
│  クライアント ════════ サーバー  │  クライアント  ・  ・  ・  ・  ・  サーバー │
│  (安定したパイプ)           │  (個別に漂流するデータグラムの集合)     │
│                           │                                 │
│  channel.write() →        │  → データはN個のTCPセグメントに分割される   │
│  → データが流れていった        │  → それぞれが経路を探し、相手のカーネルで再構成される │
│                           │                                 │
│  「コネクションが切れた」      │  → カーネルのTCBが破棄される/タイムアウトする │
│  (パイプが壊れた)           │  → そのメモリ上のファイルが存在しなくなる     │
└───────────────────────────┴─────────────────────────────────┘

この「ファイル」こそが、TCB(Transmission Control Block、伝送制御ブロック)です。

2. TCB構造体の詳細分析

TCBはTCPメカニズム全体の核です。これを理解すれば、Netty、Keep-Alive、コネクションプールなど、すべての高層の仕組みの本質を理解できます。

2.1 TCBの完全な構造(C言語擬似コード、コメント付き)

// 📂 Linux カーネル include/linux/tcp.h (簡略版)
struct tcp_sock {

    // ═══════════════════════════════════════════════
    // 📋 パート1: 識別情報 —— TCP四要素(Four-tuple)
    //            この4つのフィールドが、地球上のコネクションを
    //            一意に識別します。
    // ═══════════════════════════════════════════════
    __be32  local_ip;    // ローカルIPアドレス    e.g. 192.168.1.10
    __u16   local_port;  // ローカルポート番号  e.g. 8080
    __be32  remote_ip;   // リモートIPアドレス    e.g. 10.0.0.5
    __u16   remote_port; // リモートポート番号  e.g. 54321
    //
    // 💡 重要概念:
    //    同じサーバーポート (8080) がN個のコネクションを
    //    同時に受け入れることができます。
    //    これは各コネクションの (remote_ip + remote_port) が異なるため、
    //    四要素は依然として一意だからです!これがC10Kの数学的基礎です。

    // ═══════════════════════════════════════════════
    // ⚙️ パート2: ステートマシン —— このコネクションは「生きている」か?
    // ═══════════════════════════════════════════════
    int     state;           // TCPステータス (LISTEN/SYN_RCVD/ESTABLISHED/...)
    ktime_t last_active_ts;  // ⭐ 最終アクティブタイムスタンプ —— キープアライブメカニズムの核!
    //
    // 💡 いわゆる「コネクション維持」とは、本質的に次のことを意味します:
    //    last_active_ts を継続的に更新し、
    //    カーネルの定期的なクリーンアップタスクに
    //    「このコネクションはまだ生きている」と認識させることです。

    // ═══════════════════════════════════════════════
    // 🏓 パート3: Keep-Alive 検出状態
    // ═══════════════════════════════════════════════
    u8      keepalive_probes; // 連続して送信されたプローブパケット数
    u32     keepalive_time;   // アイドル状態がどれくらい続いたら検出を開始するか (デフォルト 7200s = 2時間)
    u32     keepalive_intvl;  // 各プローブの間隔 (デフォルト 75s)
    u32     keepalive_cnt;    // 最大プローブ回数 (デフォルト 9回)
    //
    // 💡 プローブがkeepalive_cnt回以上失敗した場合、
    //    カーネルは対向が「ダウンした」と判断し、
    //    コネクションを主动的に閉じ、このTCBを破棄します。

    // ═══════════════════════════════════════════════
    // 📦 パート4: データ帳簿 —— 信頼性のある転送の基礎
    // ═══════════════════════════════════════════════
    u32  snd_una;    // 未確認の最小送信シーケンス番号 (Unacknowledged)
    u32  snd_nxt;    // 次に送信するシーケンス番号 (Next)
    u32  rcv_nxt;    // 次に受信を期待するシーケンス番号 (ACK生成に使用)
    u32  snd_wnd;    // 送信ウィンドウサイズ (相手がどれだけ受信できるか)
    u32  rcv_wnd;    // 受信ウィンドウサイズ (自分がどれだけ受信できるか)

    // ═══════════════════════════════════════════════
    // 🗄️ パート5: メモリバッファ —— データの中継点
    // ═══════════════════════════════════════════════
    struct sk_buff_head  write_queue;   // 送信バッファ (アプリケーション → NIC)
    struct sk_buff_head  receive_queue; // 受信バッファ (NIC → アプリケーション)
    //
    // 💡 Nettyのゼロコピー最適化は、大部分において
    //    このバッファとJavaヒープメモリ間の無意味なコピーを減らすことを目指しています。
};

2.2 TCBとNetty Channelのマッピング関係

┌─────────────────────────────────────────────────────────────────┐
│                    抽象レイヤー対応図                              │
│                                                                 │
│  Javaアプリケーション層                                              │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  Netty Channel  (オブジェクト指向の高レベル抽象)         │   │
│  │  ┌───────────────────────────────────────────────────┐  │   │
│  │  │  NioSocketChannel                                 │  │   │
│  │  │    - pipeline: DefaultChannelPipeline             │  │   │
│  │  │    - unsafe: NioByteUnsafe                        │  │   │
│  │  │    - selectionKey: SelectionKey  ◄── FDの参照を保持  │  │   │
│  │  └───────────────────────────────────────────────────┘  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                          │  JNI呼び出し                           │
│  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─  │
│  OSカーネル層             │                                     │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  File Descriptor (FD)  e.g. fd=7                        │   │
│  │  (ファイルディスクリプタ:カーネルリソースの整数ハンドル)    │   │
│  │               │                                         │   │
│  │               ▼                                         │   │
│  │  TCP Socket (struct socket)                             │   │
│  │               │                                         │   │
│  │               ▼                                         │   │
│  │  TCB (struct tcp_sock) ◄─── コネクション状態を実際に格納する場所 │   │
│  │    - 四要素 (識別情報)                                    │   │
│  │    - last_active_ts (維持の鍵)                            │   │
│  │    - snd/rcv buffers (データバッファ)                     │   │
│  └─────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

  結論:channel.writeAndFlush(msg) の呼び出しは
         └→ 最終的にfd=7に対応するTCBのwrite_queueにデータを挿入します。

3. コネクション維持の二重保証メカニズム

「コネクション」が単なるTCBというメモリ上のファイルであるなら、「コネクションを維持する」ことの本質は、last_active_tsを継続的に更新し、同時にカーネルに「このファイルを触らないで」と伝えることです。

3.1 第一の保証:カーネルレベルTCP Keep-Alive(自動だが低速)

Keep-Aliveプローブパケットの「トリック」

ここで非常に直感に反する詳細があります。Keep-Aliveプローブパケットは**ビジネスデータを一切含みません**が、接続維持の役割を果たします。これはTCPプロトコルのある設計特性を利用しています。

  クライアント (送信側)                    サーバー (受信側)
       │                                   │
       │  現在のSEQ = 1000 (ここまで確認済み)  │
       │                                   │
       │  ← コネクションアイドル 2時間 →      │
       │                                   │
       │  カーネルタイマーがトリガーされ、プローブパケットを送信: │
       │  ┌──────────────────────────────┐  │
       │  │ SEQ = 999  (意図的に現在より1小さい!) │  │
       │  │ ACK = ...                    │  │
       │  │ データ長 = 0                  │  │
       │  └──────────────────────────────┘  │
       │─────────────── プローブパケット ────────────►│
       │                                   │  相手のプロトコルスタックが
       │                                   │  「不正な」SEQ=999を受信する
       │                                   │  本能的にACKで応答する
       │◄─────────────── ACK ──────────────│
       │  ACK.ack_num = 1000               │
       │  (期待される正しいシーケンス番号を通知) │
       │                                   │
       │  ✅ ACKを受信!                     │
       │  last_active_tsを更新              │
       │  keepalive_probesをリセット         │
       │                                   │

なぜSEQ-1という「汚いトリック」を使うのか?

TCPの仕様では、スライディングウィンドウ外のシーケンス番号を持つパケットに対して、受信側はACKで訂正応答**しなければなりません**。この強制的な挙動を利用することで、送信側はACKを「引き出し」、実際のデータを転送することなく相手が生存しているかを確認できます。

Keep-Aliveの時間軸

  t=0        t=7200s      t=7275s    t=7350s  ...  t=7875s
  │          │            │          │             │
  │←─ アイドル ─►│← プローブ1 →ACK│← プローブ2 → │    ...      │← プローブ9 → ×
  │  2時間     │  75秒待つ     │  75秒待つ   │    ...      │  タイムアウト!
  │          │            │          │             │
  接続確立   検出開始     更新/続行   更新/続行    カーネルが接続を閉じる
                                                    TCBを破棄する

Linuxカーネルのデフォルトパラメータ:

パラメータ 意味 デフォルト値
tcp_keepalive_time アイドル状態がどれくらい続いたら検出を開始するか 7200秒(2時間)
tcp_keepalive_intvl プローブパケットの間隔 75秒
tcp_keepalive_probes 最大プローブ回数 9回
最悪ケースの合計タイムアウト time + intvl × probes 7200 + 75×9 = 7875秒 ≈ 2.2時間

致命的な欠陥:デフォルトでは2時間後にようやく検出を開始するため、本番環境ではほとんど役に立ちません。プロセスがハングアップした後でも、カーネルはKeep-Alive ACKに応答し続けるため、アプリケーション層は異常を検知できません。

3.2 第二の保証:ユーザーレベルNettyアプリケーション心拍(能動的でスマート)

なぜカーネルKeep-Aliveだけに依存できないのか?

シナリオ:バックエンドサービスプロセスがデッドロックに陥った(GC STWが長すぎる / 業務ロジックの無限ループ)

  クライアント              OSカーネル              JVMプロセス
    │                       │                       │
    │── Keep-Aliveプローブ ───►│                       │
    │                       │  カーネルは正常に動作し、│
    │◄── ACK ───────────────│  アプリケーションに代わって応答!│ ← プロセスは既に停止
    │                       │                       │
    │  ✅ クライアントは接続が正常だと思っている │                  ❌ 実際の業務処理はできない

カーネルKeep-Aliveはネットワーク層の接続性しか検出できず、アプリケーション層の生存性を検出できません。

Netty IdleStateHandler:アプリケーション層心拍の標準実装

// ════════════════════════════════════════════════════
// Nettyパイプライン設定(サーバー側)
// ════════════════════════════════════════════════════
public class CustomChannelInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel ch) {
        ChannelPipeline pipeline = ch.pipeline();

        // 1. アイドル検出器
        //    readerIdleTime=60s: 60秒間データを受信しない場合 → READER_IDLEイベントをトリガー
        //    writerIdleTime=0:   送信アイドルは検出しない
        //    allIdleTime=0:      送受信両方のアイドルは検出しない
        pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS));

        // 2. アイドルイベント処理(心拍パケット送信 / コネクション切断)
        pipeline.addLast(new ApplicationHeartbeatHandler());

        // 3. ビジネスロジックハンドラ
        pipeline.addLast(new BusinessLogicHandler());
    }
}

// ════════════════════════════════════════════════════
// 心拍ハンドラ
// ════════════════════════════════════════════════════
public class ApplicationHeartbeatHandler extends ChannelInboundHandlerAdapter {

    private static final int MAX_INACTIVITY_COUNT = 3; // 連続する心拍タイムアウトの最大許容回数
    private int inactivityCount = 0;

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
        if (evt instanceof IdleStateEvent) {
            IdleStateEvent event = (IdleStateEvent) evt;

            if (event.state() == IdleState.READER_IDLE) {
                inactivityCount++;

                if (inactivityCount >= MAX_INACTIVITY_COUNT) {
                    // 連続3回心拍応答がない → 相手がダウンしたと判断 → 能動的に接続を閉じる
                    System.out.println("💔 連続 " + inactivityCount + " 回のアイドル状態が検出され、接続をクローズします: " + ctx.channel());
                    ctx.channel().close();
                } else {
                    // PINGメッセージを送信(カーネルの空パケットトリックとは異なり、実際のペイロードを伴う)
                    System.out.println("🏓 心拍 PINGを送信中 [" + inactivityCount + "/" + MAX_INACTIVITY_COUNT + "]");
                    ctx.writeAndFlush(Unpooled.copiedBuffer("PING", CharsetUtil.UTF_8));
                }
            }
        } else {
            super.userEventTriggered(ctx, evt);
        }
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        // 何らかのデータ(PONGを含む)を受信したら、アイドルカウントをリセット
        inactivityCount = 0;
        ctx.fireChannelRead(msg);
    }
}

二重保証の比較

項目 カーネルTCP Keep-Alive アプリケーション層Netty心拍
実行レイヤー OSカーネル空間 JVMユーザー空間
検出対象 ネットワーク層の接続性 アプリケーション層の生存性
デフォルトタイムアウト 2時間 (非常に遅い) 設定可能 (通常60秒)
データパケット 空パケット (SEQ-1トリック) 実際のビジネスデータ (PINGフレーム)
プロセス停止検出 ❌ できない ✅ できる
設定場所 OSパラメータ / ソケットオプション Nettyパイプライン
適用シナリオ 下層の最終的な接続維持 本番環境の主要な接続維持メカニズム

ベストプラクティス:両方を同時に有効にし、相互補完させます。 カーネルKeep-Aliveを30秒に短縮(NATデバイスによるコネクション破棄を防ぐため)。 Netty心拍を60秒に設定(アプリケーション層に十分な反応時間を与えるため)。

4. 大量コネクション向けデータ構造選定:ハッシュテーブル vs 赤黒木

NettyサーバーがC10K(1万コネクション)からC1M(100万コネクション)に直面するとき、データ構造の選択は非常に重要になります。カーネルは2つの異なるシナリオで、それぞれまったく異なるデータ構造を選択しています。

4.1 シナリオ1:NICがパケットを受信した際、目的のTCBをいかに高速に見つけるか?

要件:毎秒数百万パケットを受信する可能性があり、対応するTCBを最速で特定する必要があります。

答え:ハッシュテーブル O(1)

┌─────────────────────────────────────────────────────────────────┐
│              カーネルグローバルハッシュテーブル:tcp_hashinfo       │
│                                                                 │
│  NICがデータパケットを受信                                         │
│       │                                                         │
│       ▼                                                         │
│  四要素を抽出:                                                     │
│  (src_ip=10.0.0.1, src_port=54321, dst_ip=192.168.1.1, dst_port=8080)
│       │                                                         │
│       ▼                                                         │
│  hash(四要素) = 0x3A7F                                           │
│       │                                                         │
│       ▼                                                         │
│  ┌────┬────┬────┬────┬────┬─────┐                               │
│  │ 0  │ 1  │ 2  │ .. │3A7F│ .. │  ハッシュバケット配列             │
│  └────┴────┴────┴──┬─┴─┬──┴─────┘                               │
│                    │   │                                        │
│                    │   └──► TCB_A ──► TCB_B (ハッシュ衝突時のリンクリスト)│
│                    │                                            │
│  平均O(1)で目的のTCBを特定し、                                     │
│  データパケットをTCB.receive_queueにプッシュ                           │
└─────────────────────────────────────────────────────────────────┘

なぜハッシュテーブルで、赤黒木ではないのか?

  • パケット受信は極めて高頻度な操作であり、100万QPS(Queries Per Second)の場合、O(1)とO(log N)ではパフォーマンスに大きな差が出ます。
  • 四要素のハッシュ関数は均等に分散され、衝突率が低いため、実際にはO(1)に近い性能が得られます。
  • 順序付きの走査は不要であり、ハッシュテーブルで十分です。

4.2 シナリオ2:epollが100万のコネクションを監視、いかに管理するか?

要件:サーバーの稼働中にコネクションは頻繁に確立され、破棄されます。監視対象の集合は頻繁に追加・削除・更新され、かつ検索性能が安定している必要があります。

答え:赤黒木 O(log N)

┌─────────────────────────────────────────────────────────────────┐
│              epollカーネルオブジェクト:eventpoll                 │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │                    赤黒木(rbr)                            │  │
│  │                                                           │  │
│  │                      [fd=15]                              │  │
│  │                     /        \                            │  │
│  │               [fd=7]          [fd=23]                     │  │
│  │              /     \          /     \                     │  │
│  │           [fd=3] [fd=12]  [fd=19]  [fd=31]               │  │
│  │              ...    ...     ...     ...                   │  │
│  │                                                           │  │
│  │  操作の複雑性:                                            │  │
│  │  epoll_ctl(EPOLL_CTL_ADD, fd)    → O(log N) で挿入       │  │
│  │  epoll_ctl(EPOLL_CTL_DEL, fd)    → O(log N) で削除       │  │
│  │  epoll_ctl(EPOLL_CTL_MOD, fd)    → O(log N) で変更       │  │
│  │  N=1,000,000 の場合、log₂(1,000,000) ≈ 20 回操作 ✅       │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │               就緒リスト(rdllist)                          │  │
│  │                                                           │  │
│  │   [fd=7] ←──────────► [fd=23] ←──────────► [fd=31]       │  │
│  │    読み取り可能データあり      新規接続あり        書き込み可能データあり │  │
│  │                                                           │  │
│  │  epoll_wait が返すのは、このリスト内の要素です              │  │
│  └───────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

なぜハッシュテーブルではないのか?

比較項目 ハッシュテーブル 赤黒木
平均時間複雑度 O(1) O(log N)
最悪時間複雑度 O(N) (ハッシュ衝突による劣化) O(log N) 常に安定
頻繁な追加/削除性能 リハッシュが必要でコストが高い 回転によるバランス調整、安定したO(log N)
順序付き走査 サポートしない 中順走査で順序付き
メモリ利用率 大きな配列の事前割り当てが必要 ノードをオンデマンドで割り当て

結論:コネクション数が激しく変動し、追加・削除が頻繁に行われるepollのシナリオでは、赤黒木の常に安定したO(log N)は、ハッシュテーブルの「平均O(1)、最悪O(N)」よりもはるかに安全で信頼性が高いです。

5. epollの画期的優位性:なぜselect/pollを圧倒するのか

5.1 select/pollの根本的な問題:毎回「全員に声かけ」

従来のselect/pollモデル(N=100万コネクションの場合)

  アプリケーションがselect()を呼び出すたびに:
  ┌──────────────────────────────────────────────────┐
  │  100万個のFDすべてをカーネルにコピー              ← O(N) のコピー │
  │                                                  │
  │  カーネル:承知しました、ひとつずつ確認します...        │
  │  fd_1: データはありますか?いいえ。                     │
  │  fd_2: データはありますか?いいえ。                     │
  │  fd_3: データはありますか?いいえ。                 ← O(N) の走査 │
  │  ...                                             │
  │  fd_999,998: データはありますか?はい!            ← ようやく見つかった │
  │  fd_999,999: データはありますか?いいえ。                 │
  │  fd_1,000,000: データはありますか?いいえ。               │
  │                                                  │
  │  結果をすべてユーザー空間にコピーして戻す         ← O(N) のコピー │
  │  「999,998番目にデータがあります」                        │
  └──────────────────────────────────────────────────┘

  select呼び出しごとにO(N)の時間複雑度。N=100万では致命的です。

追加の制限(selectの主な問題点):

  • FD数の上限が1024(FD_SETSIZE)に固定されている。
  • 呼び出しごとにFD集合を再渡しする必要がある(増分更新が不可能)。
  • 返された後も、就緒したFDを見つけるために走査が必要。

5.2 epollの革新:ポーリングの代わりに「通知を待つ」

epollの設計哲学は、Javaのwait()/notify()とよく似ています。すなわち、「私からは積極的に尋ねず、何かあったら通知してください」というものです。

epollの主要な3つのシステムコール:

  ┌────────────────────┐
  │  epoll_create()    │  → カーネル内にeventpollオブジェクトを生成(赤黒木 + 就緒リストを含む)
  │  epfdを返す         │    一度だけ呼び出す
  └────────────────────┘
           │
           ▼
  ┌────────────────────┐
  │  epoll_ctl()       │  → 赤黒木に対して監視対象のFDを追加/削除/変更
  │  epfd, op, fd,     │    同時に、対応するTCBにコールバック関数をバインド
  │  events            │    時間複雑度 O(log N)
  └────────────────────┘
           │
           ▼
  ┌────────────────────┐
  │  epoll_wait()      │  → 就緒リストが空でなくなるまでスレッドをスリープさせる
  │  epfd, events[],   │    スリープから復帰したら、就緒リストから直接要素を読み出す
  │  maxevents,        │    時間複雑度 O(1) ← これが魔法の源です
  │  timeout           │
  └────────────────────┘

5.3 epoll_waitの覚醒メカニズム:サイバーパンクな完全プロセス

これは記事全体の最も重要な部分です。このシーケンス図に沿って、完全なプロセスを追ってみましょう。

┌──────────────────────────────────────────────────────────────────────────┐
│                    epoll 完全覚醒プロセス(シーケンス図)                     │
│                                                                          │
│  Netty I/Oスレッド         Linuxカーネル               NIC (ネットワークインターフェースカード) │
│  (ユーザー空間)             (カーネル空間)                  (ハードウェア)                 │
│       │                       │                       │                  │
│       │  epoll_wait(epfd)      │                       │                  │
│       │──────────────────────►│                       │                  │
│       │  (スレッドが中断され、スリープ状態に入る)│                       │                  │
│       │                       │                       │                  │
│       │                       │       ① データパケット到着   │                  │
│       │                       │◄──────────────────────│                  │
│       │                       │  ハードウェア割り込み (IRQ) をトリガー │                  │
│       │                       │                       │                  │
│       │                  ② ソフト割り込み処理           │                  │
│       │                  カーネルが四要素を抽出             │                  │
│       │                  hash(四要素) → TCBを特定       │                  │
│       │                  データをTCB.receive_queueに書き込む  │                  │
│       │                       │                       │                  │
│       │                  ③ コールバック関数をトリガー      │                  │
│       │                  (epoll_ctl時に事前にバインド済み)│                  │
│       │                       │                       │                  │
│       │                  ④ コールバック:                  │                  │
│       │                  赤黒木上のepitemノードを見つける    │                  │
│       │                  その参照を就緒リストrdllistに挿入  │                  │
│       │                  スリープ中のepoll_waitスレッドを覚醒 │                  │
│       │                       │                       │                  │
│       │◄──────────────────────│                       │                  │
│       │  epoll_waitが戻る!    │                       │                  │
│       │  戻り値 = 就緒FD数     │                       │                  │
│       │  (就緒リストから直接取得) │                       │                  │
│       │                       │                       │                  │
│  ⑤ Nettyが就緒イベントを処理      │                       │                  │
│  for (ready_fd : events) {    │                       │                  │
│    channel.read() / accept()  │                       │                  │
│  }                            │                       │                  │
│                                                                          │
└──────────────────────────────────────────────────────────────────────────┘

  重要な洞察:
  ステップ④の「就緒リストへの挿入」はO(1)操作です(双方向リストの先頭挿入)。
  ステップ⑤の「就緒イベントの読み取り」はO(就緒数量)であり、O(全接続数)ではありません。
  → 100万接続のうち10個しか就緒していなくても、epoll_waitは10個だけを返します。完璧です!

5.4 水平トリガー vs エッジトリガー(ET vs LT)

                    バッファ:[████░░░░]  4KBのデータが存在

  LT(Level-Triggered、デフォルト):
  ┌─────────────────────────────────────────────────┐
  │  バッファにデータがある限り、epoll_waitは毎回通知する    │
  │  複数回に分けてゆっくり読み取ることができる             │
  │  安全だが、頻繁に覚醒される可能性がある                 │
  └─────────────────────────────────────────────────┘
  epoll_wait → [fd=7 読み取り可能] が返される。あなたは2KB読み取る。
  epoll_wait → [fd=7 読み取り可能] が返される。まだ2KB読み残しがあるため、再度通知される。
  epoll_wait → [fd=7 読み取り可能] が返される。...

  ET(Edge-Triggered、高性能):
  ┌─────────────────────────────────────────────────┐
  │  状態変化時(データがない状態からある状態へ)に一度だけ通知する │
  │  一度でデータをすべて読み切らなければならない(EAGAINになるまでループ) │
  │  高性能だが、プログラミングが複雑で、読み取り漏れが発生するとデータ損失につながる │
  └─────────────────────────────────────────────────┘
  epoll_wait → [fd=7 読み取り可能] が返される。あなたはEAGAINになるまでループして読み取る必要がある。
  epoll_wait → ……                (新しいデータが到着しない限り、もう通知は来ない)

  NettyはデフォルトでLTを使用し、安全性を優先しています。
  NginxはETを使用し、性能を優先していますが、厳密な完全読み取りループを実装しています。

6. 完全なデータパケットの旅:NICからNettyハンドラまで

これまでの知識をすべてつなぎ合わせて、データパケットの完全なライフサイクルを見てみましょう。

データパケットの完全なライフサイクル
════════════════════════════════════════════════════════════════════

  【物理層】NICがイーサネットフレームを受信する
       │
       ▼
  【データリンク層】DMAが直接カーネルのリングバッファに書き込む
  (CPUを経由せず、直接メモリに書き込む)
       │
       ▼
  【割り込み層】NICがハードウェア割り込み (IRQ) をトリガー
  CPUは現在の作業を中断し、割り込み処理ルーチンに入る
       │
       ▼
  【ソフト割り込み層】ksoftirqdスレッドがデータパケットを処理
  IP層:ルーティング、TTLチェック
  TCP層:TCPヘッダを解析、四要素を抽出
       │
       ▼
  【ハッシュによる特定】hash(src_ip, src_port, dst_ip, dst_port)
  tcp_hashinfoハッシュテーブルでO(1)で目的のTCBを見つける
       │
       ▼
  【データ書き込み】データをTCB.receive_queue (sk_buffリンクリスト) に書き込む
  TCB.last_active_tsを更新(コネクション維持の重要な操作)
  TCB.rcv_nxtを更新(期待するシーケンス番号)、ACK送信準備
       │
       ▼
  【epollコールバック】このソケットに事前にバインドされたコールバック関数をトリガー
  対応するepitemノードを就緒リストrdllistに挿入
  スリープ中のepoll_waitスレッドを覚醒
       │
       ▼
  【ユーザー空間へ復帰】epoll_waitが就緒FDリストを返す
  時間複雑度:O(就緒数量)であり、総接続数には依存しない!
       │
       ▼
  【Netty I/Oスレッド - NioEventLoop】
  就緒FDを走査し、processSelectedKeys()を呼び出す
       │
       ▼
  【Java NIO】selector.selectedKeys()から就緒SelectionKeyを取り出す
  channel.read() → ByteBufを割り当て → カーネルバッファからユーザー空間にデータをコピー
       │
       ▼
  【Nettyパイプライン】
  HeadContext → ... → YourHandler.channelRead() → TailContext
       │
       ▼
  【あなたのビジネスコード】ようやくここまで!
  handler.channelRead(ctx, msg) { ... }

7. NioEventLoopソースコード分析:あの偉大な while(true)

7.1 NioEventLoopの本質

  NioEventLoop = 1つのスレッド + 1つのSelector (epollのJavaラッパー) + 1つのタスクキュー

  ┌───────────────────────────────────────────────────────────────┐
  │                     NioEventLoop 構造                           │
  │                                                               │
  │   Thread (シングルスレッド)                                        │
  │   ┌───────────────────────────────────────────────────────┐   │
  │   │                   while (true)                        │   │
  │   │                       │                               │   │
  │   │          ┌────────────┼────────────┐                  │   │
  │   │          ▼            ▼            ▼                  │   │
  │   │     I/Oイベント      通常タスク      定期タスク               │
  │   │    (epoll)      (taskQueue)  (scheduledTaskQueue)     │   │
  │   │          │            │            │                  │   │
  │   │          └────────────┴────────────┘                  │   │
  │   │                       │                               │   │
  │   │              処理後、ループ                             │   │
  │   └───────────────────────────────────────────────────────┘   │
  │                                                               │
  │   重要な設計:すべての操作が同じスレッド内で直列に実行される        │
  │   → ロック不要、並行性問題を完全に回避                          │
  └───────────────────────────────────────────────────────────────┘

7.2 核心ソースコードコメント解説

// ════════════════════════════════════════════════════════════════
// io.netty.channel.nio.NioEventLoop#run() の主要な簡略版
// このメソッドがあの「偉大なwhile(true)」です
// ════════════════════════════════════════════════════════════════
@Override
protected void run() {
    int selectLoopCount = 0; // セレクションループの実行回数をカウント

    for (;;) { // ← 実際のwhile(true)ループ、シャットダウンされない限り終了しない

        // ─────────────────────────────────────────────────────
        // 【フェーズ1】select実行:epollに就緒イベントがあるか問い合わせ
        // ─────────────────────────────────────────────────────
        try {
            int strategy;
            try {
                // selectStrategyは、今回の処理がSELECT(待機)かCONTINUE(即時処理)かを決定
                // taskQueueが空でない場合、selectNow()(非ブロッキング、即時リターン)を使用
                // taskQueueが空の場合、select(timeoutMillis)(ブロッキング待機)を使用
                strategy = selectStrategy.calculateStrategy(
                    selectNowSupplier,   // 非ブロッキング selector.selectNow()
                    hasTasks()           // taskQueueに未処理タスクがあるか
                );

                switch (strategy) {
                    case SelectStrategy.CONTINUE:
                        continue; // selectをスキップし、直接タスク処理へ

                    case SelectStrategy.BUSY_WAIT:
                        // ビジーループ待機(NIO以外で使用、ここでは無視)
                        // fall-through

                    case SelectStrategy.SELECT:
                        // ⭐ 核となる処理:epollイベントをブロッキング待機、最大timeoutMillis
                        // 内部でepoll_wait()を呼び出す
                        // イベント発生、タイムアウト、またはwakeup()によってのみ復帰
                        long nextScheduledTaskDeadlineNanos = nextScheduledTaskDeadlineNanos();
                        if (nextScheduledTaskDeadlineNanos == -1L) {
                            nextScheduledTaskDeadlineNanos = NONE; // 定期タスクがなければ、無期限に待機
                        }
                        nextWakeupNanos.set(nextScheduledTaskDeadlineNanos);
                        try {
                            if (!hasTasks()) {
                                strategy = select(nextScheduledTaskDeadlineNanos); // ← epoll_waitを呼び出す
                            }
                        } finally {
                            nextWakeupNanos.lazySet(AWAKE);
                        }
                        // fall-through
                }
            } catch (IOException e) {
                // いわゆる「JDK Epoll Bug」の処理:
                // 特定のLinuxバージョンで、selector.select()が理由なく空回りし、
                // CPUが100%になる問題!Nettyはカウンタで検出し、セレクタを再構築する
                rebuildSelector0();
                selectLoopCount = 0;
                handleLoopException(e);
                continue;
            }

            // ─────────────────────────────────────────────────────
            // 【フェーズ2】I/Oイベント処理:読み込み/書き込み/接続/受け入れ
            // ─────────────────────────────────────────────────────
            selectLoopCount++;
            cancelledKeys = 0;
            needsToSelectAgain = false;

            // ioRatio:I/O処理に費やす時間の割合(デフォルト50%)
            // I/O処理と通常タスクが互いに餓死しないように保証する
            final int ioRatio = this.ioRatio;

            boolean tasksWereRun;
            if (ioRatio == 100) { // I/O処理が100%の場合、まずI/Oをすべて処理
                try {
                    if (strategy > 0) {
                        // 就緒したすべてのSelectionKey(すなわち就緒したChannel)を処理
                        processSelectedKeys(); // ← 就緒リストを走査
                    }
                } finally {
                    tasksWereRun = runAllTasks(); // I/O処理後にすべてのタスクを処理
                }
            } else if (strategy > 0) { // I/Oイベントがある場合
                final long ioStartTime = System.nanoTime();
                try {
                    processSelectedKeys();
                } finally {
                    // I/Oに費やした時間に応じて、タスク処理に割り当てる時間を計算
                    final long ioTime = System.nanoTime() - ioStartTime;
                    tasksWereRun = runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
                }
            } else { // I/Oイベントがなく、タスクキューにタスクがある場合(selectNow()によって)
                tasksWereRun = runAllTasks(0); // 最小限のタスクのみを処理
            }

            // ─────────────────────────────────────────────────────
            // 【フェーズ3】JDK Epoll空回りBugの検出
            // ─────────────────────────────────────────────────────
            if (tasksWereRun || strategy > 0) { // タスクが実行されたか、I/Oイベントがあった場合
                if (selectLoopCount > MIN_PREMATURE_SELECTOR_RETURNS && logger.isDebugEnabled()) {
                    logger.debug("...");
                }
                selectLoopCount = 0; // カウンタをリセット
            } else if (unexpectedSelectorWakeup(selectLoopCount)) {
                // 🐛 空回り検出!セレクタを再構築(重要なバグ修正ロジック)
                selectLoopCount = 0;
            }
        } catch (CancelledKeyException e) {
            // 無視
        } catch (Error e) {
            throw e;
        } catch (Throwable t) {
            handleLoopException(t);
        } finally {
            // EventLoopがシャットダウン時に正しく終了することを確認
            try {
                if (isShuttingDown()) {
                    closeAll();
                    if (confirmShutdown()) {
                        return; // ← 唯一の正常終了点
                    }
                }
            } catch (Error e) {
                throw e;
            } catch (Throwable t) {
                handleLoopException(t);
            }
        }
    }
}

7.3 NioEventLoopとepollの対応関係

┌──────────────────────────────────────────────────────────────────┐
│              NioEventLoop (Java) ↔ epoll (Linux) 対応関係          │
│                                                                  │
│  Java NIO API                   Linux epoll                      │
│  ─────────────────────────────────────────────────────────────  │
│  Selector.open()             →  epoll_create()                   │
│                                                                  │
│  selector.register(ch, ops)  →  epoll_ctl(EPOLL_CTL_ADD, fd)    │
│                                                                  │
│  selector.cancel(key)        →  epoll_ctl(EPOLL_CTL_DEL, fd)    │
│                                                                  │
│  selector.select(timeout)    →  epoll_wait(epfd, events,        │
│                                            maxevents, timeout)  │
│                                                                  │
│  selector.selectedKeys()     →  epoll_wait が返した就緒FDリスト  │
│                                                                  │
│  selector.wakeup()           →  eventfdに1バイト書き込み、       │
│                                  epoll_wait を強制的に覚醒させる  │
│                                                                  │
│  SelectionKey.OP_READ        →  EPOLLIN                         │
│  SelectionKey.OP_WRITE       →  EPOLLOUT                        │
│  SelectionKey.OP_ACCEPT      →  EPOLLIN (リッスンソケットの場合)  │
│  SelectionKey.OP_CONNECT     →  EPOLLOUT (接続完了)              │
└──────────────────────────────────────────────────────────────────┘

8. パフォーマンス比較と主要パラメータチューニング

8.1 並行処理モデルの性能比較

        並行処理モデル性能比較(10万接続、アクティブ率1% = 1000個の就緒イベント)

  BIO (従来のブロッキングI/O)
  ─────────────────────────────────────────────────────
  10万接続 → 10万スレッド
  メモリ:10万 × 512KB (スタック) = 50GB ❌ メモリが爆発
  コンテキストスイッチ:毎秒数百万回 ❌ CPUオーバーヘッドが甚大
  就緒待機:すべてのスレッドがread()呼び出しでブロック

  NIO + select/poll
  ─────────────────────────────────────────────────────
  1つのスレッドで10万接続を管理
  select呼び出しごとに:O(N) = 10万回の走査
  呼び出しごとに10万個のFDを渡す必要あり = 大量のカーネルメモリコピー
  1000個の就緒イベント → 返された後も10万個を走査して1000個を見つける必要がある ❌

  NIO + epoll(Nettyの基盤)
  ─────────────────────────────────────────────────────
  1つのNioEventLoopスレッド(または少数のスレッド)
  epoll_ctlでの追加/削除:O(log 10万) ≈ 17回の操作 ✅
  epoll_waitの戻り値:直接1000個の就緒FDを取得
  10万個を走査する必要なし!複雑度 O(就緒数) ✅
  メモリ:赤黒木のノードのみを維持、極めて低い

  ┌────────────┬──────────────┬─────────────┬────────────────┐
  │            │    スレッド数  │  走査コスト  │  メモリ使用量   │
  ├────────────┼──────────────┼─────────────┼────────────────┤
  │ BIO        │ O(N)=10万    │ 不要        │ O(N) 極めて高い│
  │ select     │ 1            │ O(N) 毎回   │ O(N) パラメータ転送│
  │ poll       │ 1            │ O(N) 毎回   │ O(N) パラメータ転送│
  │ epoll      │ 1〜少数      │ O(就緒数)   │ O(log N) 赤黒木│
  └────────────┴──────────────┴─────────────┴────────────────┘

8.2 Netty主要パラメータチューニングリスト

// ════════════════════════════════════════════════════════════════
// Nettyサーバーの主要パラメータチューニング(本番環境の参考)
// ════════════════════════════════════════════════════════════════
ServerBootstrap bootstrap = new ServerBootstrap();

bootstrap
    .group(bossGroup, workerGroup)
    .channel(NioServerSocketChannel.class)

    // ── TCP層パラメータ ──────────────────────────────────────────────
    // SO_BACKLOG: acceptキューの長さ(3ウェイハンドシェイク完了後、accept()待ちのキュー)
    // 高並行シナリオでは適切に増やす、デフォルト128は小さすぎる
    .option(ChannelOption.SO_BACKLOG, 1024)

    // TCP_NODELAY: Nagleアルゴリズムの無効化
    // リアルタイム性が要求されるシナリオでは必須。小さいパケットのバッチ処理による遅延を回避
    .childOption(ChannelOption.TCP_NODELAY, true)

    // SO_KEEPALIVE: カーネルTCP Keep-Aliveの有効化
    // アプリケーション層の心拍と組み合わせ、二重保証とする
    .childOption(ChannelOption.SO_KEEPALIVE, true)

    // SO_REUSEADDR: ポートの再利用を許可
    // サービス再起動時に「Address already in use」エラーを回避
    .option(ChannelOption.SO_REUSEADDR, true)

    // SO_RCVBUF / SO_SNDBUF: カーネルの受信/送信バッファサイズ
    // デフォルトで通常は十分だが、高スループットシナリオでは適切に増やす
    .childOption(ChannelOption.SO_RCVBUF, 128 * 1024)  // 128KB
    .childOption(ChannelOption.SO_SNDBUF, 128 * 1024)  // 128KB

    // ── Nettyメモリ管理 ───────────────────────────────────────────
    // プーリングされたDirect Memoryアロケータを使用(GC負荷軽減 + カーネルコピー削減)
    .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)

    // ── 書き込みキュー ──────────────────────────────────────────────
    // 処理能力を超過した場合の接続待機キュー(OOM防止)
    .childOption(ChannelOption.WRITE_BUFFER_WATER_MARK,
        new WriteBufferWaterMark(32 * 1024, 64 * 1024));

// ── OS層パラメータ(sysctlまたは/etc/sysctl.confで設定が必要)──────────────
// net.core.somaxconn = 65535          # acceptキューの上限
// net.ipv4.tcp_max_syn_backlog = 8192  # SYNキューの上限
// net.ipv4.tcp_keepalive_time = 30     # Keep-Alive検出開始時間
// net.ipv4.tcp_keepalive_intvl = 10    # 検出間隔
// net.ipv4.tcp_keepalive_probes = 3    # 検出回数
// net.ipv4.tcp_fin_timeout = 15        # FIN_WAIT2タイムアウト時間
// fs.file-max = 1000000                # システムの最大ファイルディスクリプタ数
// ulimit -n 1000000                    # プロセスの最大ファイルディスクリプタ数

9. まとめ:抽象レイヤー全体像

本記事のすべての知識を、最終的な全体像としてまとめます。

╔══════════════════════════════════════════════════════════════════╗
║              Netty → Java NIO → OS 抽象レイヤー全体図             ║
╠══════════════════════════════════════════════════════════════════╣
║                                                                  ║
║  【あなたのコード】                                                ║
║  handler.channelRead(ctx, msg)                                   ║
║       ↑                                                          ║
║  ─────┼─────────────────────────────────────────────────────── ║
║  【Nettyフレームワーク層】                                        ║
║  ChannelPipeline → Handler Chain → ByteBuf                      ║
║  NioEventLoop (while true) → processSelectedKeys()              ║
║  IdleStateHandler → ApplicationHeartbeatHandler (アプリケーション層心拍) │
║       ↑                                                          ║
║  ─────┼─────────────────────────────────────────────────────── ║
║  【Java NIO層】                                                    ║
║  Selector (epollラッパー) → SelectionKey → SocketChannel       ║
║  selector.select() ↔ epoll_wait()                               ║
║       ↑                                                          ║
║  ─────┼─────────────────────────────────────────────────────── ║
║  【Linuxカーネル層】                                                ║
║  epoll (eventpoll):                                              ║
║    赤黒木 (rbr)   ← 全ての監視対象FDを格納、O(log N)で追加・削除   ║
║    就緒リスト (rdl) ← イベントのあるFD、O(1)で取得                ║
║    コールバックメカニズム ← データ到着時に自動トリガー、ポーリング不要 │
║       ↑                                                          ║
║  File Descriptor (FD = 整数ハンドル)                               ║
║       ↑                                                          ║
║  TCP Socket → TCB (struct tcp_sock):                             ║
║    四要素 (接続識別子)                                             ║
║    last_active_ts (接続維持の核)                                  ║
║    send/recv buffer (データバッファ)                              ║
║    Keep-Alive検出状態 (カーネル層の接続維持)                        ║
║       ↑                                                          ║
║  ─────┼─────────────────────────────────────────────────────── ║
║  【ハードウェア層】                                                  ║
║  tcp_hashinfo ハッシュテーブル ← O(1)で四要素に基づきデータパケットをTCBへルーティング │
║  NIC DMA + ハードウェア割り込み → ソフト割り込み ksoftirqd → プロトコルスタック処理 │
║                                                                  ║
╠══════════════════════════════════════════════════════════════════╣
║                        核心的な結論の要約                         ║
╠══════════════════════════════════════════════════════════════════╣
║                                                                  ║
║  1. TCP接続の本質 = カーネルメモリ上のTCBデータ構造                  ║
║  2. 接続維持の本質 = TCB.last_active_tsを継続的に更新すること         ║
║  3. ハッシュテーブル O(1) = パケット受信時にTCBを高速に検索(ルーティング)│
║  4. 赤黒木 O(logN) = epollの監視リスト管理(追加・削除シナリオ)     │
║  5. epoll O(1)覚醒 = コールバック + 就緒リスト、全接続をポーリングしない │
║  6. NioEventLoop = 1スレッド + while(true) + epoll_wait + タスクキュー │
║  7. 二層の接続維持 = カーネルKeep-Alive (ネットワーク層) + Netty心拍 (アプリケーション層) │
║                                                                  ║
╚══════════════════════════════════════════════════════════════════╝

参考文献

テーマ 推奨資料
Linux epoll ソースコード fs/eventpoll.c in Linux kernel
TCP プロトコル仕様 RFC 793 (TCP), RFC 1122 (Requirements)
Netty ソースコード NioEventLoop.java, AbstractChannel.java
高並行C10K問題 Dan Kegel's C10K Problem page
Linux ネットワークプログラミング 『UNIXネットワークプログラミング』Vol.1 第3版 (Stevens)
カーネルのパケット受信フロー 『詳解Linuxカーネル』など

タグ: Netty TCP/IP epoll Linux Kernel NIO

8月29日 14:22 投稿