イベント駆動I/Oモデルの系譜(select/poll/epoll/kqueue)
なぜLinuxはepoll、BSDはkqueue、Windowsはまったく別物のIOCPなのか。同じ多重化I/Oでも設計思想が割れた経緯を、O(n)スキャンから登録モデル・通知/完了モデルまで系統で整理します。
- 1.select/pollは毎回 全fd をカーネルへ渡して走査するためO(n)。epoll(Linux)とkqueue(BSD)は監視対象を一度だけ登録し、レディなものだけ返す登録モデルでこのコストを断った。
- 2.epollはfdごとにイベントを足す加算的API、kqueueはchangelistとeventlistを1呼び出しで束ねる汎用イベントキュー。レベルトリガとエッジトリガの選択はどちらも持つ。
- 3.IOCP(Windows)は「準備できた」を知らせる通知モデルではなく「I/Oが完了した」を返す完了モデル。設計の起点がそもそも違い、これがio_uringの先祖筋にあたる。
同じ目的、割れた設計
多数の接続を1スレッドで監視する「I/O多重化」という目的は共通でも、その実装はOSごとに別系統へ枝分かれしました。Linuxはepoll、BSD系(FreeBSD/macOS)はkqueue、Windowsは思想からして違うIOCPです。本記事は、その分岐がいつ・なぜ起きたかを系統樹として整理します。epoll単体の内部(赤黒木・レディリスト・io_uring)は /os/epoll-io-uring/ で詳説しているので、ここでは横並びの比較と設計思想に軸を置きます。前提として、監視対象はすべてファイルディスクリプタ、つまりソケットやパイプのカーネル側状態を指す番号である点を押さえておきます。
第1世代:select(1983, BSD 4.2)と poll(1986, System V)
すべての出発点は select です。「監視したいfd集合を渡す→何か起きたら戻る」という素朴なAPIですが、構造的に3つのコストを毎回払います。
- コピー:呼び出すたびにfd集合をユーザー空間からカーネルへ渡す。
- 全走査:カーネルは渡されたfdを端から全部チェックしてレディか調べる。
- 戻り後の再走査:アプリ側も全fdを舐めてどれが立ったか探す。
実際にイベントが起きたfdの数とは無関係に、監視総数 n に比例する仕事(O(n))が発生します。poll は select の改良版で、ビットマスクfd_setをやめてpollfd配列にしたためFD_SETSIZE(多くの実装で1024)の上限を撤廃しました。しかし渡したfdを全走査する計算量はO(n)のままです。ここが両者の本質的な共通点であり、限界でした。
pollが解決したのはfd数の上限だけです。毎回のコピーと全走査というO(n)構造はselectと同一で、計算量は改善しません。「pollなら速い」は誤り。多重化の真の高速化はepoll/kqueueの登録モデルを待つことになります。
分岐点:登録モデルの登場(2000-2002)
2000年前後、C10K問題(1万同時接続)が顕在化し、O(n)の全走査が致命傷になりました。ここで監視対象を毎回渡すのをやめ、一度カーネルに登録して状態を常駐させるという同じ着想から、二つの実装がほぼ同時期に別々に生まれます。
- kqueue:FreeBSD 4.1(2000年)に Jonathan Lemon が導入。BSD系(のちにmacOS/iOSへ)。
- epoll:Linux 2.5.44(2002年)にマージ。当初は
sys_epoll。
両者とも「登録は一度、待機時はレディなものだけ返す」ため、コストは監視総数 n ではなく実際にレディになった数 k に比例します。9999接続がアイドルで1接続だけ来ても仕事はその1件分——これがO(n)の呪縛を断った正体です。「向こうから知らせる」非同期通知の土台は割り込みとI/Oにあります。
epoll と kqueue:同じ思想、違うAPI形状
着想は同じでもAPIの形は対照的です。epollはfd単位の操作を3つに分けた加算的APIです。
int epfd = epoll_create1(0); // インスタンス生成
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 監視対象を1つ登録
int n = epoll_wait(epfd, events, maxev, -1); // レディなものだけ受け取る
対する kqueue は、変更要求の配列(changelist)と結果の配列(eventlist)を1回の呼び出しに束ねる汎用イベントキューです。
int kq = kqueue();
struct kevent ch;
EV_SET(&ch, fd, EVFILT_READ, EV_ADD, 0, 0, NULL); // 登録要求を組み立て
// 1呼び出しで「登録」と「待機」を同時に行える
int n = kevent(kq, &ch, 1, events, maxev, NULL);
この差が効いてきます。epollは登録のたびにepoll_ctlシステムコールが要りますが、kqueueは複数の変更と待機を1回のkeventに集約できるため、登録が多いワークロードでシステムコール回数を減らせます。さらに kqueue のEVFILT_*は読み書きだけでなく、ファイル変更(EVFILT_VNODE)、プロセス終了(EVFILT_PROC)、シグナル、タイマー(EVFILT_TIMER)まで同じキューで扱える汎用性を持ちます。epollはfdイベントが基本で、タイマーやシグナルはtimerfd/signalfdという別のfdを噛ませて初めて統合できます。
| 観点 | select / poll | epoll(Linux) | kqueue(BSD/macOS) |
|---|---|---|---|
| 登場 | 1983 / 1986 | Linux 2.5.44(2002) | FreeBSD 4.1(2000) |
| 監視集合の保持 | 毎回渡す(コピー) | カーネル常駐(一度登録) | カーネル常駐(一度登録) |
| 待機コスト | O(n) 全走査 | O(1)に近い(レディのみ) | O(1)に近い(レディのみ) |
| API形状 | 1関数に集合を渡す | create / ctl / wait の3分割 | kqueue + kevent の2つ |
| 登録と待機 | 毎回一括 | ctlとwaitが別呼び出し | 1回のkeventで束ねられる |
| 扱える対象 | fdのみ | fd中心(timerfd等で拡張) | fd・ファイル・プロセス・シグナル・タイマー |
| トリガ方式 | レベルのみ | レベル / エッジ | レベル / エッジ(EV_CLEAR) |
レベルトリガとエッジトリガ:両者に共通する分岐
通知の方式は epoll・kqueue どちらも2系統を持ちます。ここはバグの温床なので原理を正確に。
- レベルトリガ(LT):いま条件が成立している限り通知する。受信バッファにデータが残っていれば、読み切るまで毎回そのfdが返る。状態(レベル)を見る。epollの既定。
- エッジトリガ(ET):状態が変化した瞬間だけ通知する。新たにデータが到着したそのとき1回返り、未読が残っていても次は返さない。変化(エッジ)を見る。epollは
EPOLLET、kqueueはEV_CLEARフラグで指定する。
受信: [ ] →到着→ [####] →一部読む→ [##] →残りそのまま
LT通知: ● ●(まだある) ●(まだある)…毎回
ET通知: ● (変化なし→通知なし)
ETでは通知1回でそのfdを EAGAIN/EWOULDBLOCK が返るまで読み切る/書き切るのが鉄則です。途中でやめると残りを知らせる次の通知が来ず、そのfdが永久に固まります。対象fdはノンブロッキング必須です。
ETで特定接続だけ応答が止まる障害の多くはdrain(読み切り)忘れです。read が要求より少なく返っても「もう無い」とは限りません。EAGAIN を見るまでループする——これを守らないと、低負荷では再現せず高負荷でだけ詰まる厄介な形で出ます。kqueueのEV_CLEARも同じ規律が要ります。
もう一つの系統:IOCP(Windows)の完了モデル
ここまでのselect/poll/epoll/kqueueはすべて通知モデルです。「fdが準備できた」とだけ教え、実際のread/writeはアプリが別途呼ぶ。つまり多重化が知らせるのは「準備完了」までで、データ転送のシステムコールは依然アプリ持ちです。
Windowsの IOCP(I/O Completion Ports) は起点からして違います。アプリは最初から非同期I/O(ReadFile等のオーバーラップI/O)を発行し、カーネルが裏で転送を完了させ、「I/Oが完了した」という事実を完了ポートのキューに積みます。アプリはGetQueuedCompletionStatusで完了を回収するだけ。これが完了モデルです。
通知モデル(epoll/kqueue):
待機 → 「読めるよ」と通知 → アプリがread()を呼ぶ → データ取得
完了モデル(IOCP):
アプリが非同期readを発行 → カーネルが裏で転送 → 「読み終わった」を回収
設計思想の核心は次の点です。通知モデルは「いつ読めるか(readiness)」を中心に据え、完了モデルは「I/Oそのものを投げて結果だけ受け取る(completion)」を中心に据えます。IOCPはさらに完了ポートにスレッドプールを束ね、複数ワーカーへ完了を分配して取り出すスケーラビリティ機構を最初から内蔵します(スレッドプールの発想に近い)。Linuxがこの完了モデルへ到達したのは2019年のio_uringで、約20年遅れてWindowsの設計思想に合流した格好です。
| 観点 | 通知モデル(select/poll/epoll/kqueue) | 完了モデル(IOCP / io_uring) |
|---|---|---|
| 中心概念 | 準備完了(readiness)の通知 | I/O完了(completion)の回収 |
| 転送の実行 | 通知後にアプリがread/writeを呼ぶ | カーネルが裏で実行し結果を返す |
| システムコール | イベント毎に転送呼び出しが残る | 発行をバッチ化・削減できる |
| スレッド分配 | アプリ側で設計 | IOCPは完了ポートに内蔵 |
| 代表 | epoll(Linux) / kqueue(BSD) | IOCP(Windows) / io_uring(Linux 5.1+) |
libeventやlibuv(Node.jsの基盤)といった抽象ライブラリは、epoll・kqueue・IOCPの違いを吸収して単一APIに見せます。便利な反面、通知モデルと完了モデルという根本の違いまで平らに見えてしまう。性能チューニングや障害解析では、足元で動いているのがどの系統かを意識する必要があります。
まとめ
出発点は select(1983)。poll(1986)はfd上限を外しただけでO(n)全走査は据え置き。2000年前後のC10Kを契機に、監視集合をカーネル常駐させる登録モデルがkqueue(FreeBSD 2000)とepoll(Linux 2002)に分岐——着想は同じでも、epollはfd単位の加算的API、kqueueは変更と待機を束ねる汎用イベントキューという形の違いが残りました。トリガのLT/ETは両者に共通し、ETはEAGAINまで読み切る規律が必須です。そしてIOCP(Windows)は通知ではなく完了を返す別系統で、Linuxは20年後のio_uringでこの完了モデルへ合流しました。土台にあるのはファイルディスクリプタと割り込みによる非同期通知——この線でつなぐと、OSごとにAPIが割れた理由が一本に見えてきます。
OS Article
イベント駆動I/Oモデルの系譜(select/poll/epoll/kqueue)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
epoll
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 8
導入後に効く点
epollはfdごとにイベントを足す加算的API、kqueueはchangelistとeventlistを1呼び出しで束ねる汎用イベントキュー。レベルトリガとエッジトリガの選択はどちらも持つ。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 8
判断チェックリスト
- 自社の用途が「epoll / kqueue」に近いか確認する。
- 強みである「select/pollは毎回 全fd をカーネルへ渡して走査するためO(n)。epoll(Linux)とkqueue(BSD)は監視対象を一度だけ登録し、レディなものだけ返す登録モデルでこのコストを断った。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。