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

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

プロセスがブロックする要因の一つにファイルI/Oがあります。これを同期I/Oと言いますが、POSIXではAIO(非同期 I/O、Asynchronous I/O)も定義しており、I/O中でもプロセスがブロックせず他の処理を進められるようになります。 本記事ではバッファキャッシュからファイル I/Oを解説し、Linuxのio_submit(2)を用いたPOSIX準拠のAIOライブラリを試作してみます。

ファイルI/Oとバッファキャッシュ

io_submit(2)ではDirect I/Oを用いますが、ライブラリの試作へ進む前にまずファイルI/Oのバッファ(バッファキャッシュ)について整理します。実は単にバッファと言ってしまうと誤解される場面が多くあり、例えばプログラミング入門一般としてファイルI/Oを取り上げる際には、

  • CPUの動作は速い。ディスクの動作は遅い。
  • 両者の間に速度差を緩和する緩衝地帯を設けないと、CPUの速度が犠牲にされてしまう。
  • このため、ファイルバッファを用いる。

といった説明があり、よく以下のようなサンプルコードが提示されます。

ファイルI/Oサンプルコード

main()
{
    # define SZ 16
    int fd;
    char buffer[SZ];

    fd = open(argv[1], O_WRONLY | O_CREAT | O_TRUNC, 0644);

    memset(buffer, 'a', SZ);
    write(fd, buffer, SZ);
}

先ほどの説明の後でbufferという名前の配列を見ると、これがCPUとディスクの速度差を緩和する緩衝体(バッファ)の役割をするもなのかと思ってしまいそうです。しかし現代の一般的な環境では、このbufferにはあまりそのような意味合いはなく、単なる作業用のメモリ領域に過ぎません。細部を説明する前に、あと2つサンプルコードを挙げます。以降のサンプルコードは付属のソースファイル一式に含まれているので、詳細はそちらを参照してください。

fread(3)が正しくない?

まずは以下のコードを見て下さい。

1rw.c(要約)
# ifndef PhBufferSize
# define PhBufferSize 16
# endif

main()
{
    struct {
        int fd;
        char buffer[PhBufferSize];
    } a[2];

    a[0].fd = open(argv[1], O_WRONLY | O_CREAT | O_TRUNC, 0644);
    a[1].fd = open(argv[1], O_RDONLY);

    memset(a[0].buffer, 'a', PhBufferSize);
    write(a[0].fd, a[0].buffer, PhBufferSize);
    ssz = read(a[1].fd, a[1].buffer, PhBufferSize);
    p = "equivalent";
    if (memcmp(a[0].buffer, a[1].buffer, PhBufferSize))
        p = "different";
    printf("PhBufferSize %d, %zd bytes read, %s\n",
        PhBufferSize, ssz, p);
}

2frw.c要約

# ifndef PhBufferSize
# define PhBufferSize 16

# endif

main()
{
    struct {
        FILE *fp;
        char buffer[PhBufferSize];
    } a[2];

    a[0].fp = fopen(argv[1], "w+");
    a[1].fp = fopen(argv[1], "r");

    memset(a[0].buffer, 'a', PhBufferSize );
    fwrite(a[0].buffer, sizeof(*a[0].buffer), PhBufferSize, a[0].fp);
    sz = fread(a[1].buffer, sizeof(*a[1].buffer), PhBufferSize, a[1].fp);
    p = "equivalent";
    if (memcmp(a[0].buffer, a[1].buffer, PhBufferSize))
        p = "different";
    printf("PhBufferSize %d, %zu bytes read, %s\n",
        PhBufferSize, sz, p);
}

1rw.cも2frw.cも次のような単純な内容です。

  • 指定されたファイルを書き込み用にオープンする。
  • 同じファイルを読み取り用にオープンする。
  • ファイルへ書き込む。
  • ファイルから読み取る。
  • 読み取った内容が書き込んだ内容と一致するかを確認する。

