io_uring: コミットと完了のモデル

このページの目次

io_uring は、共有コミットキュー (SQ) と完了キュー (CQ) を介して、リクエストの準備、コミット、実行、結果の取得を分離します。これにより、オーバーヘッドをバッチ処理で分散させ、非同期操作の多様なカテゴリを統一して処理できます。実際に高速化するかは、ワークロード、デバイス、キューの深さ、実装方法に依存します。

Linux 5.1 で基本フレームワークが導入され、以降、操作と機能が追加され続けています。カーネルバージョンが 5.1 より高いからといって、すべての opcode、フラグの組み合わせ、ファイルシステムパスがサポートされているわけではありません。実際のアプリケーションでは、能力をプローブし、作成やリクエストの失敗を処理する必要があります。

SQ と CQ が運ぶのは制御情報

アプリケーションは SQE を準備し、opcode、fd、バッファアドレス、長さ、オフセット、user_data などを記入します。カーネルはそれらを消費して実行し、CQE を介して user_data、res、flags などの結果を返します。リングキューの共有は制御情報の往復コピーを減らしますが、データペイロードが自動的にゼロコピーになるわけではありません。

ユーザー空間とカーネルはそれぞれ生産インデックスと消費インデックスを進める必要があり、適切なメモリバリアが必要です。通常、liburing を介して管理し、通常の整数操作で head/tail を直接操作しないようにします。複数のアプリケーションスレッドが同じリングを同時に操作する場合も、ライブラリと使用しているモードの同時実行契約に従う必要があります。io_uring(7)

リクエストライフサイクル: SQE が消費されても、データバッファが回収できるわけではない SQE の準備 buffer 参照と user_data コミットと実行 要求を消費し I/O 開始 完了の終結 CQE 確認後に資源を回収 通常の read/write のバッファ: 準備時から、対応するリクエストの完了まで少なくとも保持する 図は通常の単一リクエストを示す。multishot やゼロコピー送信では、それぞれの後続完了通知プロトコルに従う必要がある。

混同してはいけない4つの結果

段階返される結果まだ保証されていないこと
get_sqe記入可能な SQE、または NULLI/O がまだ実行されていない
submitコミット結果または負のエラーコードリクエスト自体が成功したわけではない
wait_cqe完了レコードの取得、または待機インターフェースのエラーレコードを取得しても res が成功を意味するわけではない
CQE resその操作の結果、失敗時は負の errno読み書きが成功しても、短絡的な転送(短読み/短書き)である可能性がある

io_uring_wait_cqe が 0 を返した場合、cqe->res を再確認する必要があります。res が -ENOENT の場合、そのエラーは当該操作に由来するものであり、現在のスレッドの errno から推論してはいけません。読み取り操作で res が 0 の場合は通常 EOF を示し、正の値は実際に読み取られたバイト数です。wait_cqe、prep_read

完全な単一読み取りプログラムの例

uring_read.c として保存します。このプログラムは、指定されたファイルのオフセット 0 から最大 4096 バイトを読み取り、実際のバイト数を出力します。ファイル全体を読み取るためのループではありません。初期化または待機段階での致命的なエラーは、このデモプロセスを終了させます。長期間稼働するサービスでは、独立したキャンセルと回収パスを実装する必要があります。

#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <liburing.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static void fail_uring(const char *what, int result) {
    fprintf(stderr, "%s: %s\n", what, strerror(-result));
    exit(1);
}

