PHPの変数を明示的に解放するべきタイミング

これは フェンリル デザインとテクノロジー Advent Calendar 2022 25日目の記事です。

インフラ / バックエンド担当の森井です。 少し前に社内でPHPにおける変数の解放タイミングについて議論になったことがあり、その際調べたことをブログにまとめようと思います。

※ PHP−FPM等で動作させるウェブサーバーとしての実行環境を想定しています。

リクエスト間のメモリ

まずはPHPのライフサイクルを確認しましょう。

Every time a new request arrives to be handled, PHP will run a request startup step. We call it the RINIT.

The request is served, some content is (probably) generated, OK. Time to shutdown the request and get prepared to eventually handle another one. Shutting down a request is called the request shutdown step. We call it the RSHUTDOWN.

Learning the PHP lifecycle — PHP Internals Book

RINITでリクエストのバインドされたメモリが確保され、RSHUTDOWNで解放されます。シェアードナッシングモデルと言われるものですね。 コードの中でいくら変数を割り当てようが*1、すべては該当リクエストが処理されている間しか確保されません。

PHPのライフサイクル。リクエストごとにメモリ確保と解放を繰り返している。
PHPのライフサイクル

ウェブサーバーとして動作させている場合はリクエストの処理時間というのはミリ秒単位のことが多いので、PHPにおけるメモリ管理はあくまでその範囲内だと言えるでしょう。

リクエスト処理中のメモリ

リクエストにバインドしてメモリを確保 / 解放することがわかりました。では、メモリ管理を意識する必要はないのでしょうか。

もちろんそんなことはありません。PHP-FPMなどを使用して、複数プロセスでリクエストを処理する場合、1リクエストの処理に必要なメモリ量が減るのは良いことです。 1リクエストの処理に必要なメモリ量が少なければ並列度をあげられるかもしれませんし、サーバースペックを落としてコストパフォーマンスを良くすることもできるかもしれません。

今度はリクエスト処理中のメモリ管理について見ていきましょう。ここで Garbage Collection(以降、GC)の出番です。 PHPはリファレンスカウント方式のGCを持っています。

GCはどのような動きをするでしょうか。 PHPにおける変数は zval という構造体で保管されています。zval がもつ _zend_refcounted_h というヘッダーを見てみましょう。

typedef struct _zend_refcounted_h {
    uint32_t         refcount;         /* reference counter 32-bit */
    union {
        uint32_t type_info;
    } u;
} zend_refcounted_h;

php-src/zend_types.h at PHP-7.4.33 · php/php-src · GitHub

refcount というプロパティがありますね。これがリファレンスカウント方式の肝です。*2 新しい zval が生成されると、refcount のもつ値(リファレンスカウント)がインクリメントされます。使用されなくなるとデクリメントされます。 リファレンスカウントがゼロになると、この値は不要となりますのでメモリを解放できるようになります。 このとき、解放できる状態になっただけで、実際に解放されるかはGCの実行タイミングによることに気をつけてください。

つまり、このGCの挙動からしてメモリ使用効率の悪い状態というのは「リファレンスカウントがゼロでない変数が大きく、長く確保されること」です。

これを防ぐためにプログラマがとれる手段がきちんと用意されています。これがunset関数です。

unset — 指定した変数の割当を解除する

PHP: unset - Manual

明示的に解放する必要がないパターン

ローカル変数の解放について見ていきます。 実行環境はphp:7.4-cliです。 PHP-FPMで検証するのは少し面倒なのでCLI版でご容赦下さい...

<?php

function localFunc()
{
    echo "local before allocate: " . memory_get_usage() . "\n";    // local before allocate: 391824
    $bigVariable= str_repeat("a", 10000);
    echo "local after allocate: " . memory_get_usage() . "\n";    // local after allocate: 404112
    return;
}

echo "start: " . memory_get_usage() . "\n";    // start: 391792
localFunc();
echo "finish: " . memory_get_usage() . "\n";    // finish: 391824

$bigVariable を割り当てた直後はメモリ使用量が増えていますが、スコープを抜けた(リファレンスカウントがゼロになった)段階で変数割り当て前のメモリ使用量に戻りました。 ローカルスコープの変数はスコープを抜けた時点で解放対象となるので、明示的に解放するコードを書く必要はないでしょう。

明示的に解放する必要があるパターン

<?php

function localFunc()
{
    echo "local before: " . memory_get_usage() . "\n";
    for ($i=0; $i < 100000; $i++) { 
        $array[$i] = str_repeat("a", 10000);
    }
    echo "local after: " . memory_get_usage() . "\n";
    return;
}

echo "start: " . memory_get_usage() . "\n";
localFunc();
echo "finish: " . memory_get_usage() . "\n";

実行したところ、

Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 12288 bytes) in /var/php/for.php on line 7

が発生しました。使用できるメモリ使用量を超えてしまったようです。

for文の中で配列の各要素はリファレンスカウントがゼロになっていないので、10万回の繰り返しの中でメモリを開放できなかったようです。 では、for文の中で都度unsetしてみましょう。

<?php

function localFunc()
{
    echo "local before: " . memory_get_usage() . "\n";    // local before: 392696
    for ($i=0; $i < 100000; $i++) { 
        $array[$i] = str_repeat("a", 10000);
        unset($array[$i]);
    }
    echo "local after: " . memory_get_usage() . "\n";    // local after: 393072
    return;
}

echo "start: " . memory_get_usage() . "\n";    // start: 392664
localFunc();
echo "finish: " . memory_get_usage() . "\n";    // finish: 392696

今度は実行できました。for文の中でunsetを実行する事によりGCによって変数のメモリを解放できるようになったためです。 このようなコードはそもそも設計が悪いとも言えると思いますが、明示的に解放するという手段も持っておくと良いでしょう。

まとめ

PHPにおけるメモリ管理はリクエスト処理の中身だけ考えれば良く、リクエスト処理の中ではGCが解放してくれるのでメモリ管理をあまり気にする必要はありません。

ただし、下記のような場合明示的な解放を検討すると良いのではないでしょうか。 少々検証が足りない気もしますが、推測も織り交ぜつつ指針を導き出してみました。

  • グローバル変数:メモリ使用量が多い場合
  • ローカル変数:メモリ使用量が多く、(後続の処理等で)長く確保したままになってしまう場合。またはスコープ内で繰り返し割り当てる場合。

また、本記事のスコープからは外していますが、バッチ処理やロングポーリングのAPI等についてはこの限りではないでしょう。 システムの性質に沿った、メモリ使用効率の良いコードを書けるようにしたいものですね。

あとがき

今回PHPの挙動を調べていく上で PHP Internals Book がとても役に立ちました。PHPコアの情報はあまり多くないので、時間があれば他のページも読んでみたいです。 www.phpinternalsbook.com

*1:APCuを使用する場合や、実行環境の真のグローバルに書き込む場合は除きます。

*2:この方式は常に使用されるわけではないようです。詳しくはこちらを参照してください。