PHP でのメモリ管理について
最近、メモリを大量に使うプログラムを組んでいまして、メモリ管理にかなり困っていました。 自分では全然メモリを使っているつもりはないのに、すぐにメモリ不足になってしまうのです。 自分が思っていないタイミングで予期せず大量に確保されたり、思っていたタイミングでメモリが解放されなかったりして、 全然思ったようにならなかったのです。 この沼はかなり深い気がするので、ハマったポイントを備忘録としてメモしておきます。 メモリをシビアに管理しようとするとなかなか大変です。
思っていないタイミングでメモリが確保されるケース
まずはこれ。 予期せずメモリが大量に確保されるケースです。
下記のコードを見てください。
これは、関数を呼ぶ際の引数を渡すところで、変数に改行コードを追加をして渡しているコードです。
よくある普通のコードだと思います。
$large
には、めちゃくちゃ長い文字列が入っていると仮定してください。
例えば、めちゃくちゃ大きいファイルの中身が入っていて、数メガバイトあるような文字列だとしてください。
このコードを実行すると、関数 hoge()
を呼んだ瞬間にメモリが大量に確保されます。
このコードのいったい何が問題なのかわかりますでしょうか?
<?php $large = '(省略:めちゃくちゃ長い文字列)'; hoge($large . "\n");
原因
実はこれ、hoge()
関数を呼んだ時点で、
メモリ上では $large
と $large . "\n"
の2つ分のメモリが必要になっています。
この $large
の中身が小さければ、このコードでも問題はないのですが、
もし、この $large
の中身がめちゃくちゃ長い文字列(例えば、めちゃくちゃ大きいファイルの中身)のようなものであった場合、
このような呼び方をしただけで、必要なメモリ量が $large
の約2倍となり、メモリ不足に陥る原因になることがあります。
ただ改行コード "\n"
を追加しているだけなので、1 byte 増えるだけだろうぐらいに思っていると痛い目に合います。
$large
と $large . "\n"
は別物ですので、
関数の引数用として新たに $large . "\n"
のためのメモリを確保しなければならないのです。
実際にこれが原因でメモリ不足に陥るプログラムを偶然書いてしまい、しばらく原因がわからなくてハマってしまいました。 全然メモリを使ってるつもりがないのに、関数の中に入った瞬間に使用しているメモリが大量に増え、 関数から抜けたら減るのですから、そりゃ普通は気付きませんよという話です。 とはいえ、考えてみれば当然と言えば当然のような気がしますね。
解決策
解決策としては、同じ1つの変数として処理できるなら処理しておくということです。 別物として扱うからその分の余分なメモリが必要になるのであって、 同じものとして扱えるのであれば必要最低限のメモリで済みます。
PHP にはコピーオンライト(Copy-On-Write)という仕組みがあり、 書き込まれたときだけコピーして新たにメモリを確保するという仕組みになっているため、 同じ変数を参照しているだけであれば、新たにメモリが確保されることはありません。 まぁ、変数の中身が小さければこんなことは別に気にしなくても良いと思うのですが、 めちゃくちゃに大きい場合は気にした方が良いかもしれません。
<?php $large = '(省略:めちゃくちゃ長い文字列)'; // 引数に渡す前に処理しておく // これなら、改行コード分増えるだけ $large .= "\n"; hoge($large);
思っていたタイミングでメモリが解放されないケース
もう一つのケースは、思っていたタイミングでメモリが解放されないケースです。 適当にコードを書いていると、メモリがいつまでたっても解放されないという現象に出会うことになります。
下記のコードを見てください。
① で確保した画像のメモリが、② の 関数 destroy()
で解放されることを期待しています。
ですが、実際には、そのタイミングでは画像のメモリは解放されません。
では、実際にどのタイミングで画像のメモリが解放されるかわかりますでしょうか?
<?php $image = imagecreatetruecolor(2000, 2000); // ① destroy($image); // ② function destroy(GdImage $image) { // 画像のメモリを解放 imagedestroy($image); $image = null; // ③ }
原因
imagedestroy()
は、実はPHP 8.0 以降は何も処理をしない(呼んでもメモリを解放しない)のですが、念のため書いてます。
実際には、その下の ③ の $image = null;
で、メモリが解放されることを期待して書かれたコードです。
PHP の変数にはリファレンスカウントという、その変数が参照されている回数が記録されていて、
そのリファレンスカウントが 0 になったら、もうその変数は使用されていないということになり、
使用していたメモリが解放されるという仕組みになっています。
そこで、$image
に null
を代入にすることで、リファレンスカウントを 0 にして画像のメモリを解放させようとしています。
unset()
でも良いのですが、null
を代入した方が処理速度が速いのでそうしています。
しかし、期待とは裏腹にこの ③ のタイミングでは画像のメモリは解放されません。
何故なら、呼び出し元 ① の $image
がまだ残っており、リファレンスカウントが 0 になっていないのです。
関数の中でメモリを解放しようとしても、呼び出し元に戻るまでメモリが解放されないのです。
というか、このコード、実は最後の最後までずーーっと画像のメモリは解放されません。 なので、「どのタイミングで画像のメモリが解放されるか?」の答えは「解放されない」ということになります(もしくは PHP 終了時点)。
解決策1:呼び出し元でちゃんとクリアする
解決策の1つ目の方法は、呼び出し元でリファレンスカウントをちゃんとクリアしてあげることです。
例えば、戻り値で null
を返して呼び出し元の変数を上書きするとか、明示的に null
を代入するなどです。
ただこの方法では、結局のところ呼び出し元に戻るまでメモリが解放されることはないので、関数の中ですぐにメモリを解放したい場合は別の方法をとる必要があります。
null
を代入する場合
<?php $image = imagecreatetruecolor(2000, 2000); $image = destroy($image); // 戻り値を受け取ったタイミングで解放される function destroy(GdImage $image) { // 画像のメモリを解放 imagedestroy($image); $image = null; // ここでは解放されない return $image; }
null
を代入する場合
<?php
$image = imagecreatetruecolor(2000, 2000);
destroy($image);
$image = null; // このタイミングで解放される
function destroy(GdImage $image)
{
// 画像のメモリを解放
imagedestroy($image);
$image = null; // ここでは解放されない
}
解決策2:関数による階層構造にしない
解決策の2つ目の方法は、関数による階層構造を可能な限りしないことです。 リファレンスカウントが呼び出し元に残っているからメモリが解放されないのであれば、呼び出し元に残さなければ良いのです。 1つの関数の中で全てを処理してしまえば良いのです。
ただこれは、コードをメンテナンスするのが難しく、 わざとそういう書き方をしているということを忘れてしまったりすると、 すぐに思ったタイミングでメモリが解放されなくなることでしょう。 関数の中で、すぐに別の関数を呼びたくなるものです。 根本的な解決策とは言い難いかもしれません。
良い点としては、わざわざ明示的にメモリを解放しなくても、 関数を抜けた時点でリファレンスカウントが 0 になるので、 勝手にメモリが解放されることでしょうか。
<?php
draw();
function draw()
{
$image = imagecreatetruecolor(2000, 2000);
// 画像のメモリを解放
imagedestroy($image);
$image = null; // このタイミングで解放される(処理しなくても良い)
}
解決策3:参照渡しにして、関数内から書き換える
解決策の3つ目の方法は、呼び出し元の変数を参照渡しにして、関数内から書き換えることです。
参照渡しであれば、関数の中で null
にしたタイミングでメモリが解放されます。
参照渡しは少しトリッキーなコードになりがちなので、コードをメンテナンスするのが少し難しくなるのが欠点と言えば欠点かもしれません。
<?php $image = imagecreatetruecolor(2000, 2000); destroy($image); function destroy(GdImage &$image) { // 画像のメモリを解放 imagedestroy($image); $image = null; // このタイミングで解放される }