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カーネル』など |