Linux のページキャッシュ

世間では PHP が、Perl が、と盛り上がっているようですが空気を読まずまたカーネルの話です。今回はページキャッシュについて。

/dev/shm に参照系DBを持っていくと I/O 負荷が激減した件(当たり前だけど) - drk7jp で、ディスク上にあったファイルを /dev/shm (tmpfs) に移したら I/O 待ちがなくなって負荷がさがった、ということなんですがおそらくこれは tmpfs に置く必要はないかなと思います。Linux (に限らず他の OS もそうですが) にはディスクの内容を一度読んだらそれはカーネルがキャッシュして、二度目以降はメモリから読む機構 = ページキャッシュがあります。tmpfs にデータを載せることができた、ということは物理メモリの容量に収まるだけのデータサイズかと思うので、放っておけば該当のファイルの内容すべてがメモリ上にキャッシュされて iowait はなくなると思います。

(と、これは僕もずいぶん長いこと色々勘違いしてた話なんですが。YAPC で MySQL のデータを tmpfs に置いたりするよ、と言ったら insane といわれたんだけど多分そういうことなんだろう。)

※ これ以降は read を主体に議論していきます。

まず先に色々データの方から。ページキャッシュが噛んでメモリに載れば I/O 性能は落ちないよの件をベンチマークで。

#!/usr/local/bin/perl
use strict;
use warnings;
use Benchmark;
use IO::File;

Benchmark::timethese(10000, {
    page_cache => read_from('/home/naoya/tmp/httpd'),
    tmpfs      => read_from('/mnt/tmpfs/httpd'),
});

sub read_from {
    my $path = shift;
    return sub {
        my $fh = IO::File->new($path) or die $!;
        while (my $nread = $fh->sysread(my $buffer, 8192)) {}
        $fh->close;
    }
}

適当なファイルサイズのファイルを、tmpfs とハードディスク上のファイルシステム (ext3) にそれぞれ同じものを置いて、10,000回ひたすら read してみます。手ごろなサイズのファイルということで Apache のバイナリ 1.5MB をコピーしておきました。

Benchmark: timing 10000 iterations of page_cache, tmpfs...
page_cache:  7 wallclock secs ( 1.57 usr +  5.86 sys =  7.43 CPU) @ 1345.90/s (n=10000)
     tmpfs:  8 wallclock secs ( 4.79 usr +  2.46 sys =  7.25 CPU) @ 1379.31/s (n=10000)

変わりませんね。(tmpfs にするとユーザー空間でのプロセッサ消費時間が長いのはなんでですかね。)

他にもデータを。これははてなで使ってるとある MySQL サーバーのデータです。だいたいデータベースのデータがインデックス含め合計 8GB 強あるサーバーで、メモリ 4GB 搭載で運用していたとき。データはディスク上 (RAID 0)に置いてます。

14:10:01          CPU     %user     %nice   %system   %iowait     %idle
14:20:01          all      8.58      0.00      5.84     16.58     69.00
14:30:01          all      7.41      0.00      5.14     17.81     69.63
14:40:01          all      7.74      0.00      4.97     18.56     68.73
14:50:01          all      7.02      0.00      5.01     16.24     71.72

iowait が結構ありますね。後日にこのサーバーにメモリを追加して 8GB にしました。

14:10:01          CPU     %user     %nice   %system   %iowait     %idle
14:10:01          all     18.16      0.00     11.56      0.80     69.49
14:20:01          all     12.48      0.00      9.47      0.88     77.17
14:30:01          all     14.20      0.00     10.17      0.91     74.72
14:40:01          all     13.25      0.00      9.74      0.75     76.25

と、iowait がほぼなくなりました。ディスクI/Oがボトルネックだったのが解消された影響で、スループットが上がって CPU の方に負荷がかかっています。理想的な状態です。メモリを足しただけでI/O待ちが解消できたのは、ページキャッシュ分のメモリを確保できたから。データサイズが 8GB 強なので、物理メモリを 8GB 載せるとだいたいほとんどのデータがキャッシュできるのでこのぐらい効果がはっきり出ます。