int main(int argc, char **argv) {
    if (argc != 2) {
        fprintf(stderr, "usage: %s FILE\n", argv[0]);
        return 2;
    }
    int fd = open(argv[1], O_RDONLY);
    if (fd == -1) { perror("open"); return 1; }

    struct io_uring ring;
    int rc = io_uring_queue_init(8, &ring, 0);
    if (rc < 0) fail_uring("queue_init", rc);

    unsigned char buffer[4096];
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    if (sqe == NULL) {
        fprintf(stderr, "no SQE available\n");
        exit(1);
    }
    io_uring_prep_read(sqe, fd, buffer, sizeof buffer, 0);
    sqe->user_data = 42;

    do { rc = io_uring_submit(&ring); } while (rc == -EINTR);
    if (rc < 0) fail_uring("submit", rc);
    if (rc != 1) {
        fprintf(stderr, "expected one submitted request, got %d\n", rc);
        exit(1);
    }

    struct io_uring_cqe *cqe;
    do { rc = io_uring_wait_cqe(&ring, &cqe); } while (rc == -EINTR);
    if (rc < 0) fail_uring("wait_cqe", rc);
    int result = cqe->res;
    unsigned long long tag = cqe->user_data;
    io_uring_cqe_seen(&ring, cqe);

    int failed = result < 0 || tag != 42;
    if (result < 0) fprintf(stderr, "read: %s\n", strerror(-result));
    else printf("read=%d, user_data=%llu\n", result, tag);
    io_uring_queue_exit(&ring);
    if (close(fd) == -1) failed = 1;
    return failed;
}
cc -Wall -Wextra uring_read.c -luring -o uring_read
./uring_read input.bin

バッファはコミット後も存続し、その単一リクエストの CQE が到着するまで保持されます。cqe_seen の後、元の CQE スロットは再利用可能になるため、プログラムはまず res と user_data を保存します。タグはアプリケーションの関連キーであり、カーネルはタグが指すオブジェクトに対して参照カウントを増加させることはありません。

明示的なファイルオフセットは、複数の並列リクエストが暗黙的なファイル位置で競合するのを防ぎます。ファイル全体を完全に読み取るには、実際の返却長に基づいてオフセットと残り長さを調整し、EOF、エラー、キャンセルを処理する必要があります。短読みを一律に失敗として扱うべきではありません。

SQ が空なら buffer を再利用できる?

二要求を準備・投入すると SQ は空く。三つ目を準備すると全 buffer に所有者がある。R2→R1 の順で完了すれば CQ は完了順だが ID が対応を保つ。SQ 空きでなく完了回収後に buffer を返す。

図を準備しています
SQ が空なら buffer を再利用できる?

SQ スロット・処理中要求・バッファ所有は別の台帳。SQ 消費でスロットは空いても要求は buffer を必要とし得る。

完了順序と依存関係

通常の独立したリクエストは順序不同で完了する可能性があり、コミットキューの添字でビジネスオブジェクトを直接マッチングすることはできません。user_data には通常、リクエスト ID、タイプ、またはオブジェクトの世代がエンコードされ、古い完了がすでに再利用されたオブジェクトに割り当てられるのを防ぎます。

前後の依存関係が必要な場合は、link、drain などのメカニズムをセマンティクスに従って使用しますが、これらはデータベーストランザクションではありません。前の書き込みで既に発生した副作用は、後のリクエストが失敗しても自動的にロールバックされません。書き込みの永続性については、適切な同期操作をコミットし、その結果を観察する必要があります。

SQPOLL と IOPOLL は別の方向性

SQPOLL は、カーネルスレッドがコミットキューをポーリングします。アプリケーションは準備済みの SQE をキューに公開し続ける必要があります。liburing を使用する場合は、適切な submit パスを呼び出し続ける必要があり、get_sqe/prep のみを行って何もしないようにします。ポーリングスレッドが待機状態になり NEED_WAKEUP が設定されている場合、ライブラリは必要に応じてカーネルに入り、それを起動します。io_uring_submit

IOPOLL は、サポートされている I/O の完了をポーリングするもので、デバイス、ファイルシステム、アクセスモードなどの条件に依存します。これらは個別に議論可能であり、「コミットのポーリング」と「デバイスの完了のポーリング」を同じ機能と見なしてはいけません。

継続的なポーリングは CPU を著しく消費する可能性があり、特定のコードの 5%–10% を固定して使用するわけではありません。DEFER_TASKRUN は、対応する作業を規定されたカーネル進入パス(イベントの取得パス)に遅延させます。SINGLE_ISSUER などの制約と正しいイベント進行方法が必要であり、「割り込みを無効にして高速化する」といった単純なものではありません。高度なフラグはバージョンごとに個別に照合する必要があります。liburing setup ドキュメント

リソースの登録: 管理コストの一部を事前に支払う

バッファを登録すると、条件を満たすパスでユーザーページを固定する重複作業を減らせますが、リソースを占有し、登録・注销のコストがかかります。使用する際は、対応する fixed-buffer 操作とインデックスを選択する必要があり、1 回の登録ですべての通常の read が自動的に同じパスを利用できるわけではありません。register_buffers

