バッファキャッシュとAIO(2)

jirou senju
2010/08/10 16:56
/community/blog/images/union/pg_high_logo.png

プロセスがブロックする要因の一つにファイルI/Oがあります。これを同期I/Oと言いますが、POSIXではAIO(非同期 I/O、Asynchronous I/O)も定義しており、I/O中でもプロセスがブロックせず他の処理を進められるようになります。 今回は、バッファキャッシュを意識したさまざまなファイルI/Oについて解説します。

メモリマップ I/O

ファイルI/Oの一種にメモリマップI/O、mmap(2)があります。mmap(2)(およびmmap2(2))はオープンされたファイルをプロセスアドレス空間へマッピングするもので、例えばアプリケーション内に領域を用意し、ファイルを読み取る動作は次のようにも実装できます。

3mmap.c 要約

{
    char a[N];

    fd = open(path, O_RDONLY);
    read(fd, a, N)
    printf("%.*s\n", N, a);
}

{
    char *p;

    fd = open(path, O_RDONLY);
    p = mmap(NULL, sysconf(_SC_PAGESIZE), PROT_READ, MAP_SHARED, fd, 0);
    printf("%.*s\n", N, p);
}

上記2つの関数は同じ内容を表示します。read(2)を用いる場合は、通常静的に宣言した文字配列やmalloc(3)により割り当てたメモリ領域を事前に用意しますが、mmap(2)を用いると用意した領域がそのままファイルの内容になります。このためメモリ間のコピー(上の例ではバッファキャッシュからアプリケーション内の配列aへのコピー)を削減できます。対象ファイルがまだ読み取られておらず、バッファキャッシュに存在しない場合は、mmap(2)が返したメモリ領域を参照すると、システム側でディクスから自動的に読み取ります。

上の例では読み取りしか行っていませんが、書き込みも同様に可能です。open(2)mmap(2)のパラメータを書き込み用に変え、得られたメモリ領域内を変更すると、ファイル書き込みと同等の動作となります。この場合、メモリ領域がディスクへ書き戻す必要があることを通知するのはmsync(2)です。MS_ASYNCを指定すると、通知するだけでシステムコールは終了します。MS_SYNCを指定すると、通知に加え、内部でfsync(2)相当の処理も行われ、ディスクにまで書き戻します。

mmap(2)したメモリ領域は、アプリケーション終了時に自動的にmunmap(2)され、またmunmap(2)msync(2)を包含するため、mmap(2)したメモリ領域を更新後にアプリケーションが終了するならば、明示的なmsync(2)は省略可能です。しかし、メモリ更新からmunmap(2)まで時間があく可能性がある、またはすぐにデータをディスクにまで保存したいなどの場合にはmsync(2)を発行します。mmap(2)のパラメータにMAP_SHAREDを指定すると、アドレスこそ変換されますがこの領域の参照はシステムが管理するバッファキャッシュの直接参照に相当します。

さらに、ファイルポジションを移動するlseek(2)もユーザ空間だけで解決するアドレス演算で代替でき(上の例ではp+1すればファイル内の2バイト目が参照できる)、発行システムコール数を削減できます。

mmap(2)は効率的なI/Oに大きく貢献しますが、若干の注意点があります。

  • システムのメモリ管理に近付くという性質上、パラメータのaddresslengthoffsetはメモリページサイズの倍数でなければならない(lengthにアラインメントを必要としないシステムもある)。
  • ファイルサイズが小さい場合はプロセスアドレス空間が無駄になる。例えばページサイズが4,096バイトの場合にサイズが1バイトしかないファイルをmmap(2)すると、内容があるのは1バイトだけであり、残り4,095バイトはゼロで埋められ、プロセスアドレス空間を無駄に消費する。
  • 現代では32ビットアドレス空間は不足する場合もある。 上述の無駄に加え、フラグメンテーションが発生する恐れもあり、大きな空き領域が必要な場合に連続した領域が得られなくなることが考えられる。

このため、一般的にはある程度のサイズ(少なくともメモリページサイズ以上)を持つファイルでなければ、mmap(2)の効果は薄れると考えられます。やはりLinux固有のシステムコールですが、remap_file_pages(2)というのもあり、同じファイルに対しmmap(2)を複数回発行する場合、2回目以降はremap_file_pages(2)を用いた方が効率向上が期待できます。mmap(2)を発行すると、システム内部ではメモリ管理用の構造体を新たに作成しますが、remap_file_pages(2)を用いると既存の内部構造体を用いるためです。

sendfile(2)pipe(2)、その他のバッファ操作

ディスク上のファイルデータをソケットへ書き込む際にwrite(2)/send(2)する方法がありますが、この場合はsendfile(2)を用いた方が効率的です。バッファコピー削減に加え、mmap(2)によるプロセスアドレス空間の消費も削減できる、バッファキャッシュからソケットバッファへのカーネル内メモリコピー(実際にはコピーせずメモリページを管理する構造体の参照カウント、参照先を操作することで高速化を図れる)処理です。ファイルの内容に関知せずソケットに渡すだけの場合に効果的です。sendfile(2)は以前の記事「インターネットサーバでのPthreadとepoll」でも取り上げたため、サンプルコードはそちらを参照してください。

