非同期I/Oの実装(POSIX AIO・io_uring)
ディスクI/Oで待たされないための非同期I/O。なぜPOSIX AIOは実用にならず、libaioはO_DIRECT必須で、io_uringだけが汎用の非同期I/Oを実現できたのかを実装の中身から理解できます。
- 1.POSIX AIO(glibc実装)は内部でユーザースレッドプールが同期I/Oを代行するだけで、真のカーネル非同期ではなくスケールしない。
- 2.Linux native AIO(libaio)はカーネルで非同期化するが、O_DIRECT以外ではブロックし得る「半端な非同期」で、用途がDB等に限られた。
- 3.io_uringはSQ/CQリングで提出と完了を分離し、登録バッファ・登録ファイルでコピーと参照カウント操作を省いて真の汎用非同期I/Oを実現した。
非同期I/Oが難しい理由
read/write は呼んだスレッドを完了までブロックします。ディスクからのデータ到着には数ミリ秒かかることもあり、その間スレッドは寝たままです。これを避ける素直な発想が非同期I/O(AIO)——「I/Oを発行したら即座に戻り、完了は後で受け取る」モデルです。ところがLinuxでこの「当たり前」を汎用に実現するのは長らく困難でした。本記事は POSIX AIO → libaio → io_uring の系譜を、各段階が何を非同期化し損ねたかから解剖します。ソケットの多重化(/os/epoll-io-uring/)とは別系統の「ストレージI/Oの非同期化」が主題です。
POSIX AIO:スレッドプールという擬態
aio_read/aio_write というPOSIX標準APIがあります。一見「カーネルが非同期でI/Oしてくれる」ように見えますが、Linuxの主要実装(glibc)の中身はユーザー空間のスレッドプールです。
aio_readは内部スレッドにI/O要求をキューイングして即座に戻る。- 別スレッドが普通の同期
preadを実行し、ブロックして待つ。 - 完了したらシグナルやコールバックでアプリに知らせる。
つまり「非同期に見せかけているだけで、実体は同期I/Oをスレッドに肩代わりさせている」のです。これでは並行I/O数だけスレッドが要り、文脈切り替えとスレッド管理のコストがそのまま乗ります。数万の並行I/Oには到底スケールせず、POSIX AIOが本番で使われない最大の理由になりました。
APIがPOSIX標準でも、実装がカーネルの非同期機構を使うとは限りません。glibcのPOSIX AIOはユーザースレッドで同期I/Oを代行する方式で、I/O発行のシステムコール自体は依然ブロックします。「標準API=高性能」ではない典型例です。
Linux native AIO(libaio)と O_DIRECT の制約
そこでカーネルが本物の非同期I/Oを提供したのが native AIO(システムコール io_submit/io_getevents、ライブラリは libaio)です。要求をカーネルに提出(submit)して即座に戻り、完了は別途まとめて回収します。発行スレッドはブロックしません。
しかしこの非同期化には大きな但し書きがありました——実質 O_DIRECT(ダイレクトI/O)でしか非同期にならないのです。理由はページキャッシュにあります。
- 通常のバッファドI/Oは、読むデータがページキャッシュに無ければページ確保・ブロックI/O層への発行・ページのロック待ちなどが絡む。
- これらの経路はカーネル内部でブロックし得る箇所を含み、native AIOの枠組みでは非同期にできず、
io_submitがその場で同期的にブロックしてしまう。 - 確実に非同期になるのは、ページキャッシュを迂回しユーザーバッファとデバイスを直結する
O_DIRECTのとき。
その O_DIRECT にも厳しい制約があります。バッファのアドレス・長さ・ファイルオフセットをデバイスのブロックサイズ(通常512Bや4KB)にアラインしなければならず、ページキャッシュの恩恵(先読み・書き込みの集約)も失います。結果、libaio が活きるのは自前でキャッシュを持つデータベースなど一部に限られ、「汎用の非同期I/O」にはなり得ませんでした。
| 観点 | POSIX AIO(glibc) | native AIO(libaio) |
|---|---|---|
| 非同期化の主体 | ユーザースレッドプール | カーネル |
| 並行I/Oのコスト | I/O数ぶんのスレッド | スレッド非依存 |
| バッファドI/O | (同期を代行するので)一応動く | ブロックし得る=実質非対応 |
| O_DIRECT | 不要 | 実質必須・アライン制約あり |
| 対象操作 | read/write中心 | read/write中心 |
| スケール性 | 低い | 中(用途限定) |
io_uring:提出と完了をリングで分離する
io_uring はこの行き詰まりを、カーネルとユーザー空間で共有する2本のリングバッファで打開しました。要点は「I/Oの提出(submission)と完了(completion)を別々のキューに分離し、両者を共有メモリ上に置く」ことです。
- SQ(Submission Queue):アプリがやってほしいI/Oを SQE(Submission Queue Entry) として書き込むリング。
- CQ(Completion Queue):カーネルが結果を CQE(Completion Queue Entry) として書き込むリング。
ユーザー空間 共有メモリ カーネル
│ ①SQEを書く ──────→ [ SQ ring ] ─────→ ②I/Oを実行
│ │
│ ④CQEを読む ←────── [ CQ ring ] ←───── ③完了を書き込む
提出と完了が分離しているため、多数のI/OをまとめてSQに積み、1回の io_submit 相当のシステムコール(io_uring_enter)で投入できます。各SQEには user_data を添えられ、完了したCQEに同じ値が載るので、どの要求の結果かを照合できます。さらに O_DIRECT でなくても——カーネル内部でブロックし得るバッファドI/Oでも——カーネルワーカーに肩代わりさせることで非同期に回せるため、libaioの「O_DIRECT縛り」が外れ、ようやく汎用の非同期I/Oになりました。
提出と完了が独立キューなので、提出側と完了側のポインタ更新が干渉しません。これがロックを避けた単一生産者・単一消費者リングを成り立たせ、システムコールの固定コストをバッチ化で薄める土台になります。
SQ/CQはロックフリーのリングで、tail/head の更新とメモリバリアの順序を誤ると、書き終えていないSQEをカーネルが読む・完了を取りこぼす競合が起きます。通常は liburing が正しいバリアを隠蔽するので、自前でリングを直接叩くのは避けるのが安全です。
登録バッファ・登録ファイルによる最適化
io_uring は提出経路をさらに削るために、I/Oに使う資源を事前に一度だけ登録する仕組みを持ちます。
- 登録バッファ(
IORING_REGISTER_BUFFERS):I/Oに使う固定バッファをあらかじめカーネルにピン留め登録する。通常のread/writeは毎回ユーザーバッファのページを参照・固定(get_user_pages)する必要があるが、登録済みなら毎回のページピン留めを省ける。IORING_OP_READ_FIXEDなどで使う。 - 登録ファイル(
IORING_REGISTER_FILES):使うファイルディスクリプタを配列で登録する。通常は提出のたびに fd からstruct fileを引き、参照カウントの増減(fget/fput)が走るが、登録済みなら毎回の参照カウント操作を省略でき、配列添字でファイルを指定できる。
どちらも「I/O1回ごとの固定コスト」を提出ループの外へ追い出す最適化です。短いI/Oを高頻度で回すワークロードほど、このオーバーヘッド削減が効きます。加えて SQPOLL モードではカーネル専用スレッドがSQリングを自分でポーリングするため、定常状態では io_uring_enter すら不要になり、提出・回収ともシステムコールゼロに近づきます。
「なぜ io_uring が libaio を置き換えたか」は頻出です。核心は2点——(1) 提出と完了をリングで分離しバッファドI/Oも含めて非同期化できたこと(O_DIRECT縛りの解消)、(2) 登録バッファ/登録ファイルとSQPOLLでI/Oごとの固定コストとシステムコールを削ったこと。POSIX AIOが「スレッドプールの擬態」、libaioが「O_DIRECT限定」だった点と対比して語れると強いです。
新規の高性能ストレージI/Oなら io_uring(カーネル5.5以降で実用機能が揃う)が第一候補です。既存のDBエンジン互換や限定環境では libaio + O_DIRECT が今も現役。POSIX AIO は移植性目的の最後の手段で、性能を期待する場面では選びません。
まとめ
非同期I/Oの実装史は「どこのブロックを誰が肩代わりするか」の探求です。POSIX AIOはユーザースレッドに同期I/Oを代行させる擬態でスケールせず、native AIO(libaio)はカーネル非同期化を果たしたもののページキャッシュ経路のブロックを避けられず実質 O_DIRECT 限定でした。io_uringはSQ/CQの共有リングで提出と完了を分離し、バッファドI/Oもワーカーに回して汎用の非同期I/Oを実現。さらに登録バッファ・登録ファイルでI/Oごとの固定コストを、SQPOLLでシステムコールそのものを削りました。ソケット側の多重化(epoll・io_uring)と合わせて理解すると、現代Linux I/Oの全体像がつながります。
OS Article
非同期I/Oの実装(POSIX AIO・io_uring)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
非同期I/O
比較で見る軸
難易度: advanced / カテゴリ: OS / タグ数: 6
導入後に効く点
Linux native AIO(libaio)はカーネルで非同期化するが、O_DIRECT以外ではブロックし得る「半端な非同期」で、用途がDB等に限られた。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- OS
- タグ数
- 6
判断チェックリスト
- 自社の用途が「非同期I/O / io_uring」に近いか確認する。
- 強みである「POSIX AIO(glibc実装)は内部でユーザースレッドプールが同期I/Oを代行するだけで、真のカーネル非同期ではなくスケールしない。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。