hnwの日記

PHPのcopy関数がファイルサイズ分のメモリを消費する件の対策


補足(2010/08/24 15:00):rename関数について言えば、同一ファイルシステム上であればrenameシステムコールを利用するのでこの問題は起こりません。さらに蛇足ですが、ファイルシステムをまたがってrename関数を利用するとコピーしてから削除することになり、アトミック性を保証できないため、障害の原因にならないかどうかの検討が必要だと思います。


AKIBA de: PHPのrename()関数はファイルシステム間で使うとメモリをバカ食いする」で指摘されている通り、PHPのcopy関数やファイルシステムをまたがってrename関数を使う場合に、PHPがファイルサイズと同じ大きさのメモリを消費してしまいます。環境によっては再現しないかもしれませんが、僕の手元のMacOSX 10.5+PHP5.3.3環境では再現しました。

<?php
// 「dd if=/dev/urandom of=1gb.dat bs=1m count=1024」でファイルを作ってから実行してください。
copy("./1gb.dat","./1gb.bak");

原因と対策

この原因はPHPの実装にあります。copy関数を実現しているコード部分を以下に示します。

/* Returns SUCCESS/FAILURE and sets *len to the number of bytes moved */
PHPAPI size_t _php_stream_copy_to_stream_ex(php_stream *src, php_stream *dest, size_t maxlen, size_t *len STREAMS_DC TSRMLS_DC)
{
    char buf[CHUNK_SIZE];
    size_t readchunk;
    size_t haveread = 0;
    size_t didread;
    size_t dummy;
    php_stream_statbuf ssbuf;

    if (!len) {
        len = &dummy;
    }

    if (maxlen == 0) {
        *len = 0;
        return SUCCESS;
    }

    if (maxlen == PHP_STREAM_COPY_ALL) {
        maxlen = 0;
    }

    if (php_stream_stat(src, &ssbuf) == 0) {
        if (ssbuf.sb.st_size == 0
#ifdef S_ISREG
            && S_ISREG(ssbuf.sb.st_mode)
#endif
        ) {
            *len = 0;
            return SUCCESS;
        }
    }

    if (php_stream_mmap_possible(src)) {
        char *p;
        size_t mapped;

        p = php_stream_mmap_range(src, php_stream_tell(src), maxlen, PHP_STREAM_MAP_MODE_SHARED_READONLY, &mapped);

        if (p) {
            mapped = php_stream_write(dest, p, mapped);

            php_stream_mmap_unmap_ex(src, mapped);

            *len = mapped;

            /* we've got at least 1 byte to read.
             * less than 1 is an error */

            if (mapped > 0) {
                return SUCCESS;
            }
            return FAILURE;
        }
    }
(以下略)


copy関数を呼び出すと、上記コード中のphp_stream_mmap_range関数でmmapシステムコールを利用してコピー元ファイル全体をメモリ上にマップし、php_stream_write関数の中でwriteシステムコールでコピー先への書き込みを行います。mmapした直後はメモリは消費しませんが、ファイル全体を読み進めるうちにファイル全体がメモリに乗ってしまうため、最終的にファイルサイズと同じだけのメモリを消費してしまいます。これは巨大ファイルを扱うには不向きなやりかたです。


そもそも、copy関数の挙動を考えるとファイルの先頭から最後までをシーケンシャルに読み込みつつ書き出して、読み終わったら即座に内容を破棄してしまうわけですから、ファイルサイズにかかわらずmmapを使うメリットはほとんどありません(後述の「mmap(2)のメリットって?」も参照ください)。


ところで、php_stream_mmap_range関数の中身を追いかけていくと、ファイルサイズが4MBを超えていたらmmapしないような処理が見つかります。

PHPAPI char *_php_stream_mmap_range(php_stream *stream, size_t offset, size_t length, php_stream_mmap_operation_t mode, size_t *mapped_len TSRMLS_DC)
{
    php_stream_mmap_range range;

    range.offset = offset;
    range.length = length;
    range.mode = mode;
    range.mapped = NULL;

    /* For now, we impose an arbitrary limit to avoid
     * runaway swapping when large files are passed thru. */
    if (length > 4 * 1024 * 1024) {
        return NULL;
    }

    if (PHP_STREAM_OPTION_RETURN_OK == php_stream_set_option(stream, PHP_STREAM_OPTION_MMAP_API, PHP_STREAM_MMAP_MAP_RANGE, &range)) {
        if (mapped_len) {
            *mapped_len = range.length;
        }
        return range.mapped;
    }
    return NULL;
}


このように、mmapしようとするサイズが4*1024*1024=4MBより大きいかどうかチェックしているコードがあります。コメント部分から判断すると、巨大ファイルをmmapして他のプロセスをスワップアウトさせてしまわないようにという意図のようです。しかし、copy関数から呼び出された場合にはlengthは0となって先のチェックに引っかからないため、大容量のファイルでもmmapしてしまいます。これは本来の意図と異なる挙動なのではないでしょうか。


これを修正し、copy関数から呼び出された場合でも正しいlengthを渡すようなパッチを作成しました。PHP 5.3.3で作りましたが、他のバージョンにも適用可能だと思います。


このパッチを利用すると、4MBを超えるファイルはmmapせず、バッファリングしながらのread(2)&write(2)でファイルコピーされるようになります。もちろんメモリ消費量も改善されます。

mmap(2)のメリットって?

ところでmmapで実ファイルを触ると何が嬉しいんだっけ、と改めて考えてみました。次の2つがメリットだという認識でいいんですかね…?

  • ファイルをランダムアクセスする場合に、fseek(3)とかせずに済むので楽チン。一方で、アクセスした場所だけディスクアクセスにいくので、fread(3)などでファイル全体を取得するより効率が良い。
  • カーネル空間からユーザー空間へのメモリコピーが起こらないので、特にファイルキャッシュに乗っているファイルを読み込む場合に性能面でメリットがある。


そう考えると、頻繁にアクセスされる小さいファイルや、ランダムアクセスされるファイルであればmmapを使うメリットがありそうです。copy関数についても、頻繁に同じファイルをコピーするようならメリットがあるかもしれませんが、まずありえない状況だと思います。

ご意見募集

今回作ったパッチをPHP本家に投げようと思ったのですが、その前に他の人の意見も聞ければ嬉しいと思って記事にしてみました。これじゃだめなの?とか、他の言語ではこうしてるよ、などあれば教えてください。本当はsendfile(2)でファイルコピーするのが一番速いのかもしれませんが、今のPHPの実装にうまく組み込むのは難しそうです。sendfile(2)はソケットに対して書き込みするときしか使えないそうです。@dr_awnさん、ご指摘ありがとうございます。ソケットに対してのみsendfile(2)を使うようにするのは余計に難しいと思います。


また、mmap(2)で適切にmadvise(2)すれば読みおわったところからページアウトしてくれるかと想像していたのですが、手元の環境で観察する限りアクティブでないプロセスをスワップアウトさせる方が優先度が高いようです。mmapでうまくいく方法があればその方が良い気もするんですけどね…。


何にせよ、このあたりは他にも問題がありそうな気がします。ちょっとソースコードのぞいてみるか、という気になった人がもしいれば、記事にまとめた甲斐があるというものです。

まとめ

  • PHPのcopy関数とファイルシステムをまたいだrename関数はメモリを贅沢に使う
    • 貧乏なのでパッチ書いた