TL

高性能I/Oモデル(epoll・io_uring)の内部

数万接続を1スレッドでさばく現代サーバーの心臓部。selectのO(n)をepollがどう潰し、io_uringがシステムコールすら消す原理まで一気に理解できます。

応用epollio_uringI/O多重化Linuxイベント駆動システムコール最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.select/pollは毎回 全fd を走査・コピーするためO(n)。epollはレディなfdだけを返すレディリスト方式でO(1)に近づく。
  • 2.epollのエッジトリガ(ET)は「変化した瞬間」だけ通知。EAGAINまで読み切らないとイベントを取りこぼす。レベルトリガ(LT)は「条件が成立し続ける限り」通知。
  • 3.io_uringはSQ/CQの2つのリングバッファをカーネルと共有し、SQPOLLモードではシステムコールゼロで非同期I/Oを発行・回収できる。

なぜ高速I/Oモデルが要るのか

C10K問題(1万同時接続)以降、サーバーは「1接続=1スレッド」では立ち行かなくなりました。スレッドはスタックとスケジューリングのコストが重く(/os/process-thread/ 参照)、数万本も走らせれば文脈切り替えだけでCPUが溶けます。そこで主流になったのが、1つのスレッドで多数のfd(ファイルディスクリプタ)を監視し、準備できたものだけ処理する I/O多重化です。本記事は、その実装が selectepollio_uring とどう進化し、各段階で何が遅さの正体だったかを内部から解剖します。

select/poll のO(n)問題

select/poll の使い方は「監視したいfdの集合を渡す → 何か起きたら戻る」です。一見便利ですが、その内部には3つの構造的コストがあります。

  1. 毎回のコピー:呼び出すたびに、監視対象fd集合をユーザー空間からカーネルへコピーする。1万fdなら毎回1万件分。
  2. 全走査(スキャン):カーネルは渡されたfdを全部チェックして、どれがレディか調べる。準備できたのが1個でも全件見る。
  3. 戻り後の再走査:戻ってきた後、アプリ側も全fdを舐めてどれが立ったか探す。

つまり「実際にイベントが起きたfdの数」とは無関係に、監視対象の総数 n に比例した仕事(O(n))が毎回発生します。アイドルな接続が大半でも、その全員分のコストを払い続ける——これがC10Kでの致命傷でした。

観点selectpollepoll
fd数の上限FD_SETSIZE(通常1024)上限なし上限なし
毎回のfd集合コピーあり(全件)あり(全件)なし(登録は一度)
レディ判定全fdを走査 O(n)全fdを走査 O(n)レディリストを返す O(1)に近い
戻り後の探索全fdを再走査全fdを再走査返ったイベントだけ見る
状態の保持毎回作り直し毎回作り直しカーネル側に常駐
試験・面接の頻出ポイント

「epollがselectより速い理由」を一言で言えるかが分かれ目です。答えはfd数の上限撤廃ではなく、O(n)の全走査をO(1)に近いレディリスト方式へ変えたこと、そして監視集合をカーネル側に常駐させ毎回のコピーを排したことの2点。poll は上限こそ無いものの計算量は select と同じO(n)です。

epoll:レディリストという発想

epoll の核心は、監視対象を一度だけ登録し、カーネル内部に状態を持たせることです。APIは3つに分かれます。

int epfd = epoll_create1(0);                 // epollインスタンス生成
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);     // 監視fdを登録(一度だけ)
int n = epoll_wait(epfd, events, maxev, -1); // レディなものだけ受け取る

内部では、登録されたfdは赤黒木(バランス木)で管理され、追加・削除・検索が O(log n) で行えます。ここが「毎回コピー」を不要にする土台です。そして決定的なのがレディリストの存在です。

  • 各fdの基になるデバイス/ソケットには、データ到着などのイベント時に呼ばれるコールバックが仕掛けられている。
  • イベントが起きると、そのコールバックが当該fdをレディリスト(連結リスト)に繋ぐだけ。
  • epoll_wait は、このレディリストを見るだけで「準備できたfd」を返せる。空ならスリープし、イベントで起こされる。

結果、epoll_wait のコストは監視総数 n ではなく、実際にレディになった数 k に比例します。9999接続がアイドルで1接続だけ来ても、仕事はその1件分。これがO(n)の呪縛を断ち切った正体です。データ到着の「向こうから知らせる」非同期通知は/os/interrupt-io/ の割り込み機構が土台になっています。

エッジトリガ(ET)とレベルトリガ(LT)

epoll が登録時に選べる、いつ通知するかの2方式です。ここはバグの温床なので原理を正確に。

  • レベルトリガ(LT, デフォルト):「いま条件が成立している限り」通知する。受信バッファにデータが残っていれば、読み切るまで epoll_wait は毎回そのfdを返す。状態(レベル)を見る
  • エッジトリガ(ET):「状態が変化した瞬間だけ」通知する。新たにデータが到着したそのとき1回だけ返り、まだ未読データが残っていても次は返さない。変化(エッジ)を見る