files の登録は、リングのファイルテーブルを構築します。IOSQE_FIXED_FILE を使用する際、SQE 内の対応する値はプロセスの通常の fd ではなく、テーブル内のインデックスです。更新、注销、および在途リクエストとの関係は、インターフェースプロトコルに従って処理する必要があります。register_files

バッファ、記述子オブジェクト、リクエスト状態にはすべてライフサイクルがあります。コミットが成功したからといって buffer を free してはいけません。また、キャンセルリクエストがコミットされたからといって、古い request_id をすぐに再利用してはいけません。

キャンセルとタイムアウト: 元のリクエストの終結も観察する

キャンセル操作は別個のリクエストであり、独自の完了結果を持ちます。成功する場合もあれば、対象がすでに完了していることが判明する場合、または直ちにキャンセルできない場合もあります。元操作の完了レコードは、インターフェースに従って処理する必要があります。アプリケーションは、「キャンセルを要求した」と「元のリクエストが終結した」という2つの状態を追跡すべきです。prep_cancel

link timeout は、関連する操作の待機を制限できますが、リング全体に自動的にタイムアウト分離を設定するものではありません。独立した低速リクエストは、通常、他のすべての独立したリクエストの完了を自然にブロックしません。依存関係の有無は、コミット方法によって決まります。

通常のワンショット操作は通常1つの CQE に対応しますが、multishot の accept や recv は複数の CQE を生成する可能性があります。リクエストが継続するかどうかを判断するには、IORING_CQE_F_MORE を確認する必要があります。一部のゼロコピー送信では、データバッファの再利用時期を判断するために追加の通知が必要であり、すべての CQE をリソースの最終回収点として一律に扱うべきではありません。Multishot accept

キャンセル操作と元のリクエストはそれぞれ独自の完了を持ち、個別に決済される 元のリクエスト R バッファとリクエスト状態を保持 キャンセルリクエスト C 対象 user_data = R C の CQE キャンセル試行の結果を示す R の終結 CQE → R の結果を決済し、通常の読み書きバッファを解放 C の user_data は R と区別可能であるべき。キャンセル応答をビジネス結果と誤認しないこと 本図は通常の単一リクエストに限定。multishot、ゼロコピー送信のリソース終結条件は別途対応プロトコルによる。

キャンセル競合では2つの結果を個別に記録する

以下の表は、一意の user_data を用いて通常の単一リクエストを1つキャンセルする場合に限定し、一致するすべての項目をキャンセルするフラグは使用しません。C はキャンセルリクエスト、R は元のリクエストです。キャンセル自体は、R が完了した外部副作用をロールバックしません。キャンセル結果

C の結果解釈できることR の処理
0対象を見つけ、キャンセルに成功R の完了を依然として受け取り処理する。C を R として扱わない
-ENOENT対象が見つからない。すでに完了しているか、ID が異なる可能性R がすでに決済済みかどうかを確認し、タグを調査し、直接重複解放しない
-EALREADY直ちにキャンセルできない段階まで進行済みR の終結を待ち続け、必要なリソースを保持
その他のエラーキャンセルリクエスト自体の失敗R の状態に基づいて進行を継続するか、セッションを終了

ビジネスのタイムアウト後に単に結果が不要になった場合、R を「結果破棄」フラグ付きにできますが、I/O ライフサイクルは完了させる必要があります。ビジネス状態の終了と、カーネルによるバッファアクセスの終了は同一ではありません。これが非同期インターフェースで所有関係(所有権)を明確にする必要がある理由です。

multishot の1回の結果とリクエスト終結も分離する

以下はオフライン CQE モデルです。MORE は IORING_CQE_F_MORE のブール値を示し、実際のリングは読み取りません。2回の成功した accept で2つの fd が得られ、最後のキャンセル完了でこの multishot リクエストが終了します。例では数値のみを記録し、実際のソケットの作成や閉じは行いません。

import errno
active = True
accepted = []
terminal = None
cqes = [(101, True), (102, True), (-errno.ECANCELED, False)]
for result, more in cqes:
    assert active
    if result >= 0:
        accepted.append(result)
    if not more:
        active = False
        terminal = result
