高性能I/Oモデル(epoll・io_uring)の内部
数万接続を1スレッドでさばく現代サーバーの心臓部。selectのO(n)をepollがどう潰し、io_uringがシステムコールすら消す原理まで一気に理解できます。
- 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多重化です。本記事は、その実装が select → epoll → io_uring とどう進化し、各段階で何が遅さの正体だったかを内部から解剖します。
select/poll のO(n)問題
select/poll の使い方は「監視したいfdの集合を渡す → 何か起きたら戻る」です。一見便利ですが、その内部には3つの構造的コストがあります。
- 毎回のコピー:呼び出すたびに、監視対象fd集合をユーザー空間からカーネルへコピーする。1万fdなら毎回1万件分。
- 全走査(スキャン):カーネルは渡されたfdを全部チェックして、どれがレディか調べる。準備できたのが1個でも全件見る。
- 戻り後の再走査:戻ってきた後、アプリ側も全fdを舐めてどれが立ったか探す。
つまり「実際にイベントが起きたfdの数」とは無関係に、監視対象の総数 n に比例した仕事(O(n))が毎回発生します。アイドルな接続が大半でも、その全員分のコストを払い続ける——これがC10Kでの致命傷でした。
| 観点 | select | poll | epoll |
|---|---|---|---|
| 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で「たまに特定の接続だけ応答が止まる」障害の多くは、読み切り(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と同じ系譜です。
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、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。