インターネットサーバでのPthreadとepoll

pipe(2)

元来Unixではさまざまなリソースがファイルディスクリプタに対応付けられますが、pipe(2)もその一つで、2つのファイルディスクリプタが対応付けられます。内部ではパイプバッファという専用のメモリが確保され、2つのファイルディスクリプタを用い、パイプバッファを読み書きします。パイプバッファはディスク上の通常ファイルに対応するバッファキャッシュとは厳密には異なりますが、ユーザプログラムからは通常のファイルディスクリプタにしか見えませんので、違いを意識する必要はあまりありません。しかし、通常ファイルとは異なる、次の動作がプログラマを悩ませることがあります。

  • 読み取れるデータが存在しなければ、パイプからの読み取りは(通常)ブロックする。
  • パイプバッファが一杯ならば、パイプへの書き込みは(通常)ブロックする。

例えば次のようなコードにはバグがあります。これは以前にあるPerlライブラリに実在したもので、実際にはCではなくPerlで記述されていました。

パイプ処理のアプリケーションバグ

{
    int fds[2];
    pid_t pid;

    pipe(fds);
    pid = fork();
    if (!pid) {
        /* 子プロセス */
        write(fds[1], ...);
        return 0;
    } else if (pid > 0) {
        /* 親プロセス */
        wait(NULL);
        read(fds[0], ...);
    }
}

子プロセスが書き出した内容を親プロセスが読み取るというよくある動作ですが、このコードでは子プロセスがパイプバッファが一杯になるまで書き込むと、両プロセスともブロックしてしまいます。その原因は前述のパイプの動作を読み返すと分かります。パイプバッファが一杯になると子プロセスはそれ以上write(2)できなくなり、パイプバッファに空きが出るのを待ちます。一方親プロセスは子プロセスの終了を待つため、どちらも処理を進められなくなります。このバグは親プロセスがパイプからread(2)する先にwait(2)しているのが(ひとつの)原因なので、まずread(2)するようにすれば期待通りに動作するでしょう。もちろん一度のread(2)で書き込まれたデータをすべて読み取れる保証がなければ、複数回read(2)することになるでしょう。その他の対策として、fcntl(2)を用いO_NONBLOCKフラグを設定するノンブロッキングI/Oという方法も考えられます。

さらに、Linuxではバッファコピーを意識したシステムコールを追加しました。比較的地味で使用する場面も多くないと思いますが、簡単に紹介しておきます。

splice(2)

2つのファイルディスクリプタを指定し、対応するバッファをカーネル内でコピーする。ユーザ空間を経由しない。効率的なファイルコピーに使えるが、現在のインタフェースではファイルディスクリプタのうち一つはパイプでなければならないとされている。

Linux2.6.23(平成19年10月)ではsendfile(2)の実装にsplice(2)のコアを用いるように変更された。

以前の記事「UnionMountとUnion-type Filesystem」で紹介したUnionMountではカーネル内でsplice(2)のコアを用い、パイプを用いず通常ファイルをコピーしている(カーネル内からは一つはパイプという仕様を回避できる)。ただしsparseファイルには対応しておらず、まるごとコピーになってしまう。

tee(2)
splice(2)同様にカーネル内でメモリコピーするが、ファイルディスクリプタは2つともパイプでなければならない。
vmsplice(2)
splice(2) と同等の機能だが、パイプのファイルディスクリプタを1つとユーザ空間を指すstruct iovecを渡し、ユーザ空間をパイプへマッピングする。

UnionMountとUnion-type Filesystem

splice(2)tee(2)を用いたサンプルコードはtee(2)のマニュアルページに記述されています(man-pages v2.41以降)。

以前のvmsplice(2)にはセキュリティ問題がありました。struct iovecを用いたユーザ空間のマッピングという動作に由来する問題で、struct iovecの値を処理した後にアドレスの有効性をしなかったため、任意のユーザコードをルート権限で実行可能にできてしまうというものでした。実際の被害などは報告されませんでしたが、2.6.25、2.6.24.2で修正されました。

上記3つのシステムコールはいずれもパイプに対するI/Oという仮面を被っていますが、システム内部バッファ(ユーザ空間ではないバッファ)を利用することを目的としたものです。また、「コピー」ではなく「移動」する場合にはSPLICE_F_MOVEフラグを指定できます。しかし、マニュアルにはそう記述されていても、実はLinux 2.6.21でこのフラグは削除されています。約三年前です。復活しないということは必要性が低いということでしょう。

平成22年5月にリリースされたLinux 2.6.34では、パイプバッファのサイズを取得/変更するインタフェース、fcntl(F_GETPIPE_SZ)fcntl(F_SETPIPE_SZ)が追加実装されました。

上記ではメモリコピーと記述しましたが、内部で実際にコピーされるとは限りません。システムが管理するメモリページの参照カウントを増やし、データを共有する方式が採られます。

Direct I/O