assert accepted == [101, 102]
assert not active and terminal == -errno.ECANCELED
# 成功結果が最後のケースもあり、result >= 0 だけでまだアクティブと判断できない。
last_success, more = 103, False
assert last_success >= 0 and not more

実際の accept の成功結果は、すでに取得された接続リソースであり、接続管理またはクローズに渡す必要があります。リクエストの終結は、以前に accept された接続を自動的に閉じません。このリクエストの後続 CQE を待ち続けるかどうかを判断するには、res の正負だけでなく、MORE を確認する必要があります。Multishot accept

取消完了と元要求完了は別

C だけ回収しても buffer は保持し、R で元要求を精算する。read が先なら四バイト成功は後の取消で戻らない。回収順選択は独立台帳の説明で、全カーネルの CQE 順を保証しない。

図を準備しています
取消完了と元要求完了は別

取消と元操作は別結果。取消 res=0 でも元要求の終結処理が必要。元操作先行成功なら取消が ENOENT になり得る。

バッチ処理とバックプレッシャー

SQE が取得できない場合、準備済みの項目をコミットし、在途の数に基づいて進行を続ける必要があります。CQ もタイムリーに取得し、消費位置を進める必要があります。複数の完了を待つのと、実際のバッチトラバースは異なる動作であり、wait_cqes という名前を、アプリケーションのためにすべての CQE をすでに消費したものと解釈してはいけません。

キューの深さは、並列の在途作業を制御します。小さすぎるとデバイスを埋めきれず、大きすぎるとキューイングレイテンシ、メモリ、テールレイテンシが増加する可能性があります。「256–512 で十分」という汎用解はありません。まず、完了時間と目標スループットに基づいて並列要件を見積もり、飽和点とテールレイテンシを測定します。

スループットとレイテンシで数量を見積もり、バッファの勘定を加える

目標スループットが秒間 20000 リクエスト、平均在途時間が 2 ms と仮定すると、安定状態での平均在途数は約 40 になります。これはスループット×平均時間の容量見積もりであり、テールレイテンシの保証でも、カーネルが SQ 深度を 40 に設定しなければならない意味でもありません。

rate = 20_000
mean_seconds = 0.002
average_inflight = rate * mean_seconds
assert average_inflight == 40
buffer_size = 64 * 1024
limit = 64
buffer_budget = limit * buffer_size
assert buffer_budget == 4 * 1024 * 1024

通常の読み取りリクエストがそれぞれ 64 KiB のバッファを専有する場合、在途上限 64 は、少なくとも 4 MiB のペイロード容量に対応します。リクエストオブジェクト、リング、アライメント、その他のキューは含まれていません。SQ の空きはコミット容量に過ぎません。SQE がカーネルによって消費された後でも、リクエストは実行中の可能性があります。したがって、「新しい SQE が取得できた」ことを「在途バッファ予算が自動的に減少した」と見なしてはいけません。

アプリケーションは、在途リクエスト数と利用可能なバッファプールを独立して管理し、上限に達した場合は完了を取得するか、アップストリームを一時停止する必要があります。multishot の場合、すでに accept したが未処理の接続/メッセージの数も加算する必要があります。単一のアクティブな SQE が継続的にビジネスリソースを生成できるため、SQ 深度だけではそれらの累積を制限できません。

libaio や POSIX AIO と比較する際は、固定された buffered/direct モード、ブロックサイズ、デバイス、スレッド、同期保証を比較対象にしてください。Linux 原生 AIO の非同期性は、一部の buffered パスで制限されますが、構文上バッファ付きファイル I/O をコミットできないわけではありません。POSIX AIO はインターフェース仕様であり、Linux libc のスレッド実装はその具体的な実装の一つに過ぎません。

リクエスト API は非同期モデルを提供しますが、すべての操作がデバイスハードウェアによって非同期に実行されることを意味しません。一部の作業は即時完了するか、カーネルのワークスレッドに委ねられます。システムコールの削減数を議論する前に、まず動作、リソース回収、バックプレッシャーを検証してください。

キーワード: io_uring, SQE, CQE, liburing, completion, buffer lifetime, SQPOLL, IOPOLL, fixed buffers, cancellation, multishot, queue depth