はじめに
Linux 5.1に新しい非同期IOの仕組みとしてio_uringがマージされてから既に1年以上経ってしまいましたが、これまでのLinuxにおける非同期IOの使い方や実装を見ていきながら、io_uringが登場した背景やLinux AIO(libaio)の問題点をどのように解決しているのかについてまとめました。アプリケーションの書き方については大まかにしか説明していないので、それについてはmanページや別の記事を参照してください。
またIOという範囲が広いのですが、ここではブロックデバイス上のファイルシステムにおける通常ファイルに対するread/writeについて考えています(ネットワークは考えていないのでepollの話はないです)。
前提知識
簡単に前提となる話をおさらいします。
同期IOと非同期IO
IOを行うシステムコールとしてすぐに思いつくのはread(2)/write(2)ですが、これらは同期IOを行います。ここで同期であるとはデータがユーザーバッファに読み込まれる(or ユーザーバッファから書き出される)まで処理を行う関数が復帰しないという意味です。
Linuxで同期IOを行うシステムコールとしては他にpread64(2)/pwrite64(2)/preadv(2)/pwritev(2)/preadv2(2)/pwritev2(2)があります1。"p"がつくとIO開始オフセットが指定でき、"v"がつくと複数バッファを指定することができます。更にv2では挙動をコントロールするためのフラグを指定できます。
IOは(すぐ下で説明するページキャッシュにヒットしない限り)通常時間がかかるため、IO処理をバックグラウンドで行いたいというのは自然な考えです。非同期IOではIOの発行処理を行った段階で1度関数がリターンし、実際のIO処理をバックグラウンドで行います。そしてIOの結果は後ほど別の関数を呼び出したりすることにより確認します。
buffered IOとdirect IO
デフォルトの状態でIOを行うとkernelの内部ではページキャッシュが利用されます。すなわちread(2)を行う場合はデバイスから読み出したデータが一度ページキャッシュに入り、そこから更にユーザーバッファにコピーされます。またwrite(2)の場合はページキャッシュへのデータの書き込みが完了した時点でシステムコールがリターンし、後ほどバックグラウンドでデータがデバイスへ書き出されます(fsync(2)やfdatasync(2)を呼び出してユーザーから同期的に書き出しを行うこともできます)。このようにページキャッシュを使用するIOのことをbuffered IOと呼びます。ページキャッシュへのread/writeはメモリへのread/writeですので、ページキャッシュにヒットすれば高速に処理を行うことが可能です。
一方でdirect IOとはページキャッシュを介さず、ユーザーバッファとデバイスの間で直接IOを行うことを指します。これはopen(2)にO_DIRECTフラグを与えたFDを利用する場合に行われます。通常デバイスは最小で512byte単位のアクセスしか行えないため、direct IOを行う場合はIOサイズが512byteにalignmentされている必要があります。
ただし多くのファイルシステムではalignmentの不一致等でdirect IOができない場合もエラーにはならず、自動でbuffered IOにフォールバックします。よって不注意にプログラムを書くとdirect IOをしているつもりがbuffered IOをしている事態が発生します。
Linuxでの非同期IOの実装
ここからが本題です。Linuxの非同期IOについて調べると以下の3つが出てきます:
名前 | 実装方法 | ライブラリ | 利用できるop | 登場時期 | 状況 |
---|---|---|---|---|---|
Posix AIO | ユーザースレッド | glibc (librt) | read/write/sync | 1998? | 恐らく誰も使っていない |
Linux AIO | システムコール | libaio | read/write/sync/poll | 2002(v2.5) | あまり使われていない |
io_uring | システムコール | liburing | read/write/sync/poll+たくさん | 2019(v5.1) | 普及中 |
結論を言ってしまうと
- Posix AIOはユーザー空間でIO用スレッドを生成することで非同期IOを行うが、スレッド管理のコストが高く性能がスケールしない
- Linux AIOのは性能面でPosix AIOより優れているもののdirect IOでなければ非同期IOにならず、またその場合でもIOの発行時点で処理がブロックする可能性がある
といった問題があり、これまであまり使用されていませんでした。io_uringではLinux AIOの欠点を改善しており、buffered IOでも使用できる上、IOの発行時点で処理がブロックしないようになっています。またより高性能で様々なIOオペレーションをサポートすることも目的としています。
以下ではPosix AIOから順にAPIの使い方やどのような実装がなされているのかを確認していきます。
Posix AIO
はじめに最も古いPosix AIOを見ます。名前の通りPosixに準拠した非同期IOの実装です。他の2つの実装と大きく違う点はユーザー空間でIO実行用スレッドを生成することで非同期IOを実現している点であり、glibcではrealtimeライブラリ(librt)の中に実装されています。因みに"man aio"で出てくるman pageはこのPosix AIOの説明です。
大まかな使い方
まず簡単にAPIの使い方を説明します。ざっくりいうとアプリケーションからは以下のような流れで非同期IOを行います:
- (IOするファイルは事前にopen(2)する)
- 1つのIOリクエストを表現するstruct aiocbをセットアップする
- aio_read(3)やaio_write(3)に1.のaiocb(のポインタ)を渡して非同期IOを発行する
- aio_error(3)/aio_return(3)で発行したIOの結果を取得する
- 発行したリクエストをキャンセルする場合はaio_cancel(3)を呼ぶ
ここでIOが完了するまではaio_error(3)は-EINPROGRESSを返すため、これによりIOが完了したかどうかを確認することができます。またまとめてIOリクエストを発行する場合はlio_listio(3)を利用することができます。
1つのIOリクエストを表現するstruct aiocbは以下のような定義になっています(man(7) aioより):
#include <aiocb.h>
struct aiocb {
/* The order of these fields is implementation-dependent */
int aio_fildes; /* File descriptor */
off_t aio_offset; /* File offset */
volatile void *aio_buf; /* Location of buffer */
size_t aio_nbytes; /* Length of transfer */
int aio_reqprio; /* Request priority */
struct sigevent aio_sigevent; /* Notification method */
int aio_lio_opcode; /* Operation to be performed;
lio_listio() only */
/* Various implementation-internal fields not shown */
};
構造体のメンバを見てわかるとおり、IOを行うfile descriptorや開始オフセット、データを格納するバッファへのポインタ、IOサイズといった情報がリクエストの中に含まれていることがわかります。またIO完了時にsignalを受け取りたい場合はaio_sigeventのフィールドが使用でき、SIGEV_SIGNALを設定する場合はsignalのタイプ、SIGEV_THREADを設定する場合はIO完了時に実行されるコールバックを登録することができます。aio_lio_opcodeにはIOオペレーションの種類(read/write/sync)が入ります。
なおこのPosix AIOのように、1) IOリクエストを表現する構造体を初期化, 2) IOリクエストを発行, 3) 後ほどIOの完了を確認, という実行の流れはこのあとのLinux AIOやio_uringでも同じです。
Poxis AIOのコード
Posix AIOについて調べると裏でスレッドを作って頑張っているらしいという情報が出てくるのですが、ネット上には実際のコードがいまいち出てこないのでglibcのソースを見て確認します。
glibcのgit URLは以下に記載されています: https://www.gnu.org/software/libc/sources.html
なお以下で参照しているバージョンは2.32です。
まずaio_read(3)の定義を探すとrt/aio_read.cに見つかりますが、aio_read64にdefineされているだけです:
22 #ifdef BE_AIO64
23 #define aiocb aiocb64
24 #define aio_read aio_read64
25 #endif
次にaio_read64を探すとこちらも__aio_enqueue_request()を呼んでいるだけです(aio_write(3)を見てみるとそちらでも同じ関数を呼んでいることがわかります):
25 int
26 aio_read64 (struct aiocb64 *aiocbp)
27 {
28 return (__aio_enqueue_request ((aiocb_union *) aiocbp, LIO_READ64) == NULL
29 ? -1 : 0);
30 }
では__aio_enqueue_request()を見てます。この関数がIO発行処理のメイン関数ですが、一番重要な点はIO実行スレッドを作成する必要があると判断した場合にpthread_createを利用して新たなスレッドを生成している点です:
295 /* The main function of the async I/O handling. It enqueues requests
296 and if necessary starts and handles threads. */
297 struct requestlist *
298 __aio_enqueue_request (aiocb_union *aiocbp, int operation)
299 {
...
409 /* We try to create a new thread for this file descriptor. The
410 function which gets called will handle all available requests
411 for this descriptor and when all are processed it will
412 terminate.
413
414 If no new thread can be created or if the specified limit of
415 threads for AIO is reached we queue the request. */
417 /* See if we need to and are able to create a thread. */
418 if (nthreads < optim.aio_threads && idle_thread_count == 0)
...
/* IO用のスレッド(handle_filed_io)を生成 */
425 result = aio_create_helper_thread (&thid, handle_fildes_io, newp);
...
35 extern inline int
36 __aio_create_helper_thread (pthread_t *threadp, void *(*tf) (void *), void *arg)
37 {
...
/* pthreadが利用されている */
44 int ret = pthread_create (threadp, &attr, tf, arg);
...
コメントにもある通り、IOを行おうとするファイルに対応するIOスレッドがすでにあったり、IOスレッド数が上限に足している場合はスレッド生成は行わず、リストにリクエストをつなぐ処理だけ行います。
そしてhandle_fileds_ioでは実際にIOを実行(read/writeシステムコールを実行)し、完了後に__aio_notify()を利用して(設定されている場合は)発行元プロセスにシグナルを送信します:
478 static void *
479 handle_fildes_io (void *arg)
480 {
...
/* 例えばread request(aio_read(3))の場合はpreadを実行する */
519 if ((aiocbp->aiocb.aio_lio_opcode & 127) == LIO_READ)
...
529 aiocbp->aiocb.__return_value =
530 TEMP_FAILURE_RETRY (__libc_pread (fildes,
531 (void *)
532 aiocbp->aiocb.aio_buf,
533 aiocbp->aiocb.aio_nbytes,
534 aiocbp->aiocb.aio_offset));
...
/* aio_return(3)/aio_error(3)でaiocb.__return_value/__error_codeの値が返る */
588 if (aiocbp->aiocb.__return_value == -1)
589 aiocbp->aiocb.__error_code = errno;
...
593 /* Send the signal to notify about finished processing of the
594 request. */
595 __aio_notify (runp);
...
コードの残りを読むと他に実行するべきリクエストがある際はこのスレッドを再利用していることなどがわかります。
Posix AIOの問題点
ということでglibcが提供するPosix AIOではpthreadを使って非同期IOを行っていることが確認できました。ざっと眺めた感じでは特にトリッキーなことはしていないようです。
しかし残念ながらこの「ユーザー空間でスレッド管理を行ってIOする」という実装が性能面で大きな壁となります。実際man(7) aioにも以下の記述があります:
The current Linux POSIX AIO implementation is provided in user space by glibc. This has a number of limitations, most notably that maintaining multiple threads to perform I/O operations is expensive and scales poorly.
この性能問題のため、現在LinuxでPosix AIOを利用している人はいないようです。
Linux AIO
Posix AIOとは違い、(Linux独自の)システムコールを利用してkernel空間で非同期IOを行う仕組みがLinux AIOです。kernel空間で処理を行うことにより性能が改善しています。こちらはglibcではなくlibaioによりシステムコールのwrapperが提供されています。
大まかな使い方
こちらも簡単な使い方から見ていきます。以下の手順で非同期IOを実行します:
- (IOするファイルは事前にopen(2)する)
- io_setup(2)でio contextを初期化する
- IOイベント2毎にstruct iocbを初期する
- io_submit(2)にcontextと2.のiocb(のポインタ配列)を指定して非同期IOを発行する
- io_getevents(2)によりIOイベントの完了待ちを行い、IOの結果をstruct io_event(の配列)として受け取る
- 発行されたイベントをキャンセルする場合はio_cancel(2)を呼ぶ3
Posix AIOの使用の流れに似ていますが、1点違うのはまずはじめにio_setup(2)によりcontextの初期化を行っていることです。io_setup(2)にはそのcontextで扱うイベント数の上限(同時発行するIOの上限)を指定しますが、kernelでは実行されたIO結果を保持するリングバッファのallocation等の初期化を行います。またio_submit(2)ではstruct iocbのポインタ配列4を渡すため、1回のシステムコールで複数のIOを発行することができます。同様にio_getevents(2)も一度の呼び出しで複数のIOの完了を待ち、それらの結果を受け取ることができます(非同期のため発行順に完了するとは限りません)。
なおlibaioではシステムコールと同名のwrapperが提供されているのに加え、いくつか簡単なライブラリ関数が用意されています (例えばread用にiocbをセットアップするio_prep_pread(3)など)。
struct iocbはLinuxのABIとして以下のように定義されています:
#include <linux/aio_abi.h>
struct iocb {
__u64 aio_data;
__u32 PADDED(aio_key, aio_rw_flags);
__u16 aio_lio_opcode;
__s16 aio_reqprio;
__u32 aio_fildes;
__u64 aio_buf;
__u64 aio_nbytes;
__s64 aio_offset;
__u64 aio_reserved2;
__u32 aio_flags;
__u32 aio_resfd;
};
(※ 実体は同じですがlibaio.hでは異なるメンバ名でstruct iocbが定義されているので注意が必要です)
Posix AIOで使用されるstruct aiocbと比較してみると、struct aiocbのメンバは基本的にstruct iocbに含まれていることがわかります(IO対象のfile descriptorやデータを格納するバッファへのポインタ、IOサイズ等の情報)。一見struct aiocbにはあったsignalの設定がないように見えますが、aio_resfdにeventfd(2)を使用して作成したeventfd objectをセットするとIO完了時に通知を受け取ることができます。またstruct aiocbにはないメンバとしてaio_dataがありますが、非同期IOは発行順に完了するとは限らないため、リクエストを識別するためにユーザーが任意の値を設定できます(すぐ次に説明するstruct io_event::dataにコピーされます)5。なおaio_keyはkernel内部で使用するフィールドのためユーザーには無関係です。
io_getevents(2)では(複数の)リクエストの完了を待ち、その結果がstruct io_eventの配列に入ります。struct io_eventは以下の定義です:
#include <linux/aio_abi.h>
struct io_event {
__u64 data; /* the data field from the iocb */
__u64 obj; /* what iocb this event came from */
__s64 res; /* result code for this event */
__s64 res2; /* secondary result */
};
先程述べたとおりdataフィールドには対応するstruct iocbのaio_dataの値がコピーされます。またobjには対応するstruct iocbのポインタが入ります。またresとres2はコメントからは用途がいまいち不明ですが、read/writeの場合はresに実行したIOの戻り値(実際に読み出し/書き込みが行われたバイト数)が入り、エラーが発生した場合はres2にエラーコードが格納されます。
Linux AIOの問題点
Linux AIOを使えばカーネル空間で非同期IOを実現できて万全のようですが、実は問題がいくつかあります。これについてはio_uringの作成者によるio_uringの紹介資料6にもまとめられているのでそちらから引用すると、大きく次の3つの問題があります:
-
そもそもDirect IOでないと実は非同期IOにならない
(buffered IO(つまりopen(2)にO_DIRECTフラグを指定していない場合)でもLinux AIOのインタフェースは使えますが、その際はio_submit(2)の時点でIO完了まで処理がブロックします7) -
Direct IOを使用して非同期IOを行う場合でも、場合によってはio_submit(2)の時点で(IOが発行されるまで)処理がブロックする可能性がある
-
io_submit(2)/io_geteventes(2)で余分なuser⇔kernel間のメモリコピーが発生する上、必ず2回システムコールを呼ばなければならない
それぞれの問題点についてLinux AIOのコードも見ながら確認していきます。以下で参照しているコードはv5.10-rc5時点のものです。
1. Direct IOを使用しないと非同期IOにならない
まず非同期IOの発行処理がkernelでどのように処理されているのかを見てみます。
実はread(2)/write(2)の延長でkernelが実行する関数と、io_submit(2)の延長でkernelが実行する関数は同じです。例えばread(2)の場合は以下のような関数が呼ばれます(write(2)の場合も以下の部分までは"read"を"write"に置き換えるだけです):
readシステムコール
- ksys_read
- vfs_read
- new_sync_read
- call_read_iter
- f_op->read_iter
(以下ファイルシステム毎の処理が呼ばれ、
さらにデバイスへアクセスするためのブロックレイヤーの処理が呼ばれる)
一方、io_submit(2)でreadリクエストを処理する場合は以下です:
io_submitシステムコール
- io_submit_one
- __io_submit_one
- (aio_prep_rw)
- aio_read
- call_read_iter
- f_op->read_iter
よってどちらもf_op->read_iterを呼んでいます。このf_op->read_iterはファイルシステム毎に定義される関数で、名前が示すようにread処理の実体です。ではread(2)とio_submit(2)でf_op->read_iterを実行する時点で何が違うかというと、read_iterに渡すstruct kiocb(kernelで使用するIOを表現する構造体)にki_completeがセットされているどうかが異なります。read(2)の場合はki_completeはNULLですが、io_submit(2)の場合はaio_prep_rwにおいてki_completeに関数ポインタがセットされます:
1445 static int aio_prep_rw(struct kiocb *req, const struct iocb *iocb)
1446 {
...
1449 req->ki_complete = aio_complete_rw;
実はこのki_completeは非同期IOの完了時に実行されるコールバック関数であり、kernelではki_completeがNULLかどうかによりデバイスへのIOリクエストの完了待ちを同期で行うかどうかを決定しています。実際fs.hに以下の関数が定義されています:
/* 同期IOならtrue, 非同期IOならfalseを返す */
340 static inline bool is_sync_kiocb(struct kiocb *kiocb)
341 {
342 return kiocb->ki_complete == NULL;
343 }
このis_sync_kiocbが呼ばれている箇所を探すと分かりますが、direct IOの場合、is_sync_kiocbがtrueであるならばデバイスへのIO発行を行う処理でIO完了までブロックして待つのですが、falseであるならば発行後は完了を待たずに-EIOCBQUEUEDを呼び出し元に返します(-EIOCBQUEUEDはエラーのように見えますがエラーではなく、正常に非同期IOの発行処理が完了したことを表します)。また、buffered IOのパスではis_sync_kiocbは特に考慮されません。すなわち、
- io_submit(2)でdirect IOを行う場合、is_sync_kiocbがfalseなので非同期IOになる
- read(2)/write(2)でdirect IO行う場合、is_sync_kiocbがtrueなので非同期IOにはならない
- io_submit(2)でbuffered IOを行う場合、is_sync_kiocbはfalseであるももの、buffered IOのパスではis_sync_kiocbが考慮されないためread(2)/write(2)と変わらず同期的にIOが実行さる (このときki_completeはaio_rw_doneにおいて同期的に呼び出される)
となります。
(補足 : buffered IOの場合、ページキャッシュ上にデータがある場合はメモリコピーをするだけなので特に問題はありません(writeの場合もページキャッシュに書けるなら問題ありません)。問題はページキャッシュにデータがない時や何らかの理由でデータの書き出しが必要になった時で、デバイスへのIOを行うとともにその完了を待ちます8)
では実際にdirect IO時のf_op->read_iter以降の具体的な処理を確認してみます。最近のファイルシステム(ext4/xfs/btrfs等)ではデバイスへのIO処理にbuffer headを利用しないiomapのインフラストラクチャーに移行している9ので、ここではext4の場合を見てみます。f_op->read_iter以降のDirect IO処理は以下です:
- ext4_file_read_iter (f_op->read_iter) [fs/ext4/file.c]
- ext4_dio_read_iter
- iomap_dio_rw [fs/iomap/direct-io.c]
- __iomap_dio_rw
(※ 結局のところxfsやbtrfsのdirect IOでも最後にはiomap_dio_rwを呼びます)
ここでiomap_dio_rwの引数にis_sync_kiocbかどうかを渡しています(L.78):
52 static ssize_t ext4_dio_read_iter(struct kiocb *iocb, struct iov_iter *to)
53 {
...
/* 非同期IOの場合、is_sync_kiocb()はfalse */
77 ret = iomap_dio_rw(iocb, to, &ext4_iomap_ops, NULL,
78 is_sync_kiocb(iocb));
この値はそのまま__iomap_dio_rwのwait_for_completionに渡され、falseの場合(非同期IOをする場合)は完了待ちをせずに-EIOCBQUEUEDをリターンしています (L.572):
420 struct iomap_dio *
421 __iomap_dio_rw(struct kiocb *iocb, struct iov_iter *iter,
422 const struct iomap_ops *ops, const struct iomap_dio_ops *dops,
423 bool wait_for_completion)
424 {
...
516 do {
/* デバイスに対するIO submit処理 */
517 ret = iomap_apply(inode, pos, count, flags, ops, dio,
518 iomap_dio_actor);
...
538 } while ((count = iov_iter_count(iter)) > 0);
...
570 if (!atomic_dec_and_test(&dio->ref)) {
/* wait_for_completionがfalse(非同期IO)ならIO完了待ちを
せずに-EIOCBQUEUEDをリターン */
571 if (!wait_for_completion)
572 return ERR_PTR(-EIOCBQUEUED);
/* そうでない場合、IOが完了するまでscheduleしながら待つ */
574 for (;;) {
575 set_current_state(TASK_UNINTERRUPTIBLE);
/* IOが完了するとdio->submit.waiterがNULLになる */
576 if (!READ_ONCE(dio->submit.waiter))
577 break;
578
579 if (!(iocb->ki_flags & IOCB_HIPRI) ||
580 !dio->submit.last_queue ||
581 !blk_poll(dio->submit.last_queue,
582 dio->submit.cookie, true))
583 blk_io_schedule();
584 }
585 __set_current_state(TASK_RUNNING);
586 }
非同期IO完了時にはiomap_dio_complete_workが呼ばれますが、そこでiocb->ki_completeが実行されます:
135 static void iomap_dio_complete_work(struct work_struct *work)
136 {
137 struct iomap_dio *dio = container_of(work, struct iomap_dio, aio.work);
138 struct kiocb *iocb = dio->iocb;
139
/* Linux AIOの場合、aio_complete_rwが実行される */
140 iocb->ki_complete(iocb, iomap_dio_complete(dio), 0);
141 }
ki_completeはaio_complete_rwですが、そこからiocb_putが呼ばれ、(最後のiocbへの参照がputされるとき) にaio_complete()が実行され、IO結果がリングバッファに書かれたり、(設定されている場合は)eventfdへ通知されたりします。
なお__iomap_dio_rw中のデバイスへのIO submit処理(L.517)について少し触れると、分岐が多いのですが例えばiomap_apply ⇨ iomap_dio_actor ⇨ iomap_dio_bio_actor ⇨ iomap_dio_submit_bio ⇨ submit_bio[block/blk-core.c]などのパスでブロックレイヤーの関数が呼びされています。
やや長くなりましたがまとめるとio_submit(2)の挙動は上の通りで、システムコールの延長でデバイスへのIO発行処理まで行います。そしてdirect IOが利用されている場合はデバイスのIO処理の完了を待たずに呼び出し元にリターンしますが、buffered IOの場合は同期的にIOを実行します。よってLinux AIOではbuffered IOの場合は非同期IOになりません。
2. Direct IOを使用していてもio_submit(2)時に処理がブロックする可能性がある
1.の通りDirect IOでなければ非同期IOにならないわけですが、残念ながらDirect IOであってもio_submit(2)の時点で処理がブロックする可能性が残されています。処理がブロックするパスは複数ありますが、恐らく一番簡単な例はbuffered IOとDirect IOが同時に実行された場合なので、まずこの例を見ていきます。
1.で確認した__iomap_dio_rw()関数の他の部分を見てみると、以下のコードがあります:
420 struct iomap_dio *
421 __iomap_dio_rw(struct kiocb *iocb, struct iov_iter *iter,
422 const struct iomap_ops *ops, const struct iomap_dio_ops *dops,
423 bool wait_for_completion)
424 {
...
/* NOWAITフラグは後述 */
481 if (iocb->ki_flags & IOCB_NOWAIT) {
482 if (filemap_range_has_page(mapping, pos, end)) {
483 ret = -EAGAIN;
484 goto out_free_dio;
485 }
486 flags |= IOMAP_NOWAIT;
487 }
488
/* ページキャッシュに未書き出しデータがある場合、
デバイスに書出してその完了を待つ */
489 ret = filemap_write_and_wait_range(mapping, pos, end);
490 if (ret)
491 goto out_free_dio;
今気にしているはL.489の処理です。filemap_write_and_wait_range()は指定された範囲のページキャッシュが未書き出し(ページキャッシュ上にのみ最新のデータがあり、デバイスまで書き出されていない)である場合、そのデータのデバイスへの書き戻しを行い、それが完了するまでブロックします。つまりio_submit(2)時でdirect IOを利用して非同期IOを実行する場合も、ページキャッシュに未書き出しデータが残っているとここでブロックしてしまいます (Direct IOはページキャッシュを使わず、一方buffered IOはページキャッシュを使うので、同時に実行される場合データの一貫性が保証できないため排他処理されます)。
通常アプリケーションはbuffered IOとdirect IOを同時に実行しない(というより実行するのは正気でない)のでここでブロックすることはないのかもしれませんが、これ以外にもブロックする可能性のある処理はあります。例えばファイルサイズが大きくなり、デバイス上で新しいブロックを割り当てる必要がある場合はファイルシステムのメタデータも更新する必要があるため、その更新の完了を待つまでIOはブロックします。
更にはファイルシステムだけでなく、ブロックレイヤーにおいてデバイスのキューがFULLである場合などにも処理がブロックする可能性があります。これについてもコードを見てみます。問題点1.で少し触れたとおりiomapのdirect IOの処理では最終的にsubmit_bioが呼ばれますが、それ以降の処理は例えば以下のようになります:
submit_bio
- submit_bio_noacct
- __submit_bio_noacct
- bio_queue_enter
- blk_queue_enter
ここでblk_queue_enterを見ると、デバイスのリクエストキューが使用可能かチェックし、使用不可の場合は可能になるまで待っています:
429 int blk_queue_enter(struct request_queue *q, blk_mq_req_flags_t flags)
430 {
...
433 while (true) {
434 bool success = false;
435
436 rcu_read_lock();
/* そのキューが使用可能かチェック */
437 if (percpu_ref_tryget_live(&q->q_usage_counter)) {
...
443 if (pm || !blk_queue_pm_only(q)) {
444 success = true;
445 } else {
446 percpu_ref_put(&q->q_usage_counter);
447 }
448 }
449 rcu_read_unlock();
450
/* 使用できるなら終了 */
451 if (success)
452 return 0;
453
454 if (flags & BLK_MQ_REQ_NOWAIT)
455 return -EBUSY;
...
/* 空いていなければ利用できるのを待つ */
466 wait_event(q->mq_freeze_wq,
467 (!q->mq_freeze_depth &&
468 (pm || (blk_pm_request_resume(q),
469 !blk_queue_pm_only(q)))) ||
470 blk_queue_dying(q));
471 if (blk_queue_dying(q))
472 return -ENODEV;
473 }
L.466でwait_eventを呼びますが、これにより他のスレッドからwake_upされるまでUNINTERRUPTIBLEステートになるためここで処理がブロックします。以上のようにio_submit(2)では非同期IOのパスでも処理がブロックする可能性があり、必ずブロックせずにすぐにリターンする挙動を望むアプリケーションでは結局IOスレッドを作成してそこからio_submit(2)を実行するようにしなければなりません。
なおこの「submit時にブロックする問題」に関しては、2017年(v4.14)にRWF_NOWAITフラグがio_submit(2)に設定できるようになったことで多少改善されています。RWF_NOWAITフラグがセットされている場合、io_submit(2)で処理をブロックする必要があるとき、(NOWAITに対応している)ファイルシステムやブロックレイヤーは処理を行わずに-EAGAINをユーザーにリターンします。上の例でも__iomap_submit_dioのL.481ではNOWAITフラグが立っている場合、ページキャッシュに未書き出しデータがあるかのみをチェックし、未書き出しデータがある場合は-EAGAINを返しています。blk_queue_enterのL.454でもNOWAITフラグがセットされている場合はキューが利用できるのようになるのを待たずにエラーを返します(最終的に-EAGAINになります) 10。
ただし全てのファイルシステムやブロックレイヤーでこのNOWAITの挙動はサポートされていないので注意が必要です (ext4/xfs/btrfsやblk-mqはサポートしています11)。
3. io_submit(2)/io_geteventes(2)でuser⇔kernel間のメモリコピーが発生する上、必ず2回システムコールを呼ばなければならない
3つ目の問題点はシステムコールのAPIに関するものです。
io_submit(2)ではstruct iocbのポインタ配列、io_getevents(2)ではstruct io_eventsの配列をシステムコールの引数として渡します。kernel空間とuser空間はメモリ空間が別のため、配列の先のデータにアクセスするためにはkernel ⇔ user間でメモリコピーをする必要があります。上に記載した構造体の定義を見ると分かりますが、struct iocbは64byte, struct io_eventは32byteです。またio_submit(2)にはポインタ配列を渡すので、1つのイベントにつきポインタ(8byte)のコピーも必要です。よって合計すると 8+64+32 = 104 byteのメモリコピーが1つのイベントに対して必要になります。
104 byteというのは1回のIOサイズが大きな場合は問題ではないでしょうが、small IOを大量に行う場合はこのメモリコピーの影響が性能に現れる可能性があります。
また1つのIOに対して必ずio_submit(2)とio_getevents(2)を呼び出さなければなりません。大量にIO処理を行うとき、場合によっては非同期IOを利用していても発行と同時に完了待ちをしたいケースがあるかもしれませんが、そのときに2回システムコールを呼ばなければならないのは処理の無駄と言えます12。
io_uring
ようやくio_uringの話です。io_uringもLinux独自のシステムコールとして非同期IOを実現しており、前述のLinux AIOにおける問題点を以下のように改善しています:
-
問題点1,2について ... io_uringではdirect IOだけでなくbuffered IOも使用できます。またIOリクエストを発行する際は処理がブロックしない場合はデバイスに対するリクエスト発行まで行いますが、処理がブロックする可能性のある場合はバックグラウンドのスレッドでIO処理を行います
-
問題点3について、io_uringではuser/kernel空間で共有するリングバッファを事前にメモリ上に作成します。そして発行するIOイベントの情報やIOの結果をそのリングを用いてやり取りすることによって、システムコール時にイベントに関する情報のメモリコピーを行いません。またIOの発行と完了待ちに同じシステムコールを利用するため、発行と完了待ちを1つのシステムコールで行うことも可能です
なおLinux AIOの仕組みを改善するという話はずっと昔からあったようですが、修正が非常に大きくなってしまうことから、最終的には完全に別の仕組みとして実装されたようです。またio_uringは単に上記のAIOの問題点を改善しただけではなく、より高性能なIOを実現するための工夫や、多くの機能追加がなされています (因みにコード量だけで比較するとLinux AIOは約2千行ですが、io_uringは約1万行以上あります)。
これまでの説明と同じくまずはアプリケーションでの使い方から見ていきます。
大まかな使い方
Linux AIOに比べるとio_uringを使用するためにアプリケーションでやることはやや複雑で、以下のような流れになります:
- (IOするファイルは事前にopen(2)する)
- io_uring_setup(2)でkernel/userで共有するメモリ領域(リングバッファ等)を作成する
- 作成された共有メモリ領域をmmap(2)する
- 共有メモリ上にあるSubmission Queue(SQ)に発行するIOイベントのエントリを追加し、SQのtailポインタを更新する
- io_uring_enter(2)で非同期IOの発行や完了待ちを行う
- 共有メモリ上にあるCompletion Queue(CQ)からIO結果を取得し、CQのheadポインタを更新する
- 発行したIOをキャンセルする場合はキャンセルするイベントを別途発行する13
はじめに非同期IOを行うための各種初期化をするio_uring_setup(2)を呼ぶのはio_setup(2)に似ています。これによりio_uringで使用するリングバッファの初期化等が行われます。
次に用意したリングバッファ(を含むメモリ領域)をkernel空間とuser空間で共有するため、その領域をmmap(2)します14。リングバッファは2つあり、一つは発行するIOイベントの情報を格納するため、もう一つは実行したIOの結果を格納するために使用されます。これらのリングをそれぞれSubmission Queue(SQ), Completion Queue(CQ)と呼びます。発行するIOの情報をアプリケーションがSQに追加しkernelが読み出す一方、実行されたIOの結果をkernelがCQに書き、アプリケーションが読み出します。またリングバッファなのでキューに値を追加する側がどこまでデータがあるのかを示すtailポインタを更新し、読み出す側はどこまで読み出したかを示すheadポインタを更新します。
ここでCQ/SQの1つのエントリをそれぞれSubmission Queue Entry(SQE), Completion Queue Entry(CQE)と呼んでいます。これはLinux AIOでいうところのstruct iocb, struct io_eventの情報に対応するものです。v5.10の時点のSQE(struct io_uring_sqe)の定義は以下のようになっています:
17 struct io_uring_sqe {
18 __u8 opcode; /* type of operation for this sqe */
19 __u8 flags; /* IOSQE_ flags */
20 __u16 ioprio; /* ioprio for the request */
21 __s32 fd; /* file descriptor to do IO on */
22 union {
23 __u64 off; /* offset into file */
24 __u64 addr2;
25 };
26 union {
27 __u64 addr; /* pointer to buffer or iovecs */
28 __u64 splice_off_in;
29 };
30 __u32 len; /* buffer size or number of iovecs */
31 union {
32 __kernel_rwf_t rw_flags;
33 __u32 fsync_flags;
34 __u16 poll_events; /* compatibility */
35 __u32 poll32_events; /* word-reversed for BE */
36 __u32 sync_range_flags;
37 __u32 msg_flags;
38 __u32 timeout_flags;
39 __u32 accept_flags;
40 __u32 cancel_flags;
41 __u32 open_flags;
42 __u32 statx_flags;
43 __u32 fadvise_advice;
44 __u32 splice_flags;
45 };
46 __u64 user_data; /* data to be passed back at completion time */
47 union {
48 struct {
49 /* pack this to avoid bogus arm OABI complaints */
50 union {
51 /* index into fixed buffers, if used */
52 __u16 buf_index;
53 /* for grouped buffer selection */
54 __u16 buf_group;
55 } __attribute__((packed));
56 /* personality to use, if used */
57 __u16 personality;
58 __s32 splice_fd_in;
59 };
60 __u64 __pad2[3];
61 };
62 };
unionが多くやや見にくいですが、IOを行うFDやオフセット、IO長、bufferのアドレス等、struct iocbにもあった情報が含まれていることが分かります。よく見るとSQEではIO完了通知を受け取るためのeventfdの設定ができないように思えますが、これはio_uring_register(2)という別のシステムコールで設定することできます(高速化のためなどに補助的に様々な設定を行うシステムコールですが、必須ではないのでここでは省略しています)。
一方でCQE(struct io_uring_cqe)の定義は以下のとおりです:
159 struct io_uring_cqe {
160 __u64 user_data; /* sqe->data submission passed back */
161 __s32 res; /* result code for this event */
162 __u32 flags;
163 };
こちらもLinux AIOで使用されるstruct io_eventに似ていることが分かります(2つのresultを使う用途はなかったようで1つだけになっています。また、現在flagsは使用されていません)。
ここで少し脱線しますが、SQEに含まれるunionの定義からもio_uringで多様なIOオペレーションが使用できることが伺えると思います。実際SQEのopcodeとして以下のオペレーションを指定できます:
100 enum {
101 IORING_OP_NOP,
102 IORING_OP_READV,
103 IORING_OP_WRITEV,
104 IORING_OP_FSYNC,
105 IORING_OP_READ_FIXED,
106 IORING_OP_WRITE_FIXED,
107 IORING_OP_POLL_ADD,
108 IORING_OP_POLL_REMOVE,
109 IORING_OP_SYNC_FILE_RANGE,
110 IORING_OP_SENDMSG,
111 IORING_OP_RECVMSG,
112 IORING_OP_TIMEOUT,
113 IORING_OP_TIMEOUT_REMOVE,
114 IORING_OP_ACCEPT,
115 IORING_OP_ASYNC_CANCEL,
116 IORING_OP_LINK_TIMEOUT,
117 IORING_OP_CONNECT,
118 IORING_OP_FALLOCATE,
119 IORING_OP_OPENAT,
120 IORING_OP_CLOSE,
121 IORING_OP_FILES_UPDATE,
122 IORING_OP_STATX,
123 IORING_OP_READ,
124 IORING_OP_WRITE,
125 IORING_OP_FADVISE,
126 IORING_OP_MADVISE,
127 IORING_OP_SEND,
128 IORING_OP_RECV,
129 IORING_OP_OPENAT2,
130 IORING_OP_EPOLL_CTL,
131 IORING_OP_SPLICE,
132 IORING_OP_PROVIDE_BUFFERS,
133 IORING_OP_REMOVE_BUFFERS,
134 IORING_OP_TEE,
135
136 /* this goes last, obviously */
137 IORING_OP_LAST,
138 };
(例えば発行されたイベントのキャンセルを行うIORING_OP_ASYNC_CANCELのように、IO関連のシステムコールに対応しないオペレーションもあります)
一方でLinux AIOの場合に指定できるオペレーションは以下です:
36 enum {
37 IOCB_CMD_PREAD = 0,
38 IOCB_CMD_PWRITE = 1,
39 IOCB_CMD_FSYNC = 2,
40 IOCB_CMD_FDSYNC = 3,
41 /* 4 was the experimental IOCB_CMD_PREADX */
42 IOCB_CMD_POLL = 5,
43 IOCB_CMD_NOOP = 6,
44 IOCB_CMD_PREADV = 7,
45 IOCB_CMD_PWRITEV = 8,
46 };
Linux AIOの場合はread/write/sync/pollしかなく、その差は歴然です。buffered IOに限らない様々なIOオペレーションがio_uringでは非同期に実行できることが分かります。
話を元に戻すアプリケーションはSQにエントリを追加したあと、データが追加したことを表すためにSQのtailポインタの値を更新します。tailポインタもmmapされる領域に保持されています。そしてio_uring_enter(2)を呼び出してkernelに処理すべきエントリがあることを伝え、IOの発行を行います。io_uring_enter(2)にはイベントを発行する数と完了待ちを行う数の両方が指定できるため、1) IOの発行のみ、2) IOの完了待ちのみ、3) IOの発行と完了待ちの両方を実行、の3つの動作を1つの関数で実現できます。またSQE/CQEのデータはmmap領域に書かれるため、io_submit(2)やio_getevnets(2)のようにシステムコールの際に各IOイベントに関するメモリコピーは発生しません。なおアプリケーションはio_uring_enter(2)で必ず完了待ちを行う必要はなく、CQのtailポインタの値が更新されているかどうかをチェックすることで完了したイベントがあるかを判断することも可能です。
最後にIOが完了した後、その結果はCQに書かれているのでアプリケーションはCQEを読み出し、値の読み出しが完了したことを示すためにCQのheadポインタを更新します。
liburing
io_uringのシステムコールのwrapperおよびライブラリ関数はliburingが提供します。上に書いたように自分で直接システムコールを利用する場合はmmap(2)したりリングのtail/headポインタを更新したり15と色々大変なのですが、そのあたりはliburingのライブラリが適切にやってくれます。liburingを使用して非同期IOを行う場合は以下のような流れになります:
- (IOするファイルは事前にopen(2)する)
- io_uring_queue_init(3)で初期化を実施する (io_uring_setup(2)を呼び出し、mmap(2)を行う)
- io_uring_get_sqe(3)で使用するSQEを取得し、io_uring_prep_readv(3)などで発行するIOイベントの設定をする
- io_uring_submit(3)で非同期IOを発行する (SQのtail pointerを更新し、io_uring_enter(2)を呼び出す)
- io_uring_wait_cqe(3)で非同期IOの完了を待ち、CQEを読み出す (必要に応じてio_uring_enter(2)を呼ぶ)
- io_uring_cqe_seen(3)でCQのhead pointerを更新する
※ これ以外にもライブラリ関数は色々あります
Linux AIOの場合はライブラリ関数で追加の処理をほとんど行っていなかったため、システムコールを直接利用してもあまりアプリケーションの書き方に影響はなかったと思いますが、よほどの理由がない限りio_uringの場合はライブラリ関数を使用するべきでしょう。具体的な使用法についてはliburingのgithubなどを参照してください。
io_uringでのsubmit処理のコード
さてLinux AIOの問題点3が解決されていることはuser/kernelで共有するリングバッファを使うことやio_uring_enter(2)の定義から自明です。では問題点1と2が実際にどのように解決されているのかについて、io_uring_enter(2)を実行したときのreadオペレーションのsubmit処理から見てみます。
io_uringでは設定可能なフラグが色々あり分岐も多いのですが、特に何も設定していない状態でreadを行う場合を考えると以下のような関数が実行されます:
io_uring_enterシステムコール
- io_submit_sqes
- io_submit_sqe
- io_queue_sqe
- io_req_prep
- io_read_prep
- io_prep_rw ... ki_completeにio_complete_rwを設定
- __io_queue_sqe
- io_issue_sqe
- io_read ... 非同期IOを試みるが、処理がブロックすると判断した場合は-EAGINを返す
- io_iter_do_read
- call_read_iter
- f_op->read_iter
- io_queue_async_work ... (-EAGAINが帰ってきた場合) バックグラウンド処理でIOを実行
ざっくりというとio_submit(2)と同じくio_uring_enter(2)の延長で(ki_completeをセットした上でf_op->read_iterを呼び出して)デバイスへのIOリクエスト発行を試みるのですが、途中で処理がブロックする可能性があると判断した場合はIOを行わず、実際の処理をバックグラウンドで行うようにします。
いくつかポイントを見てきます。上記の流れでio_read()が呼ばれるとき、引数のforce_nonblockにtrueがセットされているのですが、このときki_flagsにIOCB_NOWAITフラグが自動でセットされます(L.3398)。また続くL.3405においてファイルシステムやブロックレイヤーがNOWAITフラグに対応しているかどうかもチェックしています:
3375 static int io_read(struct io_kiocb *req, bool force_nonblock,
3376 struct io_comp_state *cs)
3377 {
...
/* io_submit(2)の延長で呼ばれるときはforce_nonblockがtrue */
3398 if (!force_nonblock)
3399 kiocb->ki_flags &= ~IOCB_NOWAIT;
3400 else
3401 kiocb->ki_flags |= IOCB_NOWAIT;
3402
3403
3404 /* If the file doesn't support async, just async punt */
3405 no_async = force_nonblock && !io_file_supports_async(req->file, READ);
/* NOWAITの挙動が保証できない場合はこの時点でIOを実行しない (後述) */
3406 if (no_async)
3407 goto copy_iov;
...
2732 static bool io_file_supports_async(struct file *file, int rw)
2733 {
...
/* 通常ファイルの場合 */
2743 if (S_ISREG(mode)) {
/* ブロックレイヤーがNOWAITに対応しているか確認 (blk-mq等は対応している) */
2744 if (io_bdev_nowait(file->f_inode->i_sb->s_bdev) &&
2745 file->f_op != &io_uring_fops)
2746 return true;
2747 return false;
2748 }
...
/* ファイル(システム)がNOWAITに対応しているか確認 (ext4/xfs/btrfs等は対応している) */
2754 if (!(file->f_mode & FMODE_NOWAIT))
2755 return false;
NOWAITフラグについてはLinux AIOの節で説明したとおり、このフラグが設定されている場合、IO発行処理の途中でブロックする必要があるときは呼び出し元に-EAGAINが返ります。因みにbuffered readのパスではページキャッシュ上にデータがない場合などに-EAGAINが返ります。
ここまでで問題がなければブロックはしないことが保証されているので非同期IOを試みます(L.3413):
io_read
/* read処理を実行 (f_op->read_iterが呼ばれる) */
3413 ret = io_iter_do_read(req, iter);
3414
3415 if (!ret) {
/* readサイズが0だった場合 (io_iter_do_readの戻り値は0以上のときreadしたバイト数) */
3416 goto done;
3417 } else if (ret == -EIOCBQUEUED) {
/* デバイスへの非同期IOの発行が正常完了した場合 */
3418 ret = 0;
3419 goto out_free;
3420 } else if (ret == -EAGAIN) {
...
/* 非同期IOの発行処理でブロックする必要があった場合 */
3430 goto copy_iov;
3431 } else if (ret < 0) {
/* その他のエラーの場合 (そのままシステムコールのエラーとして返す) */
3432 /* make sure -ERESTARTSYS -> -EINTR is done */
3433 goto done;
3434 }
3435
/* buffered readで全てページキャッシュにあった場合など */
3436 /* read it all, or we did blocking attempt. no retry. */
3437 if (!iov_iter_count(iter) || !force_nonblock ||
3438 (req->file->f_flags & O_NONBLOCK))
3439 goto done;
-EIOCBQUEUEDが返ってきた場合はエラーではなく正常に非同期IOの発行が完了したということなので処理を終了します。-EAGAINが返ってきた場合はブロックする可能性のあるため処理ができなかったということなので、copy_iovにgotoします。またbuffered readで全てページキャッシュにデータがある場合はこの時点でreadが完了し、iov_iter_count(iter)が0になっているので処理を終了します(-EIOCBQUEUEDのときと違い、このときはIOが完了しているのでki_completeのコールバックを同期的に実行します)。
非同期IOが実行できなかったとき、copy_iovラベル以下ではIO発行に必要な情報(iovec)をコピーした後、-EAGINを返します (ただしリトライできると判断した場合はリトライします16):
io_read
3442 copy_iov:
/* IO発行に必要なiovecの情報をコピー */
3443 ret2 = io_setup_async_rw(req, iovec, inline_vecs, iter, true);
3444 if (ret2) {
3445 ret = ret2;
3446 goto out_free;
3447 }
/* 下位レイヤーがNOWAITをサポートしていない場合や、
リトライすべきでないと判断した場合は-EAGAINをリターン */
3448 if (no_async)
3449 return -EAGAIN;
...
3458 if (!io_rw_should_retry(req)) {
3459 kiocb->ki_flags &= ~IOCB_WAITQ;
3460 return -EAGAIN;
3461 }
...
/* リトライできる場合は再びio_iter_do_read()を実行 */
そして呼び出し元の__io_issue_sqeでは-EAGAINが帰ってきた場合、そのリクエストをバックグラウンドで実行するようにします(L.6245):
__io_issue_sqe
6233 ret = io_issue_sqe(req, true, cs);
6234
6235 /*
6236 * We async punt it if the file wasn't marked NOWAIT, or if the file
6237 * doesn't support non-blocking read/write attempts
6238 */
6239 if (ret == -EAGAIN && !(req->flags & REQ_F_NOWAIT)) {
/* f_op->pollできる場合はそちらを利用(通常ファイルは不可) */
6240 if (!io_arm_poll_handler(req)) {
6241 /*
6242 * Queued up for async execution, worker will release
6243 * submit reference when the iocb is actually submitted.
6244 */
/* バックグラウンドスレッドでIOを実行するようにキューに追加 */
6245 io_queue_async_work(req);
6246 }
io_queue_async_work() ⇨ __io_queue_async_work() ⇨ io_wq_enqueue()によりキューに追加されます (このあたりの処理はio-wq.cという別のファイルにあります)。
ということでreadの処理に関してio_submit(2)と比較すると、
- システムコール(io_uring_enter(2))の延長でki_completeにコールバック関数をセットしてf_op->read_iterを呼び出すのは同じだが、このときIOCB_NOWAITフラグを自動でセットする 17
- ただしそもそもファイルシステム等がNOWAITに対応していない可能性があるため、NOWAITに対応しているかどうかをチェックし、対応していなければf_op->read_iterは呼び出さない
- NOWAITに対応している場合はf_op->read_iterを呼び出して非同期IOの発行処理を試みる
- データが全て読めたり(buffered readで全てページキャッシュにあった場合)、非同期IOの発行が正常に完了した場合はそのまま終了する
- 一方、f_op->read_iterを実行しなかったり、実行したものの-EAGAINが返ってきた場合、リクエスト情報のコピーを作成し、実際のIO処理をバックグラウンドに回す
という違いがあり、buffered readで(デバイスからの読み出しが必要な場合)も非同期になることや下位レイヤーのサポート状況によらず処理がブロックしないことが分かります。通常ファイル以外のreadや他のIOオペレーションについても同様で、ブロックせずに実行可能なら実行し、そうでないならバックグラウンドのスレッドで実行するようにします (処理がバックグラウンドに回るということはその分スレッドの作成等が行われリソースが消費されるので、io_read()でのリトライ処理からも分かるように可能な限りバックグラウンドに回さないようにしています)。
なおIOがバックグラウンドに回された場合もIO発行に必要な情報はコピーされているため、io_uring_enter(2)の終了後アプリケーションはSQEを再利用することができます。
補足: その他のio_uringの機能
io_uringに関してはまだ説明していないことが色々あります。例えばkernel side pollingというモードがあるのですが、このモードではSQEにエントリが追加されたかどうかをkernel threadが監視を行うため、アプリケーションがio_uring_enter(2)を呼ばなくても非同期IOが行われるようになります (アプリケーションはIO完了待ちのためにもio_uring_enter(2)を呼ぶ必要はないので、つまり初期化後はシステムコールを使用せずにIOができるようになります)。
興味のある人はliburingのgithub等を参照してください。
おまけ: FIOベンチマークの比較
IO性能のベンチマークといえばFIOがありますが、実は今回説明した非同期IOにすべて対応しています。ということで同時発行するIO数を変えたときの4k random read/writeの性能を簡単に手元のマシンで測りました。あくまで普通のデスクトップマシンなので結果はおまけとして見てください。
環境
- ハード: CPU ... intel i5-8400(6コア), メモリ ... 16GB (DDR4-2133), デバイス ... PX-256M9PeG (NVMe gen3x4, 最大IOPS: rand read 180k, rand write 160k)
- ソフト: OS ... OpenSUSE Tumbleweed (kernel 5.9.8), FS ... XFS, fio ... v3.23
fioのコンフィグは以下です:
; ioengine=posixaio, libaio, io_uring
ioengine=posixaio
; buffered=0 (direct io) or 1 (buffered io)
buffered=0
; rw=randread or randwrite
rw=randread
; iodepth=1,2,4,8,16,32,64 (同時実行するIO数の上限)
iodepth=1
bs=4k
size=512m
directory=.
numjobs=1
1つの512Mファイルに対してrandom read(or random write)します。3つの非同期IOについて、buffered IOとdirect IOの両方を試しました。
結果
4k rand read
random readの結果は綺麗に出ました。見にくいですがiouring-direct, iouring-buffered, libaio-directの3つのグループとそれ以外のグループに分かれています。なお参考としてioengine=psyncで同期的にIOをした結果をpsync-direct/psync-bufferedとして載せています(同期IOなのでiodepthは無関係です)。またキャッシュは毎回クリアしているのでbuffered/direct IOに関係なくreadするデータは全てデバイスから読み出しています。
所見:
- posix aioはbuffered/directに関わらずほぼ同期IO(psync)の結果と同じ (一応iodepthが大きくなると1kOPSくらいは良くなっています。流石に性能がでなさすぎなのではないかと思いますが、Posix AIOのコードをざっと見たところでは同じファイルに対しては同じIOスレッドが使われるようなのでそれが原因かもしれません)
- libaio-directとiouring-directは概ね同じ結果で、iodepthが高くなるとIOPSも向上する (若干io-uringのほうが良い)
- libaioではbuffered IOは同期的に処理されるので(今回はページキャッシュにデータがなくデバイスにreadしているので)、libaio-bufferedの結果はpsyncとほぼ同じ
- 一方iouringではbuffered IOも正しく非同期に発行できるので、iouring-bufferedもiouring-directなどと似た結果
- またiodepth=64におけるlibaio-direct, iouring-direct, iouring-buffered実行時のシステムcpu使用率の結果は42%, 37%, 69%であり、iouring-bufferedではバックグラウンドのIO処理がcpu使用率に影響していると思われる
4k rand write
一方でrandom writeはやや掴みにくい結果です。なおグラフの上から4つの結果はbuffered IOをしているのでメモリ(ページキャッシュ)にwriteしています。
所見:
- buffered IOの結果を見ると、libaio-bufferedは同期的に処理されるためiodepthに結果がよらないが、iodepth=1のときは他の非同期IOより性能が良い(ただし同期write(psync-buffered)よりは低い)。iodepthが大きくなるにつれて他の非同期IOの方が性能が良くなり、iodepthが4以上ではio-uringの結果が他よりも良い
- direct IOの結果を見ると、posixaioはreadのときと同じくiodepthを増やしてもあまり性能は変わらない (こちらも一応1kOPS程は良くなっています)
- iouring-directとlibaio-directの結果はiodepthを増やすと性能が向上するが、途中で性能が低下するなどブレがあるので、SDのキャッシュの影響を受けていそう
※ io_uringは高IOPSであることも目的にしているので、フラグやio_uring_register(2)で各種オプションを使用したり、良いサーバーと高速なデバイスで測定したりすればより違いが分かるのではないかと思います。
おわりに
Posix AIO, Linux AIO, io_uringのそれぞれの使い方や実装を見てきました。もともとはio_uringの紹介資料に書いてあったLinux AIOの欠点やio_uringでは処理がブロックしないという記述について、コード上ではどうなっているのか確かめてみようという気持ちで調べ始めたのですが、無事に目的を達成できたかなと思います。やはりIOは奥が深くて面白いですね。最近この辺りはあまり見れていなかったのですが、io_uringは今でも活発に機能改善・追加が行われているので引き続き注目していきたいです。
-
細かい話ですがpread64/pwrite64のglibcのwrapperはpread/pwriteです ↩
-
1つのIO処理について、Posix AIOのman pageでは"request", Linux AIOのman pageでは"event"という用語が使用されていますが、同じものと考えて良いです ↩
-
ただし通常ファイルに対するread/writeではデバイスへのリクエストが発行された時点で完了までUNINTERRUPTIBLEな状態になるため、実質キャンセル不可能です ↩
-
struct iocbの配列ではなくポインタ配列なのは、struct iocbを別の構造体に埋め込んで管理できるようにするためだと思います (逆に結果は特に埋め込んで管理する必要がないので、io_getevents(2)ではstruct io_eventの配列を受け取ります) ↩
-
Posix AIOでは実はstruct aiocbの中に結果を保持しており、aio_return(3)等ではその値を返しているので、発行したstruct aiocbにより識別できます ↩
-
liburingのgithubのREADMEに記載されている以下の資料です: https://kernel.dk/io_uring.pdf ↩
-
buffered IOの場合も一度のシステムコールで多数のIOイベントを発行することができるという観点ではLinux AIOを使用する利点があると言えます ↩
-
さらに言うとreadに関してはposix_fadvise(2)にPOSIX_FADV_WILLNEEDフラグをセットすることでprefetchを行い、事前にページキャッシュにデータを読み込ませることはできますが、readする段階で必ずデータがページキャッシュにある保証はありません (例えばメモリプレッシャー時、その時点で使用されていないページキャッシュはreclaimの対象になります) ↩
-
iomapを使用していない従来のファイルシステムではdirect IOに主にfs/direct-io.cのコードが使用されていますが、iomapの場合はiomap/direct-io.cのコードが使用されます ↩
-
フラグ名はサブシステム間で変換されるので名称が異なります ↩
-
逆に言うとext4/xfs/btrfs等のコードからIOCB_NOWAITを参照している部分を探すとブロックする可能性のある処理がなにか分かります ↩
-
最近はCPU脆弱性対応のためコンテキストスイッチ時にCPUキャッシュがフラッシュされるようになっているので、なおさらシステムコールの回数は減らしたいです ↩
-
Linux AIOのio_cancel(2)と同様に通常ファイルではread/wrie処理が発行されてしまうとキャンセル不可能ですが、io_uringではバックグラウンドにIO処理が回される場合があり、未発行のread/writeリクエストはキャンセル可能です ↩
-
io_uring_setup(2)が正常終了すると戻り値としてio_uringインスタンスを表すFDが返され、このFDに対してmmapを行います。またmmapのサイズ等の情報についてはio_uring_setup(2)でやり取りされるstruct io_uring_paramsから計算できます。 ↩
-
詳細は省きましたがtail/headポインタを更新する時点でSQEへの書き込み/CQEの読み出しが完了していなければならないため、アプリケーションはmemory orderingを意識する必要があります ↩
-
新しくIOCB_WAITQというフラグが追加されており、buffered readの場合はこのフラグをセットしてリトライを試みます。このときreadするページがUptoDateでない場合(デバイスからの読み出しが完了していない場合)、waitqueueに入り-EIOCBQUEUEDを返します。pageがunlockされるタイミング(通常ページキャッシュにデータが読み込まれたタイミング)でキューから起こされるので、そのとき処理を再実行します。これによりバックグラウンドで同期readを行う場合よりも効率が向上します ↩
-
ややこしいですがそもそものIOリクエストにNOWAITフラグが設定されていていた場合、IO処理で-EAGAINが返ってくるとバックグラウンドに処理を回さずエラーを返すようにしています ↩