foreachを使うとき、もうちょいメモリのことを気にして行こうぜ

この記事はだいぶ前に書かれたものなので情報が古いかもしれません

phpでメモリを消費し過ぎて処理が止まってしまうときって、こんな(↓)エラーが出ますよね。使用し過ぎって言った方が良いのかな。

Allowed memory size of 134217728 bytes exhausted

原因はまさしくメモリを使い過ぎているわけですから、メモリの上限を上げてやれば、問題は解決するわけですね。

ini_set('memory_limit', '1G');

例えばこうですね。とにかくもう、上げられるだけ上げたいってんなら、「-1」を指定するのも良いですけど。

僕も、上記のエラーが出た場合には、とりあえずこれを入れて回避するだけでした。それで解決すんだから良いじゃねーかと。実際にメモリがどうなっているとか、そんなこと知らねーよと。

でも、そろそろ向き合う頃かなと。メモリのことも気にし始めないと、強化系なのに頑張って具現化系の能力を使って分身を作り出していたカストロの二の舞になってしまう。ヒソカに「君の書いた処理がエラーになる原因は、メモリの無駄遣い」とか言われちゃう。

そんなのやだ。言われるだけならまだ良いけど、殺されるのはやだ。ヒソカがそういうこと言う場合って、たぶん相手を殺ろうとするときだから。



と、いうわけでして、今回もコード書いてて上記のエラーが出てしまったんですが、いつものようにmemory_limitの設定でお手軽に回避するんじゃなくて、じゃあどうすりゃメモリの消費を抑えられんのかってところを今回は検証していこうと、そう思い立ったわけなのです、はい。

やっぱり人間、エコが大事だからね。抑えられるものは抑えていかねーと。メモリとか財布の中身とかほとばしる性欲とか。

何かメモリのこととか気にしてコーディングすると、ちょっと中級者の世界に足を踏み入れた感じがしなくもないね。

僕もphpを触り始めて早四年。そろそろ中級者の世界に足を突っ込みたいお年頃。プログラマ歴二年くらいの頃はまだ「いや〜俺まだまだ初心者なもんで、難しいことはよく分かんねっすよ〜」って言ってごまかしても許されていたような気がするけど、そろそろそれも通用しなくなって来る……かもしれない。

僕は未だにちょっと難しいこと言われると答えられないですからね。phpの中で、どんな処理がメモリ食ったり処理速度が遅くなったりとか、そういうのほとんど分からないです。ベンチマークなんてほとんど取ったことないし。ベンチマーク? 何それ、おいしいの? っていうレベル。

だからちょうど良い機会なのかもしんないですね。

まあ最悪「いや〜俺って全然プログラマの資質がないみたいで、もう四年以上やってんすけど、未だにたいしたスキルが身に着いてないんですよ〜、はっはっは」と開き直るっていう手があるけどね。



エラーが出た箇所を探る

何にせよ、まずはどこでメモリを使い過ぎたか調べなきゃ始まらない。

ってことでエラーが出ている箇所を見てみたんですが、どうやらforeach文で処理しているところで落ちちゃってたみたいです。

ありのまま起こったことを話すと、データベースから取って来た4万件弱のデータをforeach文で処理していたところで落ちてました。

でも4万件って、そこまで多くもないよね? 少なくもないかもだけど、落ちるほどでもないよね? 配列もそこまで複雑な形にしてないし。

とか僕は思ったんですけど、現に落ちているんだから仕方ない。



実際にメモリの使用量を見てみる

今回初めて知りましたが、PHPにはメモリの使用量を見る関数があるんですね。「memory_get_usage」という関数が。

なので、これをループの後ろに配置してやれば、ループ処理後のメモリの使用量が分かるっていうすんぽー。

foreach($data as $key => $val) {

}

echo memory_get_usage(true);

引数にtrueを指定すると、システムが割り当てた実際のメモリの大きさを取得するらしい。デフォルトはfalse。falseだとemalloc()が使用するメモリのみを取得するらしいんだけど、emalloc()っていうのが何だかよく分からん。だから今回はtrueで行く。

あとついでに使用したメモリの最大値を取る「memory_get_peak_usage」っていうのもある。これも引数がtrueかfalseでemalloc()とかいうやつを取ったり取らなかったりする。

実際、こんな感じで僕も検証してみましたが、使用量が150MBくらいになってました。php.iniではmemory_limitを128MBにしてあるので、明らかにオーバーしてるっぽい感じですね。

たぶんこういう検証の仕方で合ってると思うんだけど……合ってるよね? もし間違ってたら今日の俺はとんだピエロになってまうぜ。



while文とlist、eachの組み合わせが良いらしい

とりあえず、原因は分かりました。

次は、どうやって解決すんのかってことですよね。

噂によると、foreach文の代わりにwhile文でループ処理をして、listやeachを使ってforeachと同じような処理を実装すると、メモリが節約できるそうです。