Direct I/Oはバッファキャッシュを介さないファイルI/Oで、ブロックデバイスと直接データをやりとりします。詳細は後述しますが、LinuxネイティブなAIOシステムコールio_submit(2)では、Direct I/Oが指定されなければ、非同期動作にはなりません。

Direct I/Oは二重バッファリングを無駄とみなす考えから、システムレベルのバッファリングを省き、アプリケーションレベルのバッファリングと併用されることがあります。バッファリングを一切せずにDirect I/Oを使用しても単に性能を落とすだけです。同じファイルに通常のI/O(buffered I/O)やmmap(2)によるメモリマップI/Oと混在させても性能低下が考えられます。

Direct I/Oによるファイル書き込みは、まずバッファキャッシュがあれば、これをディスクへ書き戻し、そのバッファキャッシュを無効(invalid)にします。その後本来要求されたデータをディスクへ書き込み、この際に使用したバッファキャッシュも再び無効にします(図6)。同様にファイル読み取りも、まずバッファキャッシュをディスクへ書き戻し(有効なバッファキャッシュがあれば)、その後ディスクから直接読み取ります。

図6 Direct I/Oの動作

fig/aio-fig06.png

mmap(2)でも必要とされたアラインメントはDirect I/Oでも必須となり、特に(よくbufferと名付けられる)アプリケーション内作業領域のアドレスにもアラインメントが要求されます。これにはposix_memalign(3)などを用いると良いでしょう。現在のLinuxでは512バイトでアラインメントします。簡単な例を挙げておきます。

4dio.c 要約

{
    char *p;

    fd = open(path, O_RDWR | O_DIRECT);
    posix_memalign((void *)&p, 512, BUFSIZ);
    memset(p, 'a', BUFSIZ);
    write(fd, p, BUFSIZ);
}

一般に、Direct I/Oはデータベースなど専用の形式を持つファイルへのI/Oなどに有効と言われています。データベースソフトウェア(形態は単独アプリケーションかもしれませんし、専用ライブラリかもしれません)が自身でバッファリングを実装し、ディスクへ書き出す内容、順序も充分に意識すれば、電源断など不意の事故が発生しても、ディスク上での整合性を完全に、もしくは容易に修復可能な程度に維持できるでしょう。この場合一度のシステムコールで複数のアプリケーションバッファをI/Oするreadv(2)writev(2)の利用が考えられます。しかし、引数のstruct iovecではファイル内オフセットを指定できませんので、pread(2)pwrite(2)を繰り返すことになるかもしれません。Linux 2.6.30以降では、両システムコールを合体させたpreadv(2)pwritev(2)が追加されたので、こちらの方が役に立つかもしれません。後述するio_submit(2)に渡すstruct iocbでも、オフセット指定可能です。

また、ブロックデバイスがハードディスクではなく、近年普及しているSSD、フラッシュメモリの場合にも有効と考えられます。ブロックデバイスがメモリの場合には、バッファキャッシュの有効性がずっと下がるため、使用しなければメモリ節約につながりますし、バッファキャッシュの管理コストを削減できれば、速度面での向上もさらに期待できます。

同様の目的をもった機能は、実行形式のファイルに対しても実装されています。通常コマンドを実行する際には、実行形式のファイルをメモリ上にロードしますが、このときブロックデバイスがすでにメモリならばロードを省略しようという機能で、XIP(execute in place)と呼ばれます。ただしXIP対応のデバイス、ファイルシステムが必要になります。Linuxのtmpfsでは対応していませんが(というかバックエンドとなるブロックデバイスを持たない)、ramdiskでは対応しています(CONFIG_BLK_DEV_XIPを有効にする必要がある)。繰り返しになりますが、自身でバッファリングしないアプリケーションがディスク上のファイルに対してDirect I/Oを使用しても、通常は遅くなるだけでメリットがない点に注意してください。

その他のI/O

ディスク上の通常ファイル以外に対する使用が主ですが、上記のI/O方式以外にも、I/Oが可能になったことをシグナルで通知するシグナルドリブンI/O、データが存在しないなどの理由でI/Oをブロックするような場合でもブロックさせないノンブロッキングI/Oがありますが、本記事では割愛します。

さて、ここまでファイル I/O全般を簡単に振り返りました。思ったよりも長くなってしまいましたが、次回はいよいよ本題のAIOに取り掛かります。

(初出: H22/4)

Copyright © 2010 SENJU Jiro

本記事のサンプルコードは、以下のリンクよりダウンロードすることができます。記事と合わせてご参照ください。
[サンプルコード]
また、本稿で取り上げた内容は、記事の執筆時の情報に基づいており、その後の実装の進展、変更等により、現在の情報とは異なる場合があります。 本連載「プログラマーズ・ハイ」では読者からのご意見、ご感想を切望しています。今後取り上げるテーマなどについてもご意見をお寄せください。

Bookfair

O'Reilly Japanのセレクションフェア、全国の書店さんで開催
[ブックフェアのページへ]

Feedback

皆さんのご意見をお聞かせください。ご購入いただいた書籍やオライリー・ジャパンへのご感想やご意見、ご提案などをお聞かせください。より良い書籍づくりやサービス改良のための参考にさせていただきます。
[feedbackページへ]