イベントループとI/O多重化(epoll・kqueue・io_uring)
1スレッドで数万接続をさばく高並行サーバーの心臓部が分かる。select/epoll/kqueueのスケーラビリティ差と、io_uringが変えた設計の勘所を原理から押さえます。
- 1.selectとpollは毎回全fdを走査するためO(N)。epoll/kqueueは関心の登録と完了通知を分離し、起きたfdだけ返すのでO(発生数)に近い。
- 2.通知方式にはレベルトリガ(準備できている限り通知)とエッジトリガ(状態が変わった瞬間だけ通知)があり、後者はEAGAINまで読み切る運用が必須。
- 3.io_uringはreadiness(準備完了通知)からcompletion(完了通知)へ転換し、submit/completionの2リングで操作自体を非同期化、syscall回数も削減する。
I/O多重化が解く問題
1本のスレッドで多数の接続を同時にさばきたい。素朴にやると、各接続のreadがデータ到着までスレッドを止める(ブロッキング)ため、接続ごとにスレッドが要ります。接続が1万本なら1万スレッドとなり、コンテキストスイッチとメモリで破綻します。これが同期処理と非同期処理で触れた「待ちでスレッドを浪費する」問題の核心です。
I/O多重化(I/O multiplexing) は、複数のファイルディスクリプタ(fd)を1つのシステムコールでまとめて監視し、「どれが読み書き可能になったか」を1スレッドで受け取る仕組みです。準備できたfdだけを順に処理すれば、待ち時間にスレッドを止めずに済みます。この監視ループがイベントループで、epoll_wait等で待ち、返ってきたイベントを対応するハンドラへ振り分ける、という単純な反復で回ります。
select/pollの限界
最古の多重化APIであるselectとpollは、呼ぶたびに監視したいfdの集合全体をカーネルへ渡し、カーネルが全fdを走査して準備状態を埋めて返します。問題は、監視対象がN個なら1回の呼び出しごとにO(N) の走査が発生する点です。実際にイベントが起きるfdが1個でも、N個ぶん調べ直します。
加えてselectはfd_setのビット幅(多くの実装でFD_SETSIZE=1024)に縛られ、pollは配列サイズに上限こそないものの、毎回ユーザー空間とカーネル空間の間で全配列をコピーします。接続数に比例してコストが膨らむため、C10K(同時1万接続)級では実用に耐えません。
| API | 計算量/回 | fd集合の扱い | 上限 | 移植性 |
|---|---|---|---|---|
| select | O(N)走査 | 毎回全集合をコピー | FD_SETSIZE(約1024) | ほぼ全UNIX/Windows |
| poll | O(N)走査 | 毎回全配列をコピー | 実質メモリ次第 | POSIX系 |
| epoll | O(発生数) | 登録は1度、通知のみ受信 | メモリ次第 | Linux専用 |
| kqueue | O(発生数) | changelistで差分登録 | メモリ次第 | BSD/macOS |
epoll/kqueueのスケーラビリティ
epoll(Linux)とkqueue(BSD/macOS)が変えたのは、「関心の登録」と「完了の受信」を分離したことです。fdの監視はカーネル内部の永続的なデータ構造(赤黒木など)に一度だけ登録しておき、状態が変化したfdはカーネルが準備完了リスト(ready list)へ移す。アプリはepoll_waitでそのリストだけを受け取ります。
int ep = epoll_create1(0);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sock };
epoll_ctl(ep, EPOLL_CTL_ADD, sock, &ev); // 関心を1度だけ登録
struct epoll_event evs[64];
for (;;) {
int n = epoll_wait(ep, evs, 64, -1); // 起きたぶんだけ返る
for (int i = 0; i < n; i++) handle(evs[i].data.fd);
}
決定的な差は、epoll_waitが返すのが実際にイベントの起きたfdの数に比例する点です。10万本監視していても、今回読めるのが3本なら3件しか返らない。毎回の全走査が消え、計算量が監視数Nではなく発生数に比例するため、アイドル接続が大量にあっても安定します。kqueueも思想は同じで、登録はchangelistで差分指定し、発生イベントはeventlistで受け取ります。さらにkqueueはソケットだけでなくファイル変更・シグナル・タイマー・プロセス終了まで同じ仕組みで扱える汎用性を持ちます。
epollはLinux固有、kqueueはBSD系(macOS含む)固有で、APIに互換性はありません。だからクロスプラットフォームな非同期ライブラリ(libuv、libev、Goランタイムなど)は、Linuxならepoll、macOS/BSDならkqueue、Windowsなら後述のIOCPと、OSごとに最速のAPIへ切り替える抽象化層を内部に持ちます。アプリ側が同じイベントループAPIで書けるのはこの層のおかげです。
レベルトリガとエッジトリガ
通知の「鳴らし方」には2方式があります。挙動を取り違えるとハングや取りこぼしを生む、実務上の最頻出ポイントです。
- レベルトリガ(LT): そのfdが条件を満たしている限り、毎回の
epoll_waitで通知し続ける。受信バッファにデータが残っていれば、読み切らなくても次回また通知が来る。select/pollはLTのみ。epollの既定もLT。 - エッジトリガ(ET、
EPOLLET): 状態が変化した瞬間(例 空→データ到着)にだけ通知する。一度通知を受けたら、次の変化があるまで再通知されない。
ETでは「通知が来たら**EAGAIN(これ以上読めない)が返るまでループで全部読み切る**」のが鉄則です。途中でやめると、残りデータは次のエッジが来るまで通知されず、データが滞留したまま固まります。
// エッジトリガでは EAGAIN まで読み切る
for (;;) {
ssize_t k = read(fd, buf, sizeof buf);
if (k > 0) { process(buf, k); continue; }
if (k == 0) { close_conn(fd); break; } // 相手がクローズ
if (errno == EAGAIN) break; // 読み切った
if (errno == EINTR) continue; // シグナル割込み
handle_error(fd); break;
}
ETは「同じfdへの通知回数」を減らせるため、エッジ1回で一括処理する設計と相性が良く、epoll_waitの起床回数を抑えられます。一方LTは「読み残しても次回また通知される」ぶん、ノンブロッキング化やEAGAIN処理の取りこぼしに寛容で、実装が素直です。試験・面接では「ETはノンブロッキングfd+EAGAINまで読み切りが必須/LTは取りこぼしに強い」という対比を押さえましょう。
epollが「読める」と言っても、1回のreadで全データが読める保証はありません。accept直後のソケットを必ずノンブロッキング(O_NONBLOCK)にしておかないと、ETで読み切ろうとループした最後のreadがブロックしてイベントループ全体を止めます。多重化は「いつ呼ぶか」を教えるだけで、呼んだ先がブロックするかは別問題です。
io_uringによる設計転換:readinessからcompletionへ
epoll/kqueueはreadiness(準備完了)モデルです。「読める状態になった」と教わってから、アプリが改めてreadシステムコールを発行する。つまり通知と実I/Oが別々で、接続ごと・イベントごとにシステムコールが要ります。高頻度になると、このシステムコールのオーバーヘッド(ユーザー/カーネル境界の往復)が効いてきます。
Linuxのio_uringは、ここをcompletion(完了)モデルへ転換しました。アプリは「読め」という操作そのものを依頼し、カーネルが裏で実行して完了を通知します。中核は、ユーザー空間とカーネルで共有された2つのリングバッファです。
- SQ(Submission Queue): アプリが「このfdをこのバッファへ
readせよ」といった要求(SQE)を書き込むリング。 - CQ(Completion Queue): カーネルが完了結果(CQE、戻り値や
errno相当)を書き込むリング。
ユーザー空間 共有メモリ カーネル
操作を積む ──→ [ SQ: read, write... ] ──→ 実行
結果を読む ←── [ CQ: 結果, 結果... ] ←── 完了を書く
リングは共有メモリなので、要求の投入も結果の取得もシステムコールなしで進められます。io_uring_enterを1回呼べば複数の操作をまとめて投入(バッチsubmit) でき、システムコール回数が操作数からバッチ数へ激減します。さらにポーリングモード(SQPOLL)ではカーネルスレッドがSQを監視し、通常運用でio_uring_enterすら不要になり得ます。
転換の本質は、epollが「準備できたか」を返すのに対し、io_uringは「操作が終わったか」を返す点です。これによりread/writeだけでなくaccept/connect/fsync、さらに通常ファイルI/O(epollが苦手な領域)まで同じ枠組みで非同期化できます。完了を待って次を投入する従来形から、操作を先回りで積んでおき完了を回収する形へ、イベントループの骨格そのものが変わります。
強力な反面、io_uringは比較的新しくカーネルバージョン依存が大きく(基本は5.1以降、機能ごとに必要版が上がる)、過去には脆弱性報告も相次いだため、セキュリティ方針でカーネル側が無効化している環境もあります。共有リングのライフタイム管理(投入したバッファを完了まで生かす)も誤るとメモリ破壊につながります。可搬性が要るならepoll/kqueueを基本線に、io_uringは性能要件と運用環境を見極めて採用するのが堅実です。
Windowsとの対比:IOCP
WindowsのIOCP(I/O Completion Ports) は、実はずっと前からcompletionモデルでした。非同期I/Oを発行し、完了を完了ポートのキューから受け取る設計で、io_uringの発想に近い。歴史的に、UNIX系はreadinessモデル(epoll/kqueue)、Windowsはcompletionモデル(IOCP)と分かれていたものが、io_uringの登場でLinuxもcompletion側の選択肢を持った、と整理すると見通しが良くなります。
まとめ
I/O多重化の進化は3段で捉えられます。(1) select/pollは毎回全fdをO(N)走査するため高並行で破綻する。(2) epoll/kqueueは登録と通知を分離し、起きたfdだけを返すことで計算量を発生数に比例させ、レベル/エッジの2トリガで通知粒度を選べる。(3) io_uringはreadinessからcompletionへ転換し、submit/completionの2リングで操作自体を非同期化、システムコール回数まで削る。いずれも「待ちでスレッドを止めない」という一点を、通知の効率と非同期化の深さで押し進めたものです。この土台の上に、状態機械で中断・再開を実現するasync/awaitの内部実装や、並行性モデルの各方式が乗っています。
プログラミング Article
イベントループとI/O多重化(epoll・kqueue・io_uring)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
イベントループ
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
通知方式にはレベルトリガ(準備できている限り通知)とエッジトリガ(状態が変わった瞬間だけ通知)があり、後者はEAGAINまで読み切る運用が必須。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「イベントループ / epoll」に近いか確認する。
- 強みである「selectとpollは毎回全fdを走査するためO(N)。epoll/kqueueは関心の登録と完了通知を分離し、起きたfdだけ返すのでO(発生数)に近い。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。