Read OSS

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 のディレクトリマップで見たとおり、トップレベルの Kbuildobj-$(CONFIG_IO_URING) += io_uring/ を通じて条件付きコンパイルされています。

io_uring が生まれた理由

従来の Linux I/O は、どの選択肢も一長一短でした。

  1. 同期システムコール (read/write) — シンプルだがブロッキング。各呼び出しでシステムコールの入退場コストが丸ごと発生する。
  2. 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 のヘッダコメントには、メモリオーダリングに関する規約が記されています。

io_uring/io_uring.c#L1-L41

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_sqestruct io_uring_cqe — を定義しています。記事 1 で説明した安定した ABI 境界がここに当たります。

オペレーション定義パターン

io_uring は多数の操作タイプ (read、write、send、recv、accept、connect、poll、timeout など) をサポートしており、それぞれが struct io_issue_def で定義されています。

io_uring/opdef.h#L7-L44

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 は実際の操作を実行します。

各操作はオペコードでインデックスされたディスパッチテーブルにまとめられています。

io_uring/opdef.c#L54-L60

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 ドライバを読み解いていきましょう。