しかしこの2つは、ファイルの扱い方が異なります。1rw.cはシステムコールを用いたファイルディスクリプタを使用し、2frw.cではstdioを用いたFILEポインタです。単純にopen(2)fopen(3)へ、write(2)fwrite(3)へ、read(2)fread(3)へ、それぞれ対応させ書き換えただけとも言えます。

こんな単純なソースコードはコンパイル、実行するまでもなく、結果は一致するに決まっていると思われるかもしれませんが、実際には異なる結果になります。

1rwと2frwの実行結果

$ ./1rw /tmp/test
PhBufferSize 16, 16 bytes read, equivalent

$ ./2frw /tmp/test

PhBufferSize 16, 0 bytes read, different

2frwの実行結果が上例の1rwと同じになると予測していた方には意外かもしれません。しかし、これは嘘でもバグでもなくfread(3)は何も読み取れません。一般的なシステムでは、まず間違いなくこのような結果になります。

stdioのFILEが持つ内部バッファ

1rwと2frwの実行結果の違いはどこから来るのでしょうか。fwrite(3)が期待通りにファイルへ書き込んでくれない、もしくはfread(3) が期待通りに読み取ってくれないのでしょうか。実はこの挙動の違いはstdioのFILE *が持つ内部バッファの存在がための動作で、仕様通りなのです。

FILEは通常fopen(3)によって返され、ユーザプログラム(アプリケーション)はFILEを用いファイルI/Oを行います。ではFILEとは何でしょうか? おっと、GNU Libcはその内容をユーザプログラムには隠蔽しており、単に構造体ポインタであるという以上のことはよく分かりません。マニュアルにもFILEを引数とするライブラリ関数はいくつも記述されていますが、FILE構造体の定義は記述されていません。えぇ。定義の詳細は知らなくとも良いのです。しかし、機能はしっかり把握する必要があります。

FILEは、ファイルディスクリプタを用いたファイルI/Oに対し、高水準I/O、ストリームI/Oなどとも呼ばれており、printf(3)に代表される強力なフォーマットI/Oインタフェースは広く使用されています。本記事で注目するのは「FILE構造体は内部にバッファを持つ」という機能です。操作ライブラリとしてのsetvbuf(3)が重要と言う意味ではなく、内部バッファの存在が上記のような動作の違いを生んでいるのです。

例えば、fopen(3)したファイルへ1から100までの数字を(可読文字列として)書き出すとします。1行に数字1つです。サンプルコードを提示するまでもなくfprintf(3)をforループでまわすコードが一般的でしょう。この時fprintf(3)FILEが持つ内部バッファへ文字列を付け足すように振る舞います。つまり"1\n""1\n2\n"、と成長していき、最終的に"1\n2\n3\n...100\n"という文字列が内部バッファに作成されます。この時点では書き出す内容は内部バッファに留まり、実際のファイルへは書き出されていません(図1)。

fwrite(3)fprintf(3)は毎回write(2)を発行するわけではなく、この場合でも、この時点ではwrite(2)はまだ発行されておらず、ユーザプログラムがメモリ内でstrcat(3)などを用い文字列を作成した場合と変りません。

上例の2frw.cでもfwrite(3) は渡されたデータを内部バッファへメモリ間コピーしたに過ぎず、ディスク上のファイルは作成された直後のサイズが0バイトのままです。 このため、fread(3) は1 バイトも読み取れません(図2)。

図1 内部バッファへ蓄えられる書き込み

fig/aio-fig01.png

図2 FILEを用いた書き込み ― 2frwの動作

fig/aio-fig02.png

対して1rw.cではFILEを用いないため、内部バッファは存在しません。write(2)を発行し、ファイルシステムへデータを渡します(図3)。

図3 ファイルディスクリプタを用いた書き込み ― 1rwの動作

fig/aio-fig03.png

では、1rw.cは前掲のバッファのプログラミング入門に沿わない悪いプログラムで、遅いディスクに引きずられるようにCPUの速度が犠牲になっているのでしょうか。実はそうではありません。1rw.cはシステム側で管理される本来のバッファの恩恵を受けており、決して悪いプログラミングではありません。