//foreach文の場合
foreach($data as $key => $val) {

}

//while文の場合
while(list($key, $val) = each($data)) {

}

やっていることは一緒です。実際やってみりゃ同じ結果が得られることが分かる。

どうしてwhile文にするとメモリの使用量が減るのかってことなんですが、どうやらforeach文というのは、処理を行なう際に配列全体のコピーを作っているらしいんですよ。それがメモリを大幅に食っている原因みたいですね。while文の方はコピーを作らない。

ただ、どうやらwhile文の方が処理時間は長くなってしまうっていう話もあるので、どっちを使うかは、ケースバイケースってところでしょうか。



ベンチマークを取ってみる

あくまでも、僕がやったらこうなったっていう目で見てやってください。これがPHPの真実とは必ずしも限らないってことで。そこまでは怖くて責任が持てません^^

配列に簡単な値を100万個くらい入れて、foreach文とwhile文で処理時間とメモリの使用量を比較してみましょう。



……あ、でもその前に。

処理時間やメモリ使用量を測定するには、コードの中に自分で「memory_get_usage」だの「microtime」だのを差し込んでいけば良いんですが、GitHubでこんなライブラリがあったんで、今回はそれを使わせてもらうことにします。

Ubench.php

PHPのPEARにもベンチマークを取得するライブラリがあるらしいですが、インストールするのめんどいしね。これなら、ファイルをrequireするだけで良いから楽チン。



じゃ、行きますか。まずはforeachから。

//Ubench.phpの読み込み
require_once 'Ubench.php';
$bench = new Ubench();

//適当な配列を作成
for($i = 0; $i < 1000000; $i++) {
    $data[$i] = rand(0,9);
}

//処理時間の計測開始
$bench->start();

//ループ実行
foreach($data as $key => $val) {
   $results[] = $key * $val;
}

//処理時間の計測終了
$bench->end();

//処理時間
echo $bench->getTime();

//メモリ使用量(memory_get_usage(true))
echo $bench->getMemoryUsage();

//最大値(memory_get_peak_usage(true))
echo $bench->getMemoryPeak()

ループの中の処理は適当です。とりあえず別の変数に計算結果を入れて行くって感じで。

結果はこんな感じになりました。

処理時間・・・260ms(0.26秒)
メモリ ・・・286.25MB
最大値 ・・・286.25MB


メモリの使用量は基本変わらないんですが、処理時間は実行するたびに微妙に変わるので、何回かやって似たような値が出た時間を採用してます。

ってか、こんな単純な処理だと要素が100万個あっても、0.2秒くらいしかかからないのか……。

本当は、ループ実行の前にもメモリの使用量を計算して、ループ処理のみに使用したメモリを出すとかいう処理が必要かもしれないんですけど、今回はループ処理以外の条件が全く一緒だから、別にいいやってことで省略してます。



じゃ、次。while文。

ループ実行のところをforeachからwhileに変えるだけです。

//ループ実行
while(list($key, $val) = each($data)) {
   $results[] = $key * $val;
}

で、結果。

処理時間・・・750ms(0.75秒)
メモリ ・・・286.25MB
最大値 ・・・286.25MB


噂通り、メモリの使用量が少なくなって……ない!? 最大値も変わらず!!?

あ、あれぇ……?

これくらいの単純処理では、変わらないってことなんですかね。もっと複雑な処理をしたときに、その差が顕著になって来るとか。

それか、whileの方がメモリ食わないってのは、今は昔の話なんでしょうか。あるいは検証の仕方に問題が?

処理時間に関しては、3倍くらいになってますね。これは噂通りかな。



unset()を組み合わせた場合

データが多い方がハードディスクやメモリの使用量が多いのは当然の話で、だから配列の要素数も、処理終了時に多く残っている方がメモリの使用量は多いに違いない。

だったら、ループ処理の中でunset()して配列の要素を削っちゃえば、メモリの使用量は抑えられんじゃねーの?

//ループ処理
foreach($data as $key => $val) {
   $results[] = $key * $val;
   unset($data[$key]);
}

何となくそんなことを思ってやってみた結果がこちら。

処理時間・・・370ms(0.37秒)
メモリ ・・・157.00MB
最大値 ・・・294.50MB


unsetしていないときのforeachはこれ

処理時間・・・260ms(0.26秒)
メモリ ・・・286.25MB
最大値 ・・・286.25MB


unsetが入った分、処理時間は少し長くなっていますが、メモリの使用量は減りましたね。大幅に減りましたね。100MB以上減ったね。

でもなぜか最大値は上がった。unset実行時にメモリの消費が激しくなるとか、そんな感じなんですかね。



while文にunsetをつけたときは、こんな結果でした。

処理時間・・・620ms(0.62秒)
メモリ ・・・156.50MB
最大値 ・・・156.50MB


メモリの使用量も最大値も減っていますね。これだったのかな、while使うと節約できるって話は。

