io_uring と epoll — Linux 非同期 I/O の系譜とカーネル内部の深層
Linux における非同期 I/O の進化を、select・poll・epoll から io_uring まで、カーネル実装と実コードを手がかりに徹底解説します。
ネットワークサーバやデータベースの性能を突き詰めていくと、必ず突き当たる壁が I/O 多重化 です。1 本のスレッドが 10 万本の TCP 接続を捌く、1 つの SSD の IOPS(I/O Operations Per Second、1 秒あたりに発行できる I/O 回数)を使い切る、そういう話の深層には epoll や io_uring といった Linux カーネルのインターフェースがあります。
本記事では、非同期 I/O の歴史を select → poll → epoll → io_uring の順に追いながら、カーネルがどのようにイベント通知とシステムコール削減を実現しているか を、実際のカーネル構造体とソースファイルを手がかりに掘り下げていきます。Node.js・nginx・PostgreSQL・ScyllaDB・tokio がなぜこの API を必要としたのか、その背景も含めて整理します。
お店の対応で考えるコールセンターのオペレーターを想像してください。電話がにわかに 10 万本鳴りそうです。
- select / poll は 「10 万本の電話を一本ずつ見て『これ鳴ってる? これ鳴ってる?』と全部確認する」方式。鳴っていない電話を見る時間が無駄。
- epoll は 「鳴った電話がランプで自動点灯する掲示板」。オペレーターは点灯したものだけ見ればいい。さらに「どの電話を監視するか」のリストはカーネル (電話局) に台帳として一度預けるだけで、毎回持ち歩かなくていい。
- io_uring は 「もう電話を取らず、依頼票と完了票をカウンター越しに双方向でやりとりする」方式。通常モードでは 「票がたまったらベルを一度だけ鳴らす (
io_uring_enterを 1 回呼ぶ)」、SQPOLL モードでは 「カウンターの前に専任の担当者が常駐していて、票を置くそばから取りに行ってくれる」 — 後者では依頼側からの問い合わせ (syscall) すら不要、という極致です。この記事の結論を一言で言うなら「カーネルとユーザの間の『叫びかけ』をどこまで減らせるか」という戦いの歴史です。
1. なぜ非同期 I/O が必要なのか
1.1 同期 I/O のコスト構造
素朴なブロッキング I/O では、read(fd, buf, n) がデータ到着まで呼び出しスレッドを眠らせます。ここで fd は ファイルディスクリプタ — カーネルが開いたソケット・ファイル・パイプといったリソースを指す整数ハンドルで、「何番の窓口に話しかけるか」を表す背番号のようなものです。1 接続 = 1 スレッドのモデルでは以下のコストが累積します。
- スレッドスタック: Linux では既定 8MB (ユーザ空間
ulimit -s)。10k 接続で 80GB の仮想空間。 - コンテキストスイッチ: スイッチ 1 回は典型 1-3µs。参考までにメモリアクセスが数十 ns、syscall がサブµs オーダーなので、スイッチはスレッド操作の中でもとりわけ重い部類に入る。数十万スイッチ/秒で CPU が溶ける。
- カーネルスレッド構造体:
task_structが 1 つあたり数 KB 常駐。
これを超えるには 1 スレッドで複数 I/O を多重化 するしかありません。方法は 3 つ:
- 準備済み通知 (readiness) — カーネルに「どの fd が読める/書ける?」と問い合わせる。
select/poll/epoll。 - 完了通知 (completion) — カーネルに「この I/O やっておいて、終わったら教えて」と依頼する。
aio、io_uring。 - ユーザ空間ポーリング — DPDK や SPDK のようにカーネルを迂回する。
長らく Linux は (1) に最適化されてきましたが、2019 年の io_uring の登場で (2) が第一級市民になりました。
1.2 readiness モデル vs completion モデル
| モデル | 代表 API | 思想 | 副作用 |
|---|---|---|---|
| Readiness | select, poll, epoll, kqueue (BSD) | カーネルが「準備できた」と通知、ユーザが実際の I/O を呼ぶ | I/O 1 回につき syscall 2 回(通知 + read/write) |
| Completion | Windows IOCP, POSIX aio, io_uring | カーネルに依頼し、完了だけ受け取る | I/O 1 回で syscall 0-1 回 |
ここで言う syscall (システムコール) は、ユーザ空間のプログラムがカーネル機能を呼び出すための特権モード切替のこと。トランポリン命令と CPU モード遷移を伴うため、単純な関数呼び出しとは桁違いに重く、現代 CPU でも 1 回 200–500 ns 程度 — 普通の関数呼び出し (< 10 ns) の 20〜50 倍です。後述の KPTI でさらに重くなります。
用語をもうひとつ。reactor は「ready になった fd を受け取って I/O を実行するループ」(epoll 型)、proactor は「I/O を依頼して完了通知を受け取るループ」(IOCP / io_uring 型) を指します。以下で epoll と io_uring の対比を見るとき、この 2 つの言葉が頭にあると見通しが良くなります。
Linux のファイル I/O では、ファイルは常に「読み込み可能」とみなされるため readiness モデルが機能せず(epoll は正規ファイル非対応)、完了モデルの I/O が本当に必要 でした。これが io_uring 誕生の強い動機です。
2. 歴史: select → poll → epoll の系譜
一言でいうと: select / poll は「監視したい fd 全部を毎回カーネルに渡し、カーネルが全部舐める」方式で 。epoll はそれを「監視対象リストはカーネルに一度だけ預け、鳴った fd だけ返してもらう」構造に組み替えた、というだけの話です。
2.1 select(2) — 1983 年、BSD 4.2
最初期の I/O 多重化 API。
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);fd_set は ビットマップ。POSIX で FD_SETSIZE = 1024(glibc 既定)という制約があり、大きな fd は表現できません。毎回の呼び出しで:
- ユーザ空間で fd_set を組み立てる —
- カーネルに全ビットマップをコピー —
- カーネルが全 fd を舐めて状態を確認 —
- 結果ビットマップをユーザ空間へコピー —
1 接続あたり毎回 の全走査。接続数 1 万では耐えられません。
2.2 poll(2) — System V 由来(Linux は 2.1.23, 1997)
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd;
short events; // 監視するイベント
short revents; // 返ってきたイベント
};fd_set の 1024 制限は消えましたが、本質的に 毎回配列コピー + 全走査 という計算量問題は残っています。
2.3 C10K 問題と epoll — 2002 年、Linux 2.5.44
Dan Kegel の「C10K problem」論考 (1999) が提起した「1 万接続を 1 プロセスで処理する」課題。Davide Libenzi が実装した epoll は、2 つの根本的なブレイクスルーを達成しました。
- 状態をカーネル側に保持 する(毎回コピーしない)。
- 準備完了した fd のリストを返す(全走査しない)。
3. epoll の内部
一言でいうと: epoll は監視対象の fd を 赤黒木 で管理し、イベントが起きた fd だけを 連結リスト (ready list) に移します。
epoll_waitはそのリストから取り出して返すだけ — 全 fd を走査しない。これが per 通知の正体です。
3.1 インターフェース
int epoll_create1(int flags);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
struct epoll_event {
uint32_t events;
epoll_data_t data;
};epoll_create1()— epoll インスタンスを作成し、fd を返す。epoll_ctl()—EPOLL_CTL_ADD / MOD / DELで監視対象を変更。epoll_wait()— 準備完了した fd の配列を返す。ブロッキング/ノンブロッキング/タイムアウト。
3.2 カーネル内データ構造
Linux カーネルの fs/eventpoll.c を読むと、epoll のコアは 3 つの構造体です(主要フィールドのみ抜粋、以降の struct も同様)。
// eventpoll.c
struct eventpoll {
struct mutex mtx;
wait_queue_head_t wq; /* epoll_wait している task の待機列 */
wait_queue_head_t poll_wait;
struct list_head rdllist; /* ready 状態のアイテム */
struct rb_root_cached rbr; /* 全監視アイテムの赤黒木 */
struct epitem *ovflist; /* 通知中のオーバーフロー */
/* ... */
};
struct epitem {
struct rb_node rbn; /* eventpoll->rbr 内のノード */
struct list_head rdllink; /* rdllist 内のリンク */
struct epoll_filefd ffd; /* 監視対象の fd */
struct eventpoll *ep;
struct list_head pwqlist; /* コールバック登録用 */
struct epoll_event event;
/* ... */
};
struct eppoll_entry {
struct list_head llink; /* epitem->pwqlist 内 */
struct epitem *base;
wait_queue_entry_t wait; /* 監視対象ファイルの待機列に登録 */
wait_queue_head_t *whead;
};要点:
- 赤黒木 (red-black tree)
rbr: 監視中の全 fd を保持する平衡二分探索木。追加・削除・検索が 。 - ready list
rdllist: イベント発生したepitem(監視項目 1 つ分の構造体)だけが連結される片方向リスト。epoll_waitはここから取り出すだけ → per ready fd。 eppoll_entry: 各監視対象ファイルの 待機キュー (wait queue) に登録されるコールバック。- 補足:
wait_queue_head_tは Linux カーネルの「眠らせて起こす」プリミティブ。条件変数に近く、ソケット・パイプ・その他ブロックしうるカーネルオブジェクトはそれぞれ自分の待機キューを持つ。
- 補足:
要するに: 3 つの構造体の役割は「eventpoll = 1 個の epoll インスタンス全体、epitem = 監視している fd 1 個の情報、eppoll_entry = その fd の待機キューに差し込むフック」。フックが発火したら epitem を rdllist に繋ぎ直す、それだけ。
3.3 イベントが rdllist に載る経路
たとえば TCP ソケットに新しいパケットが届く流れは:
sock_def_readable→wake_up_interruptible_sync_poll→ep_poll_callbackのパスで コールバックが呼ばれる (fs/eventpoll.c:ep_poll_callback)。- コールバックは
rdllistにアイテムを追加するだけで、割込コンテキストでも安全(spin_lockで保護。割込ハンドラはスケジューラを呼べないため、スリープするミューテックスは使えず、CPU を手放さずに回るスピンロックが必須となる)。 - 次に
epoll_waitした task の待機キューを起こす。
3.4 Level Trigger (LT) と Edge Trigger (ET)
一言でいうと: LT (レベルトリガ) = 読める/書ける状態が続く限り毎回通知する (安全寄り)、ET (エッジトリガ) = 状態が変化した瞬間だけ 1 回通知する (速いが読み切り責任あり)。
epoll 固有のトリガモード。ここを誤解すると深刻なバグが出ます。
| モード | 意味 | read/write の挙動 |
|---|---|---|
| LT (既定) | 「まだ読める/書ける」間、通知し続ける | 1 回だけ読んで戻ってもよい。次も通知される。 |
| ET | 状態変化の瞬間だけ通知 | EAGAIN が返るまで全部読み切る必要。読み漏らすと二度と起きない。 |
ET の実装上のポイントは、epoll_wait がアイテムを rdllist から取り出す際の処理にあります。
/* ep_send_events_proc (簡略化) */
list_for_each_entry_safe(epi, tmp, &txlist, rdllink) {
revents = ep_item_poll(epi, ...);
if (revents) {
copy_to_user(events, ...);
if (epi->event.events & EPOLLET) {
/* ET: ready から外す。次にコールバックが来るまで出てこない */
} else if (!(epi->event.events & EPOLLONESHOT)) {
/* LT: ready に戻す */
list_add_tail(&epi->rdllink, &ep->rdllist);
}
}
}ET モードのほうが高性能(スプリアス通知がない)ですが、アプリケーションが正しくノンブロッキングループを回す 責任を負います。nginx や HAProxy は ET を使いますが、各イベントで while (n = read(fd, ...), n > 0) と書き切るのが標準的な書き方です。(ここでいう nginx の worker とは、nginx が起動時に CPU コア数程度 fork するワーカプロセスのことで、各ワーカーが独立に自分の epoll ループを回します。)
3.5 Thundering Herd と EPOLLEXCLUSIVE
複数プロセス・スレッドが同じ listening socket を epoll で待つと、1 接続の到着で全員が起きて 1 つだけが accept() 成功、残りは EAGAIN で戻る、という無駄が生じます。これが thundering herd (雷鳴の群れ) — 直訳通り「一抬に騒いで飛び出したが成果は 1 件だけ」という手配りだけが無駄になる状況です。
対策:
SO_REUSEPORT(Linux 3.9+) — カーネルが fd レベルで受信を分散。nginx でよく使われる。EPOLLEXCLUSIVE(Linux 4.5+) — 1 イベントにつき 1 waiter だけ起こす。epoll の内部 wait_queue エントリにWQ_FLAG_EXCLUSIVEを立てる。
3.6 epoll の限界
それでも残る問題:
- ファイル I/O に使えない — 通常ファイルはいつも「読める」。ディスクIOは必ずブロックする。
- I/O 1 回につき syscall 2 回 —
epoll_waitとread/write。VDSO 化できない。 - Spectre/Meltdown 後の syscall コスト増 — KPTI (Kernel Page-Table Isolation、ユーザ空間とカーネル空間でページテーブルを分離する Meltdown 緩和策) による TLB フラッシュで syscall 1 回あたり 0.1-1µs。100 万 IOPS を狙うと syscall だけで CPU を 10–100% 食う計算になり、ここが頭打ちの原因になります。
- 複雑な I/O チェイン —
accept → read → parse → writeを 1 つのアトミック操作にできない。
これらが io_uring を必要とした根本的な動機です。
4. io_uring 前史: aio(7), epoll_ctl 以外の試み
4.1 POSIX AIO と libaio
Linux には POSIX AIO (aio_read, aio_write)(glibc がユーザ空間スレッドでエミュレート)と、別物の Kernel AIO (io_setup, io_submit, io_getevents) がありました。
Kernel AIO (通称 libaio) は:
- Direct I/O (
O_DIRECT) + ブロックデバイス限定。O_DIRECTはページキャッシュを迂回してアプリケーションのバッファとデバイスの間で直接 DMA させるフラグで、自前でキャッシュと I/O スケジューリングを行う DB 向け。バッファ付き I/O や通常ファイルでは実質的に同期動作。 - インターフェースが扱いにくい(
struct iocbの配列、完了はio_geteventsで回収)。 - メタデータ更新を伴う操作(
open,stat,close)は対象外。
PostgreSQL や MySQL などは libaio を使っていましたが、汎用 API として普及しませんでした。汎用 AIO の欠如に長らく悩まされた PostgreSQL は、結局 v18 (2025) で 独自の非同期 I/O 抽象層(io_method を worker / io_uring / sync から選べる構造)を自前で用意することになります。
4.2 epoll_pwait2 と signalfd/timerfd/eventfd
eventfd は単一のカウンタを持つ fd で、他のサブシステムを epoll に統合するのに使われてきました(libaio の完了通知、timerfd の時刻通知、ユーザ空間からのプロセス間通知)。この「全部 fd にして epoll に流す」という文化が、Linux のイベントループ周りの設計を規定しています。
5. io_uring — 2019 年、Linux 5.1 から
一言でいうと: 依頼票 (SQE) と完了票 (CQE) を書き込む 2 本の リングバッファ をカーネルと共有し、ポインタ (head/tail) の足し引きだけで I/O をやり取りする仕組み。syscall は「たまった依頼をまとめて通知」するときだけ、さらに SQPOLL モードではそれすら不要。
5.1 設計目標
Jens Axboe が LWN 記事[1] で掲げたゴール:
- Truly asynchronous — あらゆる I/O 操作をブロックなしで依頼できる。
- No copy, no allocation — ホットパスで syscall も malloc もしない。
- Unified — ネット・ファイル・ブロック全部同じ API。
- Extensible — 新しい操作 (opcode) を追加しやすい。
これを実現したのが 共有リングバッファ という構造です。
5.2 アーキテクチャ: SQ と CQ
io_uring はカーネルとユーザ空間で 2 本のリングバッファを共有します。
- SQ (Submission Queue) — ユーザがエントリ (SQE、Submission Queue Entry の略) を書き込む方向。
- CQ (Completion Queue) — カーネルが完了エントリ (CQE、Completion Queue Entry) を書き込む方向。
- 両リングとも ユーザ・カーネル共有の mmap 領域。
mmapはファイルやデバイスメモリ・匯名メモリをプロセスの仮想アドレス空間に「貼り付ける」 syscall で、ここではカーネル側とユーザ側の両方から触れる共有メモリとして使われます。
これが io_uring の核心です。エントリの書き込み・読み出しで syscall が発生しない。syscall は io_uring_enter() を呼ぶときだけ、しかもバッチで。
要するに: 従来の epoll が「毎回電話口まで行って用件を伝える」のに対し、io_uring は「カウンターに用件を書いた依頼票を置く→完了票を拾う」スタイル。票のやりとり自体は共有メモリ上のリングなので、カウンタに手を伸ばす (=syscall する) のは「まとめて渡す/受け取る」タイミングだけでよくなります。
SQ/CQ リングを動かしてみる
レストランの注文伝票に似ています。ウェイター (ユーザ空間) は伝票を『注文リング (SQ)』に並べるだけ。キッチン (カーネル) は伝票を取って調理し、『料理リング (CQ)』に完成品を並べる。ウェイターはキッチンを呼び鈴 (syscall) で起こす必要が原則ありません。
ポイント: ①③はユーザ空間のメモリ書込だけでカーネルに入りません。②は通常 syscall (io_uring_enter) ですが、SQPOLL 時はカーネル側スレッドが勝手に拾うので『呼び鈴』すら要りません。これが syscall ゼロ I/O の正体です。
5.3 SQE と CQE の構造
/* <linux/io_uring.h> */
struct io_uring_sqe {
__u8 opcode; /* IORING_OP_READ, WRITE, ACCEPT, ... */
__u8 flags;
__u16 ioprio;
__s32 fd;
union {
__u64 off; /* オフセット */
__u64 addr2;
};
union {
__u64 addr; /* バッファ */
};
__u32 len;
union {
__kernel_rwf_t rw_flags;
__u32 fsync_flags;
__u16 poll_events;
/* ... */
};
__u64 user_data; /* CQE でそのまま返ってくる任意値 */
/* ... */
};
struct io_uring_cqe {
__u64 user_data; /* SQE で指定した値 */
__s32 res; /* syscall と同じ戻り値 (>= 0 or -errno) */
__u32 flags;
};user_data は、アプリケーションが「どの操作の完了か」を識別するための 任意の 64 ビット値。ポインタや ID を詰めて使います。
要するに: SQE が「依頼票」、CQE が「その控えエリ」で、user_data は両者を紐づける背番号。アプリ側は「いくつ依頼が出ていて」「それぞれが何、を user_data で記憶しておき、戻ってきた CQE の user_data で元のコンテキストを引く。
5.4 リングバッファのセットアップ
ユーザ空間で必要な手順(liburing が隠蔽):
io_uring_setup(entries, params)— カーネルに SQ/CQ を作らせ、各 ring の mmap オフセットを受け取る。mmapで SQ ring、CQ ring、SQE 配列を共有する。カーネル 5.4 以降はIORING_FEAT_SINGLE_MMAPにより SQ と CQ を 1 回のmmapでまとめて貼り付けられるので、合計 2 回の mmap(古いカーネルでは 3 回)で済む。- ユーザ側の
sq_head/sq_tail/cq_head/cq_tailポインタをリングヘッダから取得。
典型的なサイズは SQ = 128-4096、CQ = SQ × 2。カーネルは get_user_pages(ユーザ空間の仮想アドレスに対応する物理ページをピン留めし、カーネル/デバイスが安全に DMA できる状態にするヘルパ)で固定ページ化してマップします。
5.5 送信と完了のフロー
/* シンプルな非 SQPOLL 例(liburing 使用) */
struct io_uring ring;
io_uring_queue_init(32, &ring, 0);
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, offset);
io_uring_sqe_set_data(sqe, user_ptr);
io_uring_submit(&ring); /* これが io_uring_enter(SUBMIT) を発行 */
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe); /* 完了を待つ */
process_completion(cqe);
io_uring_cqe_seen(&ring, cqe); /* head++ */ここで重要なのは、複数 SQE を 1 回の io_uring_enter でまとめて送れる こと。1 syscall で 1000 個の read を投げられるので、syscall コストが償却されます。具体的には、epoll だと 1 回の I/O に syscall 2 回 → 500 ns × 2 = 1μs かかるところが、io_uring で 1000 依頼をバッチすれば syscall 1 回を 1000 で割って一件あたり 0.5 ns 、というオーダーの圧縮がかかります。
5.6 メモリ順序とリング操作
共有メモリで head/tail を管理しているため、適切なメモリバリア が必要です。カーネルドキュメント (io_uring.txt) とヘッダ (<linux/io_uring.h>) は以下を要求します:
- SQE を書いてから
sq->tailを更新する前にsmp_wmb(=io_uring_smp_wmb, x86 では NOP、ARM ではdmb ish)。 cq->tailを読んでから CQE 本体を読む前にsmp_rmb。
smp_wmb は「それ以前の書き込みが以後の書き込みより先に他コアから観測される」ことを保証するストア・ストアバリア、smp_rmb はロード・ロードの対応物です。
liburing の io_uring_submit / io_uring_peek_cqe がこれを正しく実装しているので、通常は直接触る必要はありません。
6. 高度なモード
一言でいうと: SQPOLL・IOPOLL・Linked SQE・registered files/buffers・multishot は「syscall と準備コストをさらに削るためのオプション群」で、全部使う必要はない。
6.1 IORING_SETUP_SQPOLL — カーネルスレッドでポーリング
io_uring_setup に IORING_SETUP_SQPOLL を立てると、カーネルが 専用スレッド を作って SQ を監視し続けます。ユーザ空間は SQE を置くだけ、io_uring_enter も不要。完全な syscall 0 で I/O が出せます。つまり「カウンターに専任の担当者を常駐させて、依頼票を置いたそばから取りに行かせる」究極モードです。
struct io_uring_params params = {
.flags = IORING_SETUP_SQPOLL,
.sq_thread_idle = 2000, /* 2 秒アイドルで眠る */
};
io_uring_queue_init_params(entries, &ring, ¶ms);代償:
- カーネルスレッドが CPU を 1 コア使う(
sq_thread_idleミリ秒アイドルで眠る)。 - Linux 5.11 で root (
CAP_SYS_ADMIN) 要件が撤廃され、SQPOLL 自体は非特権ユーザでも利用可能になりました。ただしIORING_SETUP_SQ_AFFで SQ スレッドを特定 CPU にピン留めする場合だけは今もCAP_SYS_NICEが必要です。
これは 1 プロセスで NVMe を使い切る 用途(DB、高頻度取引、ScyllaDB のシャード・パー・コア)で真価を発揮します。
6.2 IORING_SETUP_IOPOLL — ブロックデバイスのビジーポーリング
O_DIRECT のブロック I/O は、カーネル内でブロックレイヤの完了を割込みでなく ビジーポーリング で取りに行けます。NVMe の μs オーダーのレイテンシではこれが優位です。
6.3 Linked SQEs (IOSQE_IO_LINK)
複数の操作を 依存チェーン で投げられます。
/* 例: 既知の fd に対して read → write を 1 回の enter で */
sqe1 = io_uring_get_sqe(); io_uring_prep_read(sqe1, fd, buf, len, 0);
sqe1->flags |= IOSQE_IO_LINK;
sqe2 = io_uring_get_sqe(); io_uring_prep_write(sqe2, fd, buf, len, 0);
io_uring_submit(&ring);先行操作が失敗 (res < 0) すると後続は自動キャンセル。IOSQE_IO_LINK は 実行順序を保証するだけ で、前の SQE の結果 fd を次の SQE に自動で渡す仕組みはない。accept → read のように「accept 結果の fd をそのまま使う」パターンを 1 回の submit でやりたい場合は、direct descriptor(IORING_FILE_INDEX_ALLOC と IOSQE_FIXED_FILE、カーネル 5.17+ の IORING_FEAT_LINKED_FILE による遅延 fd 解決)を使う。
6.4 Registered files / buffers
io_uring_register_files()— fd 配列をカーネルに事前登録。SQE で fd の代わりに インデックス を使うと、fget()/fput()の参照カウントが省略される。io_uring_register_buffers()— ユーザバッファをピン留め登録し、I/O ごとのget_user_pagesを省略。DMA-direct パスが有効に。
高 IOPS が必要な場合の鉄板最適化。ScyllaDB のベンチマークでは 20-40% の性能向上が報告されています。
6.5 Multishot (IORING_OP_ACCEPT_MULTI, Linux 5.19+)
1 つの SQE で 複数回の完了 を受け取れます。accept や recv で「キャンセルされるまでずっと完了を生成」させる仕組み。SQE 枯渇を防ぎ、従来必要だった「accept → SQE 再投入」のループが消えます。
6.6 Provided buffers (IORING_OP_PROVIDE_BUFFERS)
「受信バッファの割り当てをカーネルに任せる」機能。到着時に使えるバッファを選ぶ ことで、何千接続分のバッファを事前に確保する無駄を省きます。HTTP サーバで有効。
6.7 Operation opcodes (抜粋)
主要な opcode を挙げると:
| 分類 | 主な opcode |
|---|---|
| ネット | ACCEPT, CONNECT, SEND, RECV, SENDMSG, RECVMSG, SHUTDOWN, CLOSE |
| ファイル | READ, WRITE, READV, WRITEV, FSYNC, FALLOCATE, OPENAT, STATX, RENAMEAT |
| poll 系 | POLL_ADD, POLL_REMOVE, POLL_MULTISHOT |
| 同期 | LINK_TIMEOUT, TIMEOUT, NOP, ASYNC_CANCEL |
| メモリ | MADVISE, FADVISE, SPLICE, TEE |
| プロセス | WAITID (5.19+) |
カーネル側は各 opcode ごとに実装ファイル(io_uring/net.c, io_uring/rw.c, io_uring/fs.c など)を持ち、既存の VFS(Virtual File System、Linux がファイルシステム種別の差を吸収して提供する抽象層)/ソケット内部関数を呼び出します。
7. カーネル内部: io_uring/ の中身
一言でいうと: 1 つの SQE はカーネル内で
io_kiocbというリクエスト構造体に化け、可能なら即時実行・ブロックしそうなら専用スレッドプールio-wqに委譲されます。完了したら mmap 共有の CQ リングに直接書き込んで呼び出し元を起こす、という流れ。
Linux 5.19 で fs/io_uring.c 一枚岩の実装がトップレベルの io_uring/ ディレクトリに分割され、以降はサブシステムとして独立しました。主要ファイル:
io_uring/
├── io_uring.c # リング管理、enter syscall
├── sqpoll.c # SQPOLL カーネルスレッド
├── rw.c # read/write 系 opcode
├── net.c # ネット系 opcode
├── fs.c # openat/statx 系
├── poll.c # IORING_OP_POLL_ADD
├── timeout.c # TIMEOUT, LINK_TIMEOUT
├── cancel.c # ASYNC_CANCEL
└── ... 30+ files7.1 リクエスト構造体 io_kiocb
1 つの SQE ごとにカーネル内で割り当てられる構造体。
struct io_kiocb {
union {
struct file *file;
struct io_cmd_data cmd;
};
u8 opcode;
io_req_flags_t flags;
struct io_cqe cqe; /* user_data, res, cflags */
struct io_ring_ctx *ctx;
struct io_uring_task *tctx;
/* opcode-specific data, task work node, etc. */
/* ... */
};slab cache (req_cachep) からアロケートされ、完了後はリサイクルされます。ホットパスのメモリ確保を避けるため、io_ring_ctx ごとに 小さなバッチのプリアロックキャッシュ(バージョンにより 128 前後)を保持しています。
7.2 非同期実行: io-wq プール
本当にブロックしうる操作(ディスク read がページキャッシュミスしたとき等)は、io_uring がカーネルスレッドプール io-wq に委譲します。つまり io-wq は「本当にブロックするものを隠しておくための裏方スレッドプール」です。各 io_ring_ctx ごとに bounded(ディスク用)と unbounded(ネット用)の 2 プール。アイドル時にはスレッドが解放されるので、ps で大量のカーネルスレッドが見えても心配ない設計になっています。
汎用の workqueue を使わず専用プールを持つのは、io-wq ワーカが submitter タスクのクレデンシャル(uid/gid, cgroup, nsproxy など)を引き継ぐ 必要があり、かつリング単位で NUMA ローカリティと優先度を制御したいため。汎用 workqueue はこれらの要件に合いません。
これはユーザから透過的で、「非同期で完了させられなければスレッドで待つ」 というフォールバックが一貫性を担保します。epoll の「絶対ブロックしない」保証と質的に違うポイントです。
7.3 完了パス
/* io_uring.c (概念コード) */
void io_req_task_complete(struct io_kiocb *req, io_tw_token_t tw)
{
struct io_ring_ctx *ctx = req->ctx;
io_cq_lock(ctx);
io_fill_cqe_req(ctx, req); /* CQE を ring に書く */
io_cq_unlock_post(ctx); /* タスクを起こす */
}CQ ring への書き込みは spin_lock で保護された上で、コピーレスで行われます(ユーザ空間 mmap への直接書き込み)。
8. io_uring の落とし穴と運用
一言でいうと: 強力だが脆弱性報告も多く、seccomp でも細かく制御できない。本番で使うなら最新 LTS カーネル + 観測ツール + ディストリビューションのポリシー確認が必須。
8.1 CVE とセキュリティ
非常に複雑で特権的な API なので、過去に多数の LPE (Local Privilege Escalation) 脆弱性が報告されました。
- CVE-2022-29582, CVE-2023-2598, CVE-2023-21400, CVE-2024-0582 など。
- その結果、Google (Android/CrOS) や多くの LTS カーネルパッケージは
io_uringを既定で無効化。 - RHEL 9.5 以降、
sysctl kernel.io_uring_disabled=1/2で全面禁止 / root のみ許可が選べるようになった。
プロダクションで io_uring を使う場合、カーネルを常に最新 LTS にする ことと、seccomp で opcode を絞る運用が推奨されます。
8.2 観測性
straceではio_uring_enterしか見えない。SQE の中身は見えません。- 代替:
bpftrace/perf traceで tracepoint (io_uring:io_uring_submit_req等) を取る、iouring-toolsのダンプを使う。 - カーネル側のカウンタ:
/proc/<pid>/io_uringにリング統計が出る。
8.3 バッファ所有権
ユーザが submit したバッファは 完了するまでカーネルが保持 します。Rust で安全にラップするのが特に難しく、tokio-uring が独自のバッファトレイト (IoBuf) を導入した理由もここにあります (Rust の借用モデルと非互換)。
8.4 互換性の現実
2026 年時点の実装状況:
- カーネル: 5.1 (2019) 導入、5.19 (2022) で一通り成熟、6.x で継続改善。
- glibc:
io_uring本体を直接は提供せず、liburingが標準。 - Node.js: libuv v1.45 (2023) で非同期ファイル I/O 向けに io_uring が導入されたが、v1.49 (2024 年 9 月) では一部カーネルでの不具合を受けて SQPOLL モードが既定で無効化 された(通常の io_uring パスは維持)。以降、環境によって挙動が分かれるため、実際のバージョンでの確認が必須。
- PostgreSQL: 18 (2025) で新設された AIO サブシステムにおいて
io_method=io_uringが選択可能に(--with-liburing付きビルドが必要)。既定値はworker。 - Rust:
tokio-uringは別クレート。代表的な非同期ランタイムtokio本体は互換性のため epoll ベースのまま。 - nginx: 実験的モジュールあり、公式採用はまだ。
- ScyllaDB: もともと Seastar フレームワークで非同期 I/O を前提に設計されており(初期は Linux AIO ベース)、io_uring が成熟してからは最も早期に移行した採用例。SPDK も NVMe 直接操作の経路で利用。
- Netty / JVM: Netty 4.2 で
IOUringtransport 提供。
8.5 運用上の地雷
- cgroup v1 と SQPOLL の CPU 計上: SQPOLL 用カーネルスレッドは submitter と独立したタスクとして走る。つまり cgroup v1 環境では、submitter 側の cgroup に CPU 使用が正しく課金されないケースがある。cgroup v2 では改善されているが、古いホストでは SQPOLL を使うワークロードが「身元不明のカーネル CPU」として見える点に注意。
- seccomp では opcode を絞れない: seccomp-bpf は syscall 単位でしか判断しないため、
io_uring_enterが運んでくる opcode ごとにフィルタすることはできない。細かく opcode を制限したい場合は BPF-LSM、新しめの seccomp notify フック、あるいは io_uring 自身の リング単位の許可リスト機構(IORING_REGISTER_RESTRICTIONS)を併用する必要がある。
9. 性能比較: 何がどれだけ速いのか
一言でいうと: 低負荷なら epoll と大差なし。NVMe を飽和させる・1M IOPS を狙う・ファイル fsync を絡める、となって初めて io_uring の投資が報われる。
syscall コストを数えてみる
空港のイミグレに例えると分かりやすいです。epoll は旅行者 1 人ずつ審査官に声をかける方式、io_uring は団体ツアーの代表が 1 枚の名簿で一括審査してもらう方式、SQPOLL は常駐の顔パス担当がいて窓口すら通らない方式です。
前提: syscall 1 回 ≈ 500 ns (KPTI 有り、Intel 旧世代の典型値)、リクエスト当たりの user-side 処理 ≈ 50 ns。epoll は read/write が毎回 syscall になるため syscall コストが n に比例します。 n=10,000 のワークロードでは、epoll は数ミリ秒を syscall だけで溶かすのに対し、io_uring はバッチサイズ次第で二桁減り、SQPOLL だと syscall コストが消えます。
9.1 ベンチマーク例 (概観)
いくつか代表的な報告を要約します(詳細な条件はそれぞれの一次資料参照):
| ワークロード | epoll | io_uring (ポーリングなし) | io_uring (SQPOLL + registered) |
|---|---|---|---|
| ネットワーク echo 1M conn | 基準 | ~1.2x | ~2-3x |
| NVMe random read 4KB | ~800k IOPS | ~1.5-2M IOPS | ~3-5M IOPS |
| DB commit (fsync-heavy) | 基準 | ~1.1x | ~1.3-1.5x |
io_uring の本領は 高 IOPS・低レイテンシ領域 で発揮されます。ただし低負荷時は epoll との差はごく小さく、実装コストに見合わないことも多い。
9.2 いつ io_uring を選ぶべきか
10. まとめ
一言でいうと: 40 年の歴史を貫く原則は「カーネルとユーザ空間の呼び合いをどこまで減らせるか」。select → epoll → io_uring はその答えを少しずつ更新してきた系譜。
非同期 I/O は 40 年かけて、以下のように進化してきました。
- select (1983) — 固定サイズ fd_set、毎回 。
- poll (1997) — fd_set 制限は解消、計算量は据え置き。
- epoll (2002) — カーネル側状態保持 + ready list で 。C10K 問題を実用レベルで解決。
- io_uring (2019) — 共有リングで syscall 削減 + 真の非同期。C10M / 単機 NVMe 飽和の時代に対応。
流れには一貫した設計圧力があります。(a) カーネルに状態を置いて繰り返しコピーを避ける、(b) 1 回の syscall でできる仕事を増やす、(c) ブロックしうる操作を非同期に下駄を履かせる。
epoll はこれからも最も安定した選択肢であり続けるでしょう。ただし、1 ノードで NVMe を使い切る・10M 接続を捌く・ファイルと fsync を含む非同期パイプラインを組む、そういう要件がある場合は io_uring への投資が正当化されます。
選択肢はもう 1 つだけではありません。カーネルインターフェースの設計思想を理解しておくこと が、その判断を正しくするための一番の近道です。
参考文献
- J. Axboe, "Efficient IO with io_uring", liburing.pdf, 2019.
- D. Libenzi, "Improving (network) I/O performance" (epoll), 2002.
- D. Kegel, "The C10K problem", 1999.
- Linux kernel source:
fs/eventpoll.c - Linux kernel source:
io_uring/ liburing— ユーザ空間ヘルパと公式ドキュメント。- LWN: "The rapid growth of io_uring", 2020 / "Security considerations for io_uring", 2023.