2frw.cの動作は仕様通りですが、プログラマの意図とは異なる、アプリケーションバグかもしれません。1rw.cと同じ結果にしたい場合は、後述するfflush(3)をコールし、内部バッファをwrite(2)する必要があります。

これまでは一般に「バッファ」と呼ばれるものを指すのに、「本来のバッファ」、「stdio FILEの内部バッファ」、「単なる作業用のメモリ領域」などと言葉を使い分けてきましたが、次節からはひとつづつ解説します。

システムレベルのバッファリング

CPUに対してディスクの速度が遅いことは広く知られており、ディスクの内容をメモリに保持するバッファリングと言う動作もよく知られています。このバッファは、アプリケーションが直接操作するものではなく、システム側が管理します。アプリケーションがファイルへ書き込んだ場合のシステム動作を、階層構造から簡単におさらいしてみましょう。大雑把に言えば、ファイル名(オープン後はファイルディスクリプタ)と書き込むデータという2つの入力が、ディスク上のブロックに到達するまでの流れです。

基本的には現代のOSに共通する話だと思いますが、筆者はすべてのOSを熟知しているわけではないので :-) 当世のLinuxを前提とします。

図4 ファイルへの書き込み ― バッファキャッシュまで

fig/aio-fig04.png
アプリケーション
名前によりファイル名を識別し、オープン後はファイルディスクリプタによりファイルを操作する。ファイルディスクリプタと書き込むデータをファイルシステムへ渡す。
ファイルシステム
アプリケーションから受け取ったファイルディスクリプタをinode(アイ-ノード) へ変換する。ファイル内容(ファイルデータ)を保存するディスクブロックはinodeが保持、管理する。ディスクブロックに対応するメモリ(バッファ)内容を書き換え、そのバッファとinodeがdirtyである(書き込む必要がある)とマークする。

ファイルシステムがバッファとinodeをdirtyにすれば、制御はアプリケーションへ返ります(図4)。つまりこの時点では書き込んだ内容はまだディスクへ到達しておらず、システムが管理するメモリ内に留まっています。ファイルシステムが書き換えたこのメモリが前掲の「本来のバッファ」、すなわちCPUとディスクの速度差の吸収を目的とした緩衝体です。バッファキャッシュとも呼びます。

バッファキャッシュはシステムが管理するもので、ディスクへの実際の書き出しはシステムにより非同期に行われます。すでに制御はアプリケーションへ返っており、別の流れになりますが、データがバッファキャッシュからディスクへ到達するまでの流れを追います(図5)。

図5 ファイルへの書き込み ― バッファキャッシュの書き戻し

fig/aio-fig05.png
dirtyバッファキャッシュ書き戻しカーネルスレッド
ディスクへ書き込む必要がある(dirty)とされたけれど、メモリ(バッファキャッシュ)内に留まったままにされているデータは、一定時間経過後、または空きメモリが一定量よりも少なくなった時点でブロックデバイスへ渡す。明示的に書き戻しを指示することもできる。
ブロックデバイス
渡されたデータをディスクなどのデバイスへ書き込み、永続的に保存する。

デバイスが内部に持つバッファキャッシュ(ハードウェアレベルのバッファリング)も存在しますが、本記事では取り上げません。

バッファキャッシュ書き戻し処理は、それまでのpdflushスレッドに代わり、平成21年12月にリリースされたLinux 2.6.32からbdiスレッド(backing_device_information)に置き換えられました。per-bdi flusherとも呼ばれます。役割/位置付けは変りませんが、構造的な変化は大きく、dirtyなinode、バッファを小分けに管理、書き戻すことにより、速度性能改善を目的とした変更です。この方向性はその後も維持され、変更作業は継続されています。

開始の契機など書き戻し処理には/proc/sys/vm下にいくつかのパラメータが用意されています。詳細は割愛しますが、環境に応じたチューニングは一考の価値があります。

取り上げられる機会が少ないようですが、laptop_modeなども有用でしょう。