ということでデータサイズを見てページキャッシュに任せられそうなサイズなら OS に任せておくのが良いんじゃないかなと思います。

ここで少しうんちく。

Linux のディスクキャッシュが「ページキャッシュ」と呼ばれるのは、キャッシュの単位がページだからです。ページというのは Linux の仮想メモリの最小単位。つまり何かしらのデータがメモリに存在するとき、そのメモリ領域をカーネルが扱うときの最小単位です。ディスクの内容をキャッシュする場合、ファイルを丸ごとキャッシュしたりするのではなくiノード番号とファイルのオフセットをキーにしてページ単位でキャッシュします。

後で深追いしますがページキャッシュは ext3 などのファイルシステムを使う場合、(Direct I/O 時以外) read / write に対して常に透過的に働きます。また、より参照頻度の高いページがキャッシュに残るように動きます。従って、OS を立ち上げて放っておけば一度読んだディスク上の領域は徐々にキャッシュされていって、最適化されていきます。

ということは、OS を再起動すればメモリの内容と一緒に最適化されたキャッシュはすべてクリアされてしまうし、巨大なファイルを入出力してメモリに空きがなければ、古いキャッシュは追い出されてしまうことになります。

MySQL が動いてるサーバーをメンテナンスで再起動したり、あるいはバックアップでデータを総なめした後にすぐ稼動させると、I/O 待ちが発生してスループットが出ないことがよくあります。これはページキャッシュが効いてない状態でサーバが稼動しているので、メモリでなく I/O がディスクに対して行われるためです。これを回避するために、主要なデータファイルはあらかじめ read しておくと言う手があります。

自分の場合

#!/usr/bin/perl
use strict;
use warnings;
use IO::File;
use File::Basename qw/basename/;

for my $file (@ARGV) {
    my $fh = IO::File->new($file, "r") or die $!;
    my $size = 0;
    printf STDERR "%s: ", $file;
    while (my $nread = $fh->sysread(my $buffer, 8192)) {
        $size += $nread;
        my $line = sprintf "%d bytes read", $size;
        print STDERR $line, "\b" x length $line;
    }
    print "\n";
}

というファイルを read して進捗を画面に表示するだけの Perl スクリプトをでっちあげて、MySQL のデータが格納されているディレクトリでこのスクリプトを実行して全データを読んでから mysqld を立ち上げています。多分 cat して /dev/null でもいいと思います。

カーネルがどのぐらいのページをキャッシュしているかは、sar -r をすれば分かります。

18:20:01    kbmemfree kbmemused  %memused kbbuffers  kbcached kbswpfree kbswpused  %swpused  kbswpcad
18:30:01      3566992    157272      4.22     11224     50136   2048276         0      0.00         0
18:40:01      3546264    178000      4.78     12752     66548   2048276         0      0.00         0
18:50:01       112628   3611636     96.98      4312   3499144   2048232        44      0.00        44

sar -r の kbcached 項です。また %memused は物理メモリがページキャッシュ込みでどれぐらい使われているかを示す数値。上記はとあるサーバーの再起動直後。18:40 〜 18:50 の間に先のスクリプトを実行してデータを全部 read しました。メモリが 4.22 % とほとんど使われていない状態から一気に 96.98 % まで使われたのが分かります。また kbcached に使われたメモリが回っているのが分かります。

Linux はメモリに空きがある限りページキャッシュにキャッシュを蓄え続けます。他のアプリケーションでメモリが必要になるとページキャッシュは優先的に開放されます。ので %memused が 96.98% だと「空きメモリがない!」と一見びっくりしてしまいますが全く問題ありません。

深追い

さて、せっかくなのでまた深追いしてみます。ページキャッシュの処理はどこで実装されているか、を知ろうのコーナーです。

read(2) の処理を実装しているであろう箇所、つまりファイルシステム周りのコードを見ればいいわけですが、ファイルシステム関連は幾つかのレイヤに分かれた構造になっているのでなかなか複雑です。