処理時間まで短くなっている(750ms => 620ms)のは何でだろう。メモリの最大値も減っているってことは、全体的に負荷が減って実行処理が早くなったのかな。PCだって、CPU使用率が高くなると処理が重くなるもんね。



そこそこ大量のデータをループ処理するような場合には、while文とunsetを組み合わせるのが、一番良いんですかね。

少なくともこの実験結果からはそうなりましたけど……どうなんでしょ?



おまけ

これまでの実験により、元の配列は処理が終わったらunsetをすればメモリの使用量は減るってことが分かりました。

じゃあ、あれ使ったらどうなるんだろう。

あれだよ、あれ。あれい。array_shift。配列の先頭の要素を引っこ抜くやつ。

while($val = array_shift($data)) {
    $result[] = $val * 2;
}

処理時間・・・500〜800ms(0.5〜0.8秒)くらい?
メモリ ・・・148.50MB
最大値 ・・・148.50MB


ああ……やっぱりと言うべきか、メモリの使用量は減りましたね。

処理時間は、何かやるたびにバラバラで、100ms以下のときもあれば1秒以上かかることもあったんで、ちょっとあいまいです。

メモリのことを考えればこれもありなのかもしれないけど、でもこれだと配列のキーが取れないね。






ってな感じで、メモリについてあれこれ考えると言いつつ、実際のところはループ処理の比較ってだけだったんですが、いかがなもんでしょ。

これから先、こういうのいろいろ出て来ると思うんですよねー。

前の会社にいたときは、受託開発をやっていたんで、次から次へと新しいウェブサイトにシステムを組み込む感じだったから、データが膨大になるってことがあまりなかったんですけど、今は一つのウェブサービスをずーっと作り続けてますからね。カスタマイズを続けるうちにカスタネットのように機能が重なり合い練りに練ったカスタードクリームのような深みを生みつついずれはカスピ海のごとき膨大なデータを扱うことになるのです。

どんどんデータが蓄積されていきますからね。それをずっと見続けているわけなので、段々とメモリがどうとか処理が重くなって来たとかデータベースのインデックスをもっと的確に張らないとダメとかサーバーの負荷がやばいとか、そういうのいっぱい出て来るんでしょうね。

まあ、また何かの折りには、こんなような検証をやってみたいと思いますわ。






おまけのおまけ

ふと気になって調べてみたんですが、配列に入れられる要素の数には、一応上限があるみたいですね。要素数というか、参照カウンタってやつが。参照カウンタって何だ?

最初の方でも言いましたけど、僕は何がどうなるとメモリの消費量が上がったりするのかもよく分からないですから、もしかしてここが原因なのかなとも最初は思ったんですよ。つまり配列の要素数が上限を上回ったりしていることが関係しているんじゃないかと。



結構昔の情報なんですが、PHP5系の場合、どうやら配列には32bit分の要素が入れられるっぽいです。32bitっていうと……だいたい42億くらいですかね。

うん、4万とか霞んで見えるわ。そりゃそうだ。4万程度で上限を迎えてたら、一流大企業の従業員データすらまともに処理出来ないかもしれないもんね。

ちなみにPHP4系の頃は16bit(65536個)だったみたいです。

配列の中身が42億件になる場合って、どんな場合だろう。日本中の電話番号を全部集めれば……でも40億には届かないか。



実際に電話番号の件数ってのがどれくらいあるのかは分からないけど、ここは一つ、フェルミ推定的な感じでどれくらいあるのか考えてみましょー。

総務省のホームページに行くと現在の市外局番の一覧が見れるので、そこからざっと眺めてみると、市外局番の数は全部で550個くらいある。結構あるな。

市内局番は2桁から4桁の数字で指定される。ここは間を取って3桁で統一しとこう。計算がめんどいし。

3桁の数字は全部で1000個あるわけだから、概算で市外局番と市内局番の組み合わせは550 × 1000 = 550000(55万)通り。でもこれ全部が使われているってことはないだろうから、2桁と4桁を除外している分も考慮しつつ適当に下方修正して、キレの良いところで50万個くらいにしときますか。

最後の下4桁の組み合わせは全部で1万個あるので、50万 × 1万 = 50億。で、これも全部使われてはいないだろうってことで、大雑把に下方修正しよう。この組み合わせのうち、7割くらいが使われていると仮定して、50億 × 0.7 = 35億くらいでどうかしら?

思ったよりあったね……それでも40億には届かないけど。ってか、日本の電話番号って、35億もあるのか? そんなにないよね、きっと。



まあ電話番号は惜しかったけど、こんな感じのデータを一挙に扱うようなことがあれば、配列が上限に達することもある……かもしれない。

匿名 2015年03月03日 14:07:37
参考になりました、感謝します
まっち~(管理人) 2015年03月05日 10:11:01
>匿名さん
こちらこそ、お役に立てて何よりです。
もしかしたら何か関連しているかも?