書き戻しの契機は上記以外にもMagic SysRqキーによる緊急remount、緊急syncがあります。

バッファキャッシュ内に留まっているデータは放っておいても、最終的にはディスクへ書き戻されますが、この間に電源断などの事故により正常に書き戻されなければ、ディスク上のファイルシステムは整合性を保てなくなる恐れがあります。

この場合に、ファイルシステムの整合性の確認/修復、また可能ならばファイルデータの救済などを行うのが、通常システム起動時に実行されるfsckです。

ディスクI/O回数を増やしシステムパフォーマンスを犠牲にしてでも、ファイルシステムが不整合になる可能性を減らしたい場合には、明示的に書き戻しを指示するfsync(2)sync(8)が有効です。ファイルオープン時にO_SYNCを指定しても同等の効果が得られますが、書き込み回数が多い場合には性能劣化が大きくなるため注意が必要です。

Linux固有のシステムコールですが、対象範囲を指定できるsync_file_range(2)というのもあります。

書き戻し後にdirtyからclean(ディスクとメモリの内容が一致しており、もう書き戻す必要がない)になったバッファはそのままメモリ上に残るかもしれませんし、システムが空きメモリを必要としている場合には破棄されるかもしれません。

バッファキャッシュにはシステムの空きメモリを流用します。

空いていればどんどんバッファキャッシュにまわされ、別の用途にメモリが必要になれば使用頻度が低いバッファキャッシュは破棄され、必要な用途に割り当てられます。

破棄されるのは使用頻度が低い順ですが、この破棄とディスクからの再読み取りが頻発してしまうと、システムのパフォーマンス低下の一因となり得ます。

I/O単位サイズ

I/Oでは単位サイズも重要です。アプリケーションが書き込むデータサイズは任意ですが、ファイルシステムでのI/Oはブロック単位、またブロックデバイスへ渡されるのはセクタ単位です。

いずれも512よりも大きい2の累乗が使用されますが、具体的な値は環境に依存します。例えばファイルシステム作成時にブロックサイズを512バイト、1KB、4KB...64KBなどにも指定可能です。デフォルトではファイルシステム容量からブロックサイズを自動的に決定するファイルシステムが多いようです。通常はメモリページサイズがファイルシステムブロックサイズ上限になります。実際のブロックサイズはstat(2)が返すst_blksizeで確認できます。

アプリケーションレベルのバッファリング

システムが管理するバッファキャッシュをシステムレベルのバッファリングと呼ぶのに対し、アプリケーションが自身で管理することをアプリケーションレベルのバッファリング、またはユーザレベルのバッファリングと呼びます。stdioのFILEは厳密にはライブラリレベルのバッファリングですが、アプリケーションレベルのバッファリングの一種 です。

2frw.cFILEの内部バッファのため期待通りに動作しない例として取り上げたため、印象が悪くなってしまったかもしれませんが、FILEは決して悪いものではなく、それどころか内部バッファが威力を発揮し、性能/効率に大きく貢献する場合が多くあります。例えば小さいサイズのデータを数多く読み書きする場合を考えます。その度にread(2)write(2)を発行することももちろん可能ですが、システム内部では既定のI/O単位サイズに丸めあげられるため、コスト高になる場合があります。

ファイルの10バイト目だけを変更する場合に、write(2)で1バイトだけ書き込むと、システムはバッファを完成させるため前9バイト、後ろ502バイト(最低でも)を補填します。補填とはつまりファイルを読み取り、バッファ内をファイルデータで満たすことです。ファイルがすでに読み取られており、バッファが存在していれば新たな読み取りは発生しませんが、I/O単位サイズに一致しない書き込みは遅い読み取り動作を待たなければなりません。書き込みをI/O単位サイズに一致させると、バッファ内容を現在のファイル内容で補填する必要がなくなり、読み取りの待ち時間を削減できます。

また、システムコールは一度制御がカーネルへ移るため、多少なりともオーバヘッドを伴います。I/O結果は同じでも、I/O単位サイズに注意し、システムコール発行回数を削減した方が効率面では有利です。このことはddを使って簡単に確認できます。