肝になるのは仮想ファイルシステムです。VFS に関しては VFSとファイルシステムの基礎技術 (1/2):Linuxファイルシステム技術解説(1) - @IT あたりが概要をつかむのにわかりやすいと思います。

Linux は ext3、tmpfs、reiserfs、xfs、vfat ... と多数のファイルシステムを扱うことができます。それぞれ特徴を持った異なるファイルシステムなので当然それらの実装はことなるわけですが、各実装ごとにプログラムインタフェースが異なっていると、コードを書いている人が困ってしまいます。そこでファイルシステムの実装を隠蔽するのが仮想ファイルシステムです。またその抽象化されたファイルシステムはブロック型デバイスを抽象化したものと言えます。

と多層の関係になっているわけです。すべてのファイルシステムへの命令は VFS を経由してそれぞれの実装へ到達します。VFS はインタフェースを抽象化するだけでなく、各ファイルシステムに共通の手続きを提供したり、ページキャッシュのような性能を向上させる機能などを提供します。

ここまで読んでピンと来た方は多いと思いますが、要はインタフェース/抽象クラスとその実装の関係になってるわけですね。で、この抽象レイヤから実際の実装の処理が起動されるまでの一連の流れが面白い。

は VFS のインタフェースに合わせて簡易なファイルシステムを作るという記事です。これがとても分かりやすいので興味のある方は是非ご一読を。

...ここまで書いて疲れた。以下は気力があったらあとで書きます。

fs/read_write.c

const struct file_operations generic_ro_fops = {
        .llseek         = generic_file_llseek,
        .read           = do_sync_read,
        .aio_read       = generic_file_aio_read,
        .mmap           = generic_file_readonly_mmap,
        .sendfile       = generic_file_sendfile,
};

read が通常の同期 read 時。aio_read は非同期I/O (AIO) API で呼ばれたとき。

fs/ext3/file.c

const struct file_operations ext3_file_operations = {
        .llseek         = generic_file_llseek,
        .read           = do_sync_read, // → fs/read_write.c
        .write          = do_sync_write,
        .aio_read       = generic_file_aio_read, // → mm/filemap.c
        .aio_write      = ext3_file_write,
        .ioctl          = ext3_ioctl,
#ifdef CONFIG_COMPAT
        .compat_ioctl   = ext3_compat_ioctl,
#endif
        .mmap           = generic_file_mmap,
        .open           = generic_file_open,
        .release        = ext3_release_file,
        .fsync          = ext3_sync_file,
        .sendfile       = generic_file_sendfile,
        .splice_read    = generic_file_splice_read,
        .splice_write   = generic_file_splice_write,
};

ext3 は VFS が提供するジェネリックな read を使う。write は ext3 固有実装。

fs/ext3/inode.c ext3_read_inode(struct inode * inode)

        if (S_ISREG(inode->i_mode)) {
                inode->i_op = &ext3_file_inode_operations;
                inode->i_fop = &ext3_file_operations;
                ext3_set_aops(inode);
        } else if (S_ISDIR(inode->i_mode)) {
                inode->i_op = &ext3_dir_inode_operations;
                inode->i_fop = &ext3_dir_operations;
        } else if (S_ISLNK(inode->i_mode)) {
                if (ext3_inode_is_fast_symlink(inode))
                        inode->i_op = &ext3_fast_symlink_inode_operations;
                else {
                        inode->i_op = &ext3_symlink_inode_operations;
                        ext3_set_aops(inode);
                }
        } else {
                inode->i_op = &ext3_special_inode_operations;
                if (raw_inode->i_block[0])
                        init_special_inode(inode, inode->i_mode,
                           old_decode_dev(le32_to_cpu(raw_inode->i_block[0])));
                else
                        init_special_inode(inode, inode->i_mode,
                           new_decode_dev(le32_to_cpu(raw_inode->i_block[1])));
        }

