純規の暇人趣味ブログ

手を突っ込んで足を洗う

[PHP]readfileは巨大ファイルを扱える

      2016/11/16    HimaJyun

「例えば、PHPを避ける」、もちろん間違った考え方なんだけど、この記事を書いた人がそう言いたくなるのも少し理解出来る

PHP、いい加減でもいい加減に動いてくれて、非プログラマでもとりあえずそれっぽい感じに動く。

お陰様でネット上には間違った情報だらけ、今回はちょっと目に付いた「readfile()」と言う関数の間違いを書いてみる。

悲劇のreadfile

「PHP readfile」でググると結構良いランクの所に「メモリに溜め込んで使えない」だの書かれてあって可哀想に思う、readfileの事が。

それとは別に「echo file_get_contents("hoge");」をサンプルとして載せているサイトも少なくないと思う(調べてはいないが

タイトルにもある通りだが、readfileは巨大なファイルを扱える、もちろんメモリに溜め込んだりはしない。(この事はきちんと公式マニュアルに書かれてある

逆に「echo file_get_contents」なんてのは禁じ手、こいつはメモリに溜め込む、当然ながら巨大なファイルは扱えない

なんで良いの?/ダメなの?

PHPしか経験のない人だと分からないだろうけど、I/O系はちょっぴり重い。(らしい、厳密に数値を測ったりした事は無い

僕もそこまで詳しく踏み込んだ訳ではないのだが、echoでの出力も逐一クライアントに送っていたら効率が悪い

なので、PHPにはoutput_bufferingと言う物がある。

これは、echoなどの出力を全て溜めておいて、後からまとめてクライアントに送り付けると言う物。

実際はWebサーバ側のフィルタとか色々あるんだけど、難しくなるので考えないとして、とにかく出力(送信)を1度にまとめてしまって高速化しよう、と言う代物

巷で良くある「readfileツカエネー」は本当はreadfileがファイルをメモリに溜め込んでいるのではなく、このoutput_bufferingの上に溜まっているって訳。

実際に検証

ただ、「こうなんだ!!」と提唱しただけで信じてくれる人なんて殆ど居ない。

先の通り、これにはphp.iniのoutput_bufferingが関係してくるので、これのOn,Offでreadfileとfile_get_contentsの計4パターンを実際に試してみる。

テスト環境は手元の仮想マシン、Ubuntu 16.04、メモリ8G、PHP7、Apache2.4、テスト用のファイルは1Gのnullファイル。

サンプルコードは以下の通り、適当だけど構わない。

<?php
header("Content-Type: octet-stream");
$mem = memory_get_usage(FALSE);
file_put_contents("./log","Start: ${mem}\n",FILE_APPEND);

// ここで切り替え
//readfile('./zero');

// ここで一度変数に入れているのは
// こうしないとechoを抜けた時点でメモリが解放されてしまって
// 正確に測定できないから
// echoを「抜けた」時点で解放されるってだけなので、メモリに溜め込まれるのは変わらない
$file = file_get_contents("./zero");
echo $file;

$mem = memory_get_usage(FALSE);
file_put_contents("./log","End: ${mem}\n",FILE_APPEND);

まずはfile_get_contentsから行こうか。

output_buffering=On,file_get_contents

とりあえず絶対死ぬパターン、出力バッファが有効で、file_get_contents。

結果は次の通り、開始時の消費メモリが「358912」バイト、すなわち約358kb

終了時の消費メモリが「2147834528」バイト、もう桁が増えまくってヤバいかほりがプンプン漂っているが、これは2gbちょい

1gbのファイルを送信するのに2gbのメモリを消費している、これはoutput_buffering分と、file_get_contents分が重ね掛けになっている事でそうなっている。

つまりこのやり方は最悪、まぁ当然だよね、file_get_contentsってそう言う用途で使う物じゃないし。

output_buffering=Off,file_get_contents

次に、file_get_contentsがメモリに溜め込むので、今回の様な用途には適さないと言う事を確認したい。

出力バッファを無効にし、file_get_contentsとechoで送信する。

結果は次の通り、開始時の消費メモリが「342320」バイト、すなわち約342kb

そして終了後はと言うと、「1074088360」バイト、先ほどの2gb程ではないが、やはり1gbを消費、やっぱりダメだったねっ!!

このやり方もアウト、これでfile_get_contentsはこの様な用途には適さないと言う事が決まった

output_buffering=On,readfile

一般的に「readfileツカエネー」方達が陥っているパターンだと思われるもの。

readfileで安全にファイルを送信しているにも関わらず、出力バッファが有効な所為で無意味になっているパターン

結果は次の通り、開始時の消費メモリが「356624」バイト、もう計算するのが面倒なので手抜きだが、多分350kbくらい。

そして終了後は……「1074098584」バイト、だいたい1gb、やはり思惑通りの結果。

このやり方もアウト、残るは1つ。

output_buffering=Off,readfile

僕が正しいと提唱しているパターン

出力バッファが無効になっており、なおかつreadfileで安全に送信している。

結果は次の通り、開始時の消費メモリが「340032」バイト、多分340kbくらい。

そして終了後はと言うと……「340128」バイト、1GBのファイルを空きメモリに問題を与えることなく送信しきった、読み通りの結果。

これが本来あるべきreadfileの挙動だ、いいかい、readfileはメモリには溜めない、良いね?

対処法

「じゃけんoutput_buffering無効にしましょうね~」とは行かない、高速化のための機能を無効になると遅くなるのはウッキーなモンキーでも分かる。

まずphp.iniを触れないパターンも十分にあり得る訳であって、それらを踏まえたうえで3つの対処方法をご紹介する。(もっとあるかも知れない)

(ちなみにfreadで少しずつ吐き出すのは本質的な解決になっていない上に気持ち悪いのでパス)

output_bufferingをOffに

一応書いておくがオススメはしない、php.iniにあるoutput_bufferingをOffにしよう。

こうする事でoutput_bufferingが無効になってこれらの問題はなくなる。

ただ、高速化云々のデメリットを考えればこの方法はあり得ない。

output_bufferingに数値を指定

On,Offしか使えないと見せ掛けて数値が指定出来るoutput_bufferingさん、実にPHPらしい。

数値を指定すると、バッファの最大サイズをその値までに制限する(としか書かれていない、超過した場合に「超過分はそのまま出力」、「バッファ分を出力して再びバッファリング」、「超過した時点でバッファ分が出力され、それ以降はバッファリングされない」、の、どの挙動になるのかは分からなかった

ちなみに最大値は4096で、多くのサイトでも4096が推奨されている。(そして、僕も推奨している。

一応、4096で設定した場合にreadfileで実行した結果も説明しておくと、開始時が「348432」で終了時が「364912」、メモリセーフ(って言ったらいいのかな)

と言うか僕の手元のUbuntuでは4096がデフォルト、もしかしてRHELならこれがOnだったりするのかな?

プログラム側で無効化

多くの場合、PHPから巨大ファイルを送り出すにはX-SendFileかX-Accel-Redirectの様な機能を利用する事をお勧めする(使い方記事

が、どうしてもPHPから送り出さなければならない場合、output_bufferingに関わらず正常に送信出来る様にしておきたい。

実はこのoutput_buffering、プログラム側から無効に出来る、こんな感じで。

// 出力バッファを無効化
while (ob_get_level()) {
  ob_end_flush();
}
flush();

これを、readfileなどの直前に呼び出してやれば、output_bufferingが無効化されてメモリセーフにreadfileが出来る。(ちなみに、header系の関数は先に呼び出しておかないと上手い具合に動かない)

ob_end_flush()が1つのバッファリングに対してしか効かない(バッファリングは複数設定出来る(ほぼ無意味だが)ため、複数設定されている場合には複数回呼び出す必要がある)事に気付かずに「ダメだった」と勘違いしている人が多いように思える。

実際はob_get_level()が0になるまでob_end_flush()を呼び出してやる必要がある。

ちなみに、ob_end_clean()ではなくob_end_flush()を使おう、ob_end_clean()は今まで溜めていた内容を破棄してしまう、ob_end_flush()ならクライアントに送信される。

ぶっちゃけfopen、fread、ob_flush、flush、なんて気持ち悪いループを組む意味はないと思う。

きちんとバッファリングを無効にしておけば普通にreadfile1つできれいさっぱり完結するはず、少なくとも僕の知り得る限りでは。

結論

ここから導き出される結論は1つなんですよ、公式のマニュアル読んで下さい。

前の記事でも同じ事書いてますが、PHPのマニュアルは丁寧に分かりやすく日本語化されています、これを読まないなんて言語に対する冒涜ですよ。

PHPを学んでいると「ネットの情報が間違いだらけ」だと言う事を嫌でも痛感させられる。

(もちろん僕自身、自分の書いている情報は極力間違えないように心掛けて確認しながら書いてはいるが、もし間違っていたらやんわり指摘してほしい)

 - プログラミング