write(2) のサイズと回数比較例

$ dd if=/dev/zero bs=1M count=1 2> /dev/null | \
    time dd of=/dev/null obs=1
2048+0 records in
1048576+0 records out
1048576 bytes (1.0 MB) copied, 0.282584 s, 3.7 MB/s
0.11user 0.16system 0:00.28elapsed 98%CPU (0avgtext+0avgdata 0maxresident)k
0inputs+0 outputs (0 major+211minor) pagefaults 0swaps

$ dd if=/dev/zero bs=1M count=1 2>/dev/null | \
    time dd of=/dev/null obs=512
2048+0 records in
2048+0 records out
1048576 bytes (1.0 MB) copied, 0.00376508 s, 279 MB/s
0.00user 0.00system 0:00.00elapsed 50%CPU (0avgtext+0avgdata 0maxresident)k
0inputs+0 outputs (0major+213 minor)pagefaults 0swaps

単純すぎて比較が見えにくいかもしれませんが、1バイトの書き込みを1M回実行した場合と、512バイトづつの書き込みを2K回実行した場合の資源消費を示した例です。入出力先はディスク上の通常ファイルではないため、実質的にwrite(2)と若干のメモリ操作だけの比較となります。write(2)を1M回実行した場合よりも2K回実行した場合の方が短時間で終了し、CPU使用率も少ないことがわかります。書き込みサイズを512の倍数にしておけばほぼ同等の効率が得られます。上記の単純比較も付属ソースコード一式に含めてあります。

上例から、小さいサイズのwrite(2)を多数繰り返すよりも、大きいサイズでまとめて行う方が効率的であることがわかります。stdioのFILEはまさにこの動作をアプリケーションに提供するものです。FILEへの書き込みは内部バッファへ蓄積され、一杯になった時、または明示的に掃き出しを要求された場合にのみ、内部バッファをwrite(2)へ渡します。読み取りの場合も内部バッファサイズでread(2)し、アプリケーションへは内部バッファからデータを返します。

FILEのバッファフラッシュ

それでは内部バッファのサイズはいくつで、掃き出し要求は具体的にどう行うのでしょうか。アプリケーションはファイルシステムへデータを渡すのですから、I/O サイズはファイルシステムのブロックサイズの倍数で、かつ大きい方が速度面では有利です。以前は/usr/include/stdio.hで定義されているBUFSIZがそのサイズだったと思うのですが(あまりにも古い記憶だから自信がありません)、手元のGNU Libc v2.7で確認するとfopen(3)stat(2)を発行し、得られたst_blksizeを内部バッファサイズとしていました(少なくとも通常ファイルの場合は)。前述のようにst_blksizeはファイルシステムに依存しますから、固定されているわけではなく、対象ファイルに最適な値を内部バッファサイズとしています。アプリケーションのI/O単位では文字、行といった概念が一般的で、ブロックデバイスのセクタは通常意識しません。FILEでの内部バッファリングは、性能劣化を防ぎつつ任意サイズでのI/Oをプログラマに提供するものとも言えます。内部バッファリングのレベルも指定可能です。全バッファリング(fully buffered)、行バッファリング(line buffered)、バッファリングなし(unbuffered)の三段階が提供されており、デフォルトは全バッファリングとされており、内部バッファが一杯になると内部でwrite(2)を発行して書き出しますが、出力先が端末の場合はデフォルトで行バッファリングとされます。このため改行コードで終わる文字列を標準出力(通常は端末画面)に書き込むと、バッファリングされず即座に表示されます。バッファリングのサイズ、動作の変更詳細については、setvbuf(3)を参照してください。

FILEの書き込みエラー