iノードオブジェクトを探しあてた後、iノードオブジェクトの種類に合わせて file_operations 構造体を変えて inode->i_op / i_fop に代入している。これによってiノードオブジェクトでもポリモフィズム。

tmpfs の実装は mm/shmem.c にある。

static struct file_system_type tmpfs_fs_type = {
        .owner          = THIS_MODULE,
        .name           = "tmpfs",
        .get_sb         = shmem_get_sb,
        .kill_sb        = kill_litter_super,
};

これが super_block 構造体にセットされるファイルシステムタイプの設定。

mm/shmem.c

static const struct file_operations shmem_file_operations = {
        .mmap           = shmem_mmap,
#ifdef CONFIG_TMPFS
        .llseek         = generic_file_llseek,
        .read           = shmem_file_read,
        .write          = shmem_file_write,
        .fsync          = simple_sync_file,
        .sendfile       = shmem_file_sendfile,
#endif
};

tmpfs は read / write ともに独自実装。VFS 層の read (generic_file_aio_read()) は使わない。shmem_file_read() は do_shmem_file_read() へつながる。do_shmem_file_read() ではページをアプリケーションバッファにコピーしたらそこで毎回ページキャッシュを開放している? /dev/shm とページキャッシュで二重にデータを持つのを避けるため? ここがはっきりしない。

ssize_t do_sync_read(struct file *filp, char __user *buf, size_t len, loff_t *ppos)
{
        struct iovec iov = { .iov_base = buf, .iov_len = len };
        struct kiocb kiocb;
        ssize_t ret;

        init_sync_kiocb(&kiocb, filp);
        kiocb.ki_pos = *ppos;
        kiocb.ki_left = len;

        for (;;) {
                ret = filp->f_op->aio_read(&kiocb, &iov, 1, kiocb.ki_pos);
                if (ret != -EIOCBRETRY)
                        break;
                wait_on_retry_sync_kiocb(&kiocb);
        }

        if (-EIOCBQUEUED == ret)
                ret = wait_on_sync_kiocb(&kiocb);
        *ppos = kiocb.ki_pos;
        return ret;
}

ジェネリックな read 用コールバックである do_sync_read は flip->f_op->aio_read を中で呼んでる。つまり非同期I/Oの関数をラップして、wait して同期 I/O の実装としている。Don't Repeat Yourself。

で、generic_file_aio_read() を追っていくと do_generic_mapping_read() にたどり着く。ここでページキャッシュとディスク内容のマッピングが行われる。

  • page 構造体の mapping メンバは address_space 構造体をポインタする
  • address_space 構造体は iノードオブジェクトと 1:1 → iノードオブジェクトはi ノード番号を知っている
  • page 構造体はファイルのオフセットを知っている
  • ∴ page 構造体からiノード番号、オフセットにたどり着く。この二つの情報をインデックスにしてキャッシュされる = ページ単位でのキャッシュ
  • address_space 構造体 → page は Radix Tree。ファイルの大きさはキャッシュの検索性能にほとんど影響を与えない

まとめ

  • Linux はメモリがある限りページ単位でブロック型デバイスの入出力をキャッシュする
  • I/O はページキャッシュに任せよう
  • ページキャッシュの状態は sar -r で確認できる
  • DB はメモリにフィットさせよう (http://d.hatena.ne.jp/stanaka/20070427/1177651323)
  • ページキャッシュがクリアされてしまったら read してキャッシュに載せよう
  • VFS 周りの実装はインタフェースにコールバックを登録していく実装になっている
  • tmpfs は read / write が tmpfs 用に実装されている。I/Oに伴うページキャッシュの扱いが通常と違う。
  • tmpfs はスワップアウトされるので、どうしても優先的にメモリに載せておきたいデータは ramfs がいいかもしれない (未検証)

追記

この辺りの話をまとめて本に書きました。

[24時間365日] サーバ/インフラを支える技術 ?スケーラビリティ、ハイパフォーマンス、省力運用 (WEB+DB PRESS plusシリーズ)

よろしければご一読ください。