TL

イベントループとI/O多重化(epoll・kqueue・io_uring)

1スレッドで数万接続をさばく高並行サーバーの心臓部が分かる。select/epoll/kqueueのスケーラビリティ差と、io_uringが変えた設計の勘所を原理から押さえます。

応用イベントループepollio_uringI/O多重化非同期最終更新: 2026-06-21
TL;DR要点だけ先に
  • 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であるselectpollは、呼ぶたびに監視したいfdの集合全体をカーネルへ渡し、カーネルが全fdを走査して準備状態を埋めて返します。問題は、監視対象がN個なら1回の呼び出しごとにO(N) の走査が発生する点です。実際にイベントが起きるfdが1個でも、N個ぶん調べ直します。

加えてselectfd_setのビット幅(多くの実装でFD_SETSIZE=1024)に縛られ、pollは配列サイズに上限こそないものの、毎回ユーザー空間とカーネル空間の間で全配列をコピーします。接続数に比例してコストが膨らむため、C10K(同時1万接続)級では実用に耐えません。

API計算量/回fd集合の扱い上限移植性
selectO(N)走査毎回全集合をコピーFD_SETSIZE(約1024)ほぼ全UNIX/Windows
pollO(N)走査毎回全配列をコピー実質メモリ次第POSIX系
epollO(発生数)登録は1度、通知のみ受信メモリ次第Linux専用
kqueueO(発生数)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はソケットだけでなくファイル変更・シグナル・タイマー・プロセス終了まで同じ仕組みで扱える汎用性を持ちます。

なぜLinuxとBSDで別物なのか

epollはLinux固有、kqueueはBSD系(macOS含む)固有で、APIに互換性はありません。だからクロスプラットフォームな非同期ライブラリ(libuv、libev、Goランタイムなど)は、Linuxならepoll、macOS/BSDならkqueue、Windowsなら後述のIOCPと、OSごとに最速のAPIへ切り替える抽象化層を内部に持ちます。アプリ側が同じイベントループAPIで書けるのはこの層のおかげです。

レベルトリガとエッジトリガ

通知の「鳴らし方」には2方式があります。挙動を取り違えるとハングや取りこぼしを生む、実務上の最頻出ポイントです。

  • レベルトリガ(LT): そのfdが条件を満たしている限り、毎回のepoll_waitで通知し続ける。受信バッファにデータが残っていれば、読み切らなくても次回また通知が来る。selectpollは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;
}
LT vs ET の使い分け

ETは「同じfdへの通知回数」を減らせるため、エッジ1回で一括処理する設計と相性が良く、epoll_waitの起床回数を抑えられます。一方LTは「読み残しても次回また通知される」ぶん、ノンブロッキング化やEAGAIN処理の取りこぼしに寛容で、実装が素直です。試験・面接では「ETはノンブロッキングfd+EAGAINまで読み切りが必須/LTは取りこぼしに強い」という対比を押さえましょう。

多重化はブロッキングを禁じない

epollが「読める」と言っても、1回のreadで全データが読める保証はありませんaccept直後のソケットを必ずノンブロッキング(O_NONBLOCK)にしておかないと、ETで読み切ろうとループした最後のreadブロックしてイベントループ全体を止めます。多重化は「いつ呼ぶか」を教えるだけで、呼んだ先がブロックするかは別問題です。

io_uringによる設計転換:readinessからcompletionへ

epollkqueuereadiness(準備完了)モデルです。「読める状態になった」と教わってから、アプリが改めて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は「操作が終わったか」を返す点です。これによりreadwriteだけでなくacceptconnectfsync、さらに通常ファイルI/O(epollが苦手な領域)まで同じ枠組みで非同期化できます。完了を待って次を投入する従来形から、操作を先回りで積んでおき完了を回収する形へ、イベントループの骨格そのものが変わります。

io_uringの注意点

強力な反面、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) selectpollは毎回全fdをO(N)走査するため高並行で破綻する。(2) epollkqueue登録と通知を分離し、起きた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、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

イベントループepollio_uringI/O多重化非同期イベントループepollio_uring