受信:   [   ] →データ到着→ [####] →一部読む→ [##] →残りはそのまま
LT通知:               ●           ●(まだある)   ●(まだある)…毎回
ET通知:               ●            (変化なし→通知なし)

ETでは「通知された1回」で、そのfdを EAGAIN/EWOULDBLOCK が返るまで読み切る/書き切る のが鉄則です。途中でやめると残りデータの存在を知らせる次の通知が来ず、そのfdが永久に固まることがあります。当然、対象fdはノンブロッキングでなければループ自体がブロックします。

ETの取りこぼしは“発生条件が見えにくい”バグ

ETで「たまに特定の接続だけ応答が止まる」障害の多くは、読み切り(drain)忘れです。read が要求バイト数より少なく返っても「もう無い」とは限りません。EAGAIN を見るまでループする——これを守らないと、低負荷時は再現せず高負荷時だけ詰まる、という厄介な形で表面化します。

観点レベルトリガ(LT)エッジトリガ(ET)
通知の条件条件が成立し続ける間ずっと状態が変化した瞬間のみ
読み方一度に全部読まなくてよいEAGAINまで読み切る必要
通知回数多くなりがち最小限
実装の難度素直で安全drain忘れで詰まりやすい
必須条件なくても動くfdはノンブロッキング必須

なお ET は通知回数が減るぶん epoll_wait 往復を節約できますが、性能差より正しさの担保のしやすさでLTを選ぶ設計も現実には多くあります。

io_uring:システムコールという最後の壁

epoll でも、実際の read/write 自体は依然システムコールです。1イベントごとにユーザー↔カーネルのモード切替が走り、その固定コストは/os/system-call/ のとおり無視できません。さらに epoll は「準備できたか」を教えるだけで、データのコピーはアプリが別途呼ぶ通知モデルにとどまります。

io_uring はこの構図を完了モデルへ転換します。鍵は、カーネルとユーザー空間で共有メモリ上に置いた2本のリングバッファです。

  • SQ(Submission Queue):アプリが「やってほしいI/O(read/write/accept/sendなど)」をSQEとして書き込むリング。
  • CQ(Completion Queue):カーネルが完了結果をCQEとして書き込むリング。
ユーザー空間                 共有メモリ               カーネル
   │  ①SQEを書く  ───────→ [ SQ ring ] ───────→  ②I/Oを実行
   │                                               │
   │  ④CQEを読む  ←─────── [ CQ ring ] ←───────  ③完了を書く

この共有リングのおかげで、I/Oの依頼も結果回収もメモリ書き込み/読み出しだけで済み、引数のコピーが原理的に不要になります。発行をまとめて1回の io_uring_enter で投入すれば、多数のI/Oを1システムコールにバッチ化できます。

ゼロシステムコールの原理(SQPOLL)

さらに踏み込んだのが SQPOLLモードです。カーネル側に専用スレッドを常駐させ、それがSQリングを自分でポーリングします。

  • アプリはSQEを書き、リングの末尾(tail)ポインタを進めるだけio_uring_enter すら呼ばない。
  • カーネルスレッドがtailの変化に気づき、新規SQEを勝手に拾って実行する。
  • 完了はCQリングに積まれ、アプリはCQのhead/tailを見るだけで回収する。

定常状態では、I/Oの発行も回収もシステムコールゼロになります。これが「ゼロシステムコールI/O」の正体で、モード切替コストそのものを消し去ります。割り込みすら抑えてポーリングで回す思想は、高速NICのNAPIと同じ系譜です。

epollを置き換えるのではなく“包含”する

io_uring は accept・read・write・fsync・タイマー・さらにはネットワークの送受信まで非同期化でき、epollが担っていた多重化も内包します。実際 IORING_OP_POLL_ADD で epoll 的な待ちも表現可能。新規高性能サーバーが io_uring を第一候補にするのはこのためです。一方、対応カーネル(5.1以降、実用機能は5.5+)が必要で、運用面の成熟度はワークロードで見極めます。

共有リングは“正しい順序”が命

SQ/CQはロックを使わない単一生産者・単一消費者を基本とする設計で、ポインタ更新とメモリバリアの順序を誤ると、まだ書き終えていないSQEをカーネルが読む/完了を取りこぼす、といった競合が起きます。通常はliburingが正しいバリアを隠蔽するので、自前でリングを直接叩くのは避けるのが安全です。多数のI/Oをまとめる発想は/os/thread-pool/ のバッチ処理とも通じます。

まとめ

まとめ

進化の軸は一貫して「無駄な仕事とモード切替をどう削るか」です。select/pollは監視総数nに比例する全走査と毎回のコピーでO(n)。epollは監視集合をカーネルに常駐させ、レディリストで「準備できたものだけ」を返してO(1)に近づけました。通知方式のET/LTは、変化(エッジ)か状態(レベル)かの違いで、ETはEAGAINまで読み切る規律が必須です。そしてio_uringは、SQ/CQの共有リングで通知モデルを完了モデルに変え、SQPOLLでシステムコールすらゼロにしました。土台にあるのはシステムコールのコストと割り込みとI/Oの非同期通知——ここが腑に落ちると、現代サーバーの「速さの理由」が一本の線でつながります。

OS Article

高性能I/Oモデル(epoll・io_uring)の内部を実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

epoll

比較で見る軸

難易度: advanced / カテゴリ: OS / タグ数: 6

導入後に効く点

epollのエッジトリガ(ET)は「変化した瞬間」だけ通知。EAGAINまで読み切らないとイベントを取りこぼす。レベルトリガ(LT)は「条件が成立し続ける限り」通知。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
OS
タグ数
6

判断チェックリスト

  • 自社の用途が「epoll / io_uring」に近いか確認する。
  • 強みである「select/pollは毎回 全fd を走査・コピーするためO(n)。epollはレディなfdだけを返すレディリスト方式でO(1)に近づく。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

epollio_uringI/O多重化Linuxイベント駆動epollio_uringI/O多重化
参考: 公式情報