内部バッファが一杯でなくとも明示的に書き出す場合は、fflush(3)をコールします。見落とされがちですがこの戻り値は重要です。write(2)の戻り値は、例えばファイルシステムの容量不足が原因で書き込めなかったなどのエラーを期待するため、あまり見落とされることはありません。fwrite(3)fprintf(3)の戻り値も、write(2)のようなエラーが期待されるようで、これもあまり見落とされることはないようです。しかし、fwrite(3)は内部バッファへ書き込んだだけで、write(2)を発行しない場合が多くあります。このため、ENOSPCなどのエラーを正しく検知できるかは期待できません。エラーを調べるにはfflush(3)の戻り値が重要です。fwrite(3)の戻り値にももちろん意味はありますので、両方とも確認すべきでしょう。ferror(3)も有効です。さらにfflush(3)をコールしてもデータがディスクへ確実に保存されたとは限りません。実質的にwrite(2)に相当するものであり、データはバッファキャッシュへ留まります。ディスクへ到達させるには前述のようにfsync(2)を発行する必要があります。

またFILEクローズ時にも内部バッファは掃き出されるため、fclose(3)の戻り値も重要です。同様に見落とされがちですが、fwrite(3)の戻り値は確認するけれど、fclose(3)の戻り値を確認しないのは、アプリケーションバグです。だいぶ昔の話ですが、FILEを用いた実装のメールクライアントがメールを受信し、ユーザのホームディレクトリへ書き込んだのは良いけれど、fclose(3)の戻り値を確認しなかったため、ホームディレクトリが一杯になっていることがわからず、その時に受信したメールをすべて失ってしまい、著者の周囲で悲鳴が上がったことがあります。2frw.cのようなプログラムが、ライフラインと呼ばれることもあるメールを扱うならば、fflush(3)fsync(2)も発行した方が良いでしょう。同様に見落とされがちですが、FILEを使用せずwrite(2)close(2)する場合でもclose(2)の戻り値確認は重要です。write(2)ではエラーを返さずclose(2)で初めてエラーを検知できるファイルシステムもあります。実装にもよりますが、NFSなどがそれにあたります。

初めに挙げた「単なる作業用のメモリ領域」は変数名こそbufferと名乗っていますが、動作的にはバッファとは言い難く、強いて言えばアプリケーションレベルのバッファリングと呼べなくもないという程度のものです。

シーケンシャルアクセスの効率化

ファイルシステムではブロック単位、ブロックデバイスではセクタ単位でI/Oが発行されるのは前述の通りですが、このことはアプリケーションがファイルからデータを1バイト読み取るだけでも、システム内部ではもっと多くのバイト数が読み取られことを意味します。さらに1ブロックだけが必要とされる場合でも、自動的にその続きの数ブロックも同時に読み取り、本来要求されたI/Oを阻害しない範囲でバッファキャッシュへ蓄えておく先読み(read-ahead)という機能もあります。この機能はディスクのヘッドシーク時間を考えると非常に効果的です。さらにLinuxではアプリケーションのファイルアクセスパターンに応じて動的に先読み量を変化させ、シーケンシャルアクセスの場合には自動的に先読み量を増加させて、より多くのデータを先読みします。

初めからシーケンシャルにアクセスすると決まっているアプリケーションならば、事前にシステムに通知することにより、先読み効果を最大限に活かせます。この通知にはmadvise(2)fadvise(2)readahead(2)などを用います。シーケンシャルアクセス以外の場合でもその用途を通知することで、効率向上が図れます。書き込みの場合でもディスクブロック割り当てをあらかじめ要求するfallocate(2)というのもあります。詳細は各システムコールのマニュアルを参照してください。

先読みを活用しても、読み取るデータが常にバッファキャッシュ内に存在するとは限りません。システムがメモリ不足になった場合はやはり破棄される恐れがあります。後述のmmap(2)に加え、mlock(2)も用いると、プロセスアドレス空間をメモリ上に保つことができ、読み取るデータをバッファキャッシュ内に保持できます。もちろん、これはメモリ消費とのトレードオフです。

ここまで 「バッファ」という言葉に解説を加えました。次回ではバッファキャッシュを意識したさまざまなファイルI/Oを解説します。

(初出: H22/4)

Copyright © 2010 SENJU Jiro

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

Bookfair

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

Feedback

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