io_uring: カーネルの非同期 I/O エンジンの内部構造
前提知識
- ›記事 1: アーキテクチャとディレクトリマップ
- ›記事 4: システムコールパスと VFS
- ›メモリオーダリングとアトミック操作の理解
- ›非同期 I/O の概念への慣れ
io_uring: カーネルの非同期 I/O エンジンの内部構造
記事 4 では、システムコールの全経路をたどりました。特権遷移、レジスタの保存と復元、Spectre 対策、ディスパッチ、VFS のトラバーサル、そしてリターンです。この一連の処理は高速ですが、アプリケーションが毎秒数百万回の I/O を実行する場合、その都度カーネルへ入退場するオーバーヘッドがボトルネックになります。io_uring はリングバッファをカーネルとユーザースペース間で共有してこのオーバーヘッドを排除し、ホットパスではシステムコールなしに I/O の投入と完了を実現します。
io_uring は fs/ から独立してカーネルソースツリーのルート直下に昇格した、数少ないサブシステムのひとつです。記事 1 のディレクトリマップで見たとおり、トップレベルの Kbuild で obj-$(CONFIG_IO_URING) += io_uring/ を通じて条件付きコンパイルされています。
io_uring が生まれた理由
従来の Linux I/O は、どの選択肢も一長一短でした。
- 同期システムコール (
read/write) — シンプルだがブロッキング。各呼び出しでシステムコールの入退場コストが丸ごと発生する。 - aio (Linux AIO) — 真の非同期処理を実現するが、ファイルへのダイレクト I/O にしか対応しておらず、API が煩雑で操作ごとのオーバーヘッドも大きい。
高性能サーバー(データベース、ウェブサーバー、ストレージエンジン)は、毎秒数十万回の I/O 操作を捌く必要があります。その規模になると、記事 4 で見たシステムコールのオーバーヘッド — swapgs、CR3 スイッチ、pt_regs の構築、Spectre 対策、SYSRET の検証 — が全操作に乗算されて無視できない負荷になります。
io_uring はこの問題を共有メモリ設計で解決します。ユーザースペースは Submission Queue Entry(SQE)をカーネルから見えるメモリに直接書き込み、カーネルは Completion Queue Entry (CQE) を書き戻します。SQ ポーリングモードでは、新しいジョブが追加されたことをカーネルに伝える通知すらシステムコールなしで行われます。
リングバッファアーキテクチャ
核となるデータ構造は、mmap() を介してユーザースペースとカーネルで共有される一対のリングバッファです。
flowchart LR
subgraph "Userspace Process"
SQT["SQ Tail (written by app)"]
CQH["CQ Head (written by app)"]
end
subgraph "Shared Memory (mmap'd)"
subgraph "Submission Queue"
SQE1["SQE 0"]
SQE2["SQE 1"]
SQE3["SQE ..."]
SQE4["SQE N"]
end
subgraph "Completion Queue"
CQE1["CQE 0"]
CQE2["CQE 1"]
CQE3["CQE ..."]
CQE4["CQE N"]
end
end
subgraph "Kernel"
SQH["SQ Head (written by kernel)"]
CQT["CQ Tail (written by kernel)"]
end
SQT -->|"smp_store_release"| SQE1
SQH -->|"smp_load_acquire"| SQE1
CQT -->|"smp_store_release"| CQE1
CQH -->|"smp_load_acquire"| CQE1
io_uring.c のヘッダコメントには、メモリオーダリングに関する規約が記されています。
A note on the read/write ordering memory barriers that are matched between
the application and kernel side.
After the application reads the CQ ring tail, it must use an
appropriate smp_rmb() to pair with the smp_wmb() the kernel uses
before writing the tail (using smp_load_acquire to read the tail will
do). It also needs a smp_mb() before updating CQ head (ordering the
entry load(s) with the head store), pairing with an implicit barrier
through a control-dependency in io_get_cqe.
これはロックフリーの単一プロデューサー/単一コンシューマープロトコルです。アプリケーションは SQE を生成して CQE を消費し、カーネルは SQE を消費して CQE を生成します。同期手段はメモリバリアのみであり、ロックやシステムコールに比べてはるかに低コストです。
この状態をすべて保持するメインのコンテキスト構造体が struct io_ring_ctx です。
include/linux/io_uring_types.h#L271-L289
struct io_ring_ctx {
/* const or read-mostly hot data */
struct {
unsigned int flags;
unsigned int drain_next: 1;
unsigned int task_complete: 1;
unsigned int lockless_cq: 1;
unsigned int syscall_iopoll: 1;
...
記事 3 のスケジューラで見た struct rq と同様に、フィールドは慎重にグループ化されています。const or read-mostly hot data は頻繁に書き込まれるフィールドから分離されており、キャッシュの競合を最小限に抑えています。
ヒント: UAPI ヘッダ
include/uapi/linux/io_uring.hは、ユーザースペースから見える構造体 —struct io_uring_sqeとstruct io_uring_cqe— を定義しています。記事 1 で説明した安定した ABI 境界がここに当たります。
オペレーション定義パターン
io_uring は多数の操作タイプ (read、write、send、recv、accept、connect、poll、timeout など) をサポートしており、それぞれが struct io_issue_def で定義されています。
struct io_issue_def {
unsigned needs_file : 1;
unsigned plug : 1;
unsigned ioprio : 1;
unsigned iopoll : 1;
unsigned buffer_select : 1;
unsigned hash_reg_file : 1;
unsigned unbound_nonreg_file : 1;
unsigned pollin : 1;
unsigned pollout : 1;
...
unsigned short async_size;
int (*issue)(struct io_kiocb *, unsigned int);
int (*prep)(struct io_kiocb *, const struct io_uring_sqe *);
};
これも C の vtable パターンの一例ですが、VFS よりも細粒度です。各操作はビットフィールドで自身のケーパビリティ (ファイルが必要か?iopoll に対応しているか?バッファ選択をサポートしているか?) を宣言し、2 つの関数ポインタを提供します。prep は SQE の検証と準備を担当し、issue は実際の操作を実行します。
各操作はオペコードでインデックスされたディスパッチテーブルにまとめられています。
const struct io_issue_def io_issue_defs[] = {
[IORING_OP_NOP] = {
.audit_skip = 1,
.iopoll = 1,
.prep = io_nop_prep,
.issue = io_nop,
},
[IORING_OP_READV] = {
.needs_file = 1,
.unbound_nonreg_file = 1,
.pollin = 1,
.buffer_select = 1,
...
.prep = io_prep_readv,
.issue = io_read,
},
...
ファイルは操作のファミリーごとに分割されています。rw.c が読み書き、net.c がネットワーク操作、poll.c がポーリング、timeout.c がタイムアウトを担当する、といった構成です。
| ソースファイル | 操作 |
|---|---|
io_uring/rw.c |
READV, WRITEV, READ_FIXED, WRITE_FIXED |
io_uring/net.c |
SENDMSG, RECVMSG, SEND, RECV, ACCEPT, CONNECT |
io_uring/poll.c |
POLL_ADD, POLL_REMOVE |
io_uring/timeout.c |
TIMEOUT, TIMEOUT_REMOVE, LINK_TIMEOUT |
io_uring/openclose.c |
OPENAT, CLOSE |
io_uring/sqpoll.c |
SQ ポーリングスレッドの管理 |
io_uring/io-wq.c |
ワーカースレッドプール |
オペレーションのライフサイクル: prep → issue → completion
カーネルが Submission Queue Entry を処理するとき、明確なライフサイクルをたどります。
flowchart TD
A["Userspace writes SQE<br/>to submission queue"] --> B["Kernel reads SQE<br/>(smp_load_acquire)"]
B --> C["io_issue_defs[opcode].prep(req, sqe)<br/>Validate and prepare"]
C --> D{"prep result?"}
D -->|Success| E["io_issue_defs[opcode].issue(req, flags)<br/>Execute operation"]
D -->|Error| G["Post CQE with error"]
E --> F{"Result?"}
F -->|Complete| H["Post CQE to completion queue<br/>(smp_store_release tail)"]
F -->|Would block| I["Delegate to io-wq<br/>worker thread"]
I --> H
prep フェーズは、カーネルが SQ をドレインするときに同期的に実行されます。SQE のフィールドを検証してパラメータを取り出し、内部の struct io_kiocb リクエストを構築します。準備に失敗した場合 (不正なファイルディスクリプタ、無効なフラグ) は、エラーを示す CQE が即座に投入されます。
issue フェーズでは操作の完了を試みます。多くの操作、特にページキャッシュにヒットするものは即座に完了します。操作がブロックする可能性がある場合 (例: キャッシュにないデータの読み込み) は -EAGAIN を返し、リクエストは非同期完了のために io-wq ワーカープールへ引き渡されます。
Completion では、結果を含む CQE が Completion Ring に書き込まれます。アプリケーションは次の CQ ドレイン時にそれを読み取ります。
SQ ポーリングモードと io-wq ワーカープール
SQ ポーリングモード (IORING_SETUP_SQPOLL)
標準モードでは、「新しい SQE がある」とカーネルに伝えるために io_uring_enter() という 1 回のシステムコールが依然として必要です。SQ ポーリングモードはそれすら不要にします。専用のカーネルスレッドが Submission Queue を継続的にポーリングして新しいエントリを探します。
このポーリングスレッドは io_uring/sqpoll.c 内の io_sq_thread() で動作します。設定可能な時間の間、新しい SQE を探してスピンし続け、キューがアイドル状態になるとスリープして CPU を解放します。スリープ中はアプリケーションが SQ フラグの IORING_SQ_NEED_WAKEUP を検知し、ウェイクアップのシステムコールを 1 回送信できます。
これにより、定常状態では真のゼロシステムコール I/O が実現します。アプリケーションは共有メモリに SQE を書き込み CQE を読み取るだけで、カーネルのポーリングスレッドが投入を担います。高スループットのワークロード (NVMe ストレージ、高速ネットワーキング) では、システムコールのオーバーヘッドを完全に排除できます。
flowchart LR
subgraph "Standard Mode"
A1["App writes SQE"] --> A2["App calls io_uring_enter()"]
A2 --> A3["Kernel processes SQEs"]
end
subgraph "SQ Poll Mode"
B1["App writes SQE"] --> B2["Kernel poll thread<br/>sees new SQE"]
B2 --> B3["Kernel processes SQEs"]
end
io-wq ワーカープール
すべての操作がブロックせずに完了できるわけではありません。issue() が -EAGAIN を返した場合、リクエストは io_uring/io-wq.c に実装された io-wq ワーカープールへキューイングされます。このプールは以下を管理します。
- Bounded workers — 追加のファイルアクセスを必要としないジョブ用 (リソース枯渇を防ぐために上限あり)
- Unbounded workers — 多くの同時スレッドが必要になる可能性がある、ソケットやパイプなどの非通常ファイルへの操作用
io-wq サブシステムは、これらのプール間でのスレッドの生成、スリープ/ウェイク、ワークスティーリングを管理します。io_uring のニーズに特化した、専用のカーネルスレッドプールと言えます。
ヒント: io_uring のベンチマーク時は、io-wq スレッドの生成に注目しましょう。ワーカースレッドが多数生成されている場合、操作がブロックしているサインです。ダイレクト I/O (
O_DIRECT) への切り替えやデータをページキャッシュに乗せることで、処理をインラインの高速パスに留めることができます。
次のステップ
これでカーネルへの 2 つの経路が揃いました。従来のシステムコールパス (記事 4) と、共有メモリを使った io_uring パスです。最後の記事では、カーネルに追加された最新の言語 — Rust を取り上げます。kernel クレートがこれらの C インターフェース(VFS 操作、ドライバ登録、initcall メカニズム)を安全な Rust の抽象化でどのようにラップしているかを見ながら、実際の Rust GPU ドライバを読み解いていきましょう。