【入門編】PHPの`memory_limit`設定とGCの相互作用:リクエスト終了時のメモリ解放遅延とピークメモリ使用量への影響 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

こんにちは。日々のWebアプリケーション開発、本当にお疲れ様です。

LaravelやSymfonyといったモダンなフレームワークを使いこなし、JavaやGo、Node.jsといった他言語のメモリ管理モデルも知っているあなたなら、きっと一度はこう感じたことがあるはずです。

「あれ、このリクエスト、処理終わったはずなのに、なんでまだメモリを保持しているんだろう?」
「`memory_limit` に突然ヒットしたけれど、ちゃんと変数はスコープアウトさせているはずなのに……」

他の言語からPHPに入ったエンジニアが最も戸惑うポイント、そしてシニアの扉を叩くエンジニアが必ず直面する壁が、この「PHPのメモリ管理とガベージコレクション(GC)の裏側」です。

今回は、PHPがZendエンジン内部でどのようにメモリを割り当て、解放し、そしてあの恐ろしい `memory_limit` とGCがどう裏で綱引きをしているのか、その真実を紐解いていきましょう。ここを理解すると、PHPの挙動が手に取るように美しく見えてきますよ。

—

1. 散らかった部屋の片付け:参照カウントと循環参照の限界

まずは基本のおさらいから。PHP(Zendエンジン)のメモリ管理の根底にあるのは、おなじみの「参照カウント(Reference Counting)」です。

Zendエンジンの内部データ構造である `zval`(PHPの変数を表現する構造体)には、その変数を参照している数が `refcount` という整数値で記録されています。変数が別の変数に代入されたり、関数に渡されたりすると `refcount` がインクリメントされ、スコープを抜けるなどして不要になるとデクリメントされます。

この `refcount` が `0` になった瞬間、即座にその `zval` が占有していたメモリはエージェント(Zendメモリマネージャ: Zend MM)に返却されます。これが、PHPが「変数を解放する」仕組みの基本です。

厄介な「循環参照」の罠

しかし、ここで問題が起きます。例えば、オブジェクト同士が互いにプロパティとして参照し合うような「循環参照(Circular Reference)」構造を作った場合です。

class Node {
public ?Node $child = null;
}

$a = new Node();
$b = new Node();

$a->child = $b;
$b->child = $a; // 循環参照の発生

// ここで $a と $b のスコープを外した(unsetした)とする
unset($a, $b);

このコードを実行したとき、$a と $b が指し示していたオブジェクトの `zval` の参照カウントは、お互いを指し合っているため `0` になりません(`refcount = 1` のまま残ります)。
結果として、「誰もアクセスできないけれど、メモリ上からは消えない幽霊データ(メモリリーク)」が誕生します。

この幽霊たちを回収するためにPHPに組み込まれているのが、「循環参照ガベージコレクタ(Concurrent Cycle Collector)」です。

—

2. GCは「掃除機」ではなく「ゴミ袋がいっぱいになってから動く自動回収機」

他言語(JavaやGoなど)のGCのイメージを持っていると、「不要になったら裏でこまめにキレイにしてくれるんでしょ?」と思いがちですが、PHPのGCは全く異なります。

PHPのGCは、常にメモリを監視しているわけではありません。
Zendエンジンは、参照カウントが減ったものの `0` にならなかった `zval`(可能性のあるもの)を、「ルートバッファ(Root Buffer)」という専用のリストにコツコツと溜め込んでいきます。

そして、このルートバッファが一定のサイズ(デフォルトでは `10,000` エントリ)に達した瞬間、あるいは明示的に `gc_collect_cycles()` が呼ばれた瞬間にだけ、重いスキャン処理(バッファ内の変数を辿って循環参照を探すアルゴリズム)を実行します。

つまり、PHPのGCは「暇なときにちょこちょこ掃除する」のではなく、「ゴミ袋がパンパンになってから、一度作業を止めて大掃除を始める」タイプなのです。

—

3. `memory_limit` 設定がGCとリクエスト終了時に与える影響

ここで、本日の本題である `memory_limit` との相互作用の話に入りましょう。

Webアプリケーション(PHP-FPM環境)において、1つのリクエストは独立したプロセス(またはスレッド)のライフサイクルを持ちます。リクエストが開始され、大量のオブジェクト生成やデータベースからの巨大な結果セットの処理を行い、レスポンスを返して終了する――この一連の流れの中で、メモリは次のように振る舞います。

ピークメモリ使用量(Peak Memory Usage)の正体

よく「メモリリークしていないはずなのに、なぜかリクエストの途中で `Allowed memory size of xxx bytes exhausted` になる」という現象が起きるのはなぜでしょうか?

それは、「GCが動くタイミング(ルートバッファが満杯になる閾値)よりも前に、一時的なオブジェクトの生成量が `memory_limit` を超えてしまった」からです。

[リクエスト開始]
↓ 大量のオブジェクト生成 (zvalが次々とルートバッファへ)
↓ バッファが満杯になる前に memory_limit に到達 💥 FATAL ERROR!
[リクエスト終了]

PHPのGCは、バッファが溢れるか、明示的に呼ばれない限り発動しません。そのため、数万件のレコードをループで処理し、その都度古いインスタンスの参照を捨てているつもりでも、参照カウントが0にならずルートバッファに溜まり続け、GCが一度も走らないまま `memory_limit` の天井に激突するという悲劇が起きます。

リクエスト終了時のメモリ解放遅延の錯覚

もう一つの現象が、「リクエストの処理は終わったのに、FPMプロセスのメモリ使用量がなかなか下がらない、あるいは次のリクエストに引き継がれているように見える」というものです。

これには2つのレイヤーが関わっています。

1. Zend MM(Memory Manager)のキャッシュ戦略
PHPは、OSからメモリを細かく取得・返却するオーバーヘッドを嫌います。一度リクエスト内で確保したメモリブロックは、リクエストが終了して変数がすべて破棄された後も、Zend MMが「次のリクエストでまたすぐ使うかもしれないから、プールしておこう」と内部で保持し続けます。そのため、OSから見たプロセス全体のメモリ使用量(RSS)は減っていないように見えます。これはメモリリークではなく、パフォーマンス最適化のための仕様です。
2. 長寿命プロセス(PHP-FPM)におけるGCのタイミング
デーモン的に動き続けるFPMプロセスにおいて、もしスクリプト内で一度もGCの閾値に達しなかった場合、ガベージコレクションはリクエストのライフサイクル全体で一度も実行されていない可能性があります。

—

4. アーキテクトが実践するべき現場の最適化アプローチ

このメカニズムを踏まえると、巨大なデータを扱うバッチ処理や、メモリプレッシャーの高いAPIエンドポイントを設計する際に、どのような対策を講じるべきかが見えてきます。

① バッチ処理では「明示的なGCの制御」を行う

もし数万件ループを回すような重厚な処理を書く場合、デフォルトのGC任せにするのは危険です。一定のイテレーションごとに、意図的にゴミを回収させましょう。

  • 処理用モック関数
  • /
    function process_heavy_data(int $index): void {
    $obj = new stdClass();
    $obj->data = str_repeat(‘A’, 1024 10); // 10KBのデータ
    // あえて循環参照を作るような複雑な構造をここで展開している想定
    }

    ② そもそも「循環参照」を作らない設計にする

    最もクリーンで高速な解決策は、GCに頼る必要のないコードを書くことです。
    親が子を持ち、子が親を持つような双方向の参照(ドメインモデルでありがちな構造)を避け、ID(スカラー値)による参照に置き換えるだけで、Zendエンジンは参照カウントだけで瞬時にメモリを解放できるようになります。

    ③ `memory_limit` を場当たり的に上げない

    エラーが出たからといって、`.user.ini` や `php.ini` で `memory_limit = 2G` のように無駄に引き上げるのは、アーキテクトとしては悪手です。
    これは「ゴミ屋敷の片付け方を工夫する代わりに、もっと広い家(高いサーバー代)に引っ越す」ようなものです。本当にそのメモリが必要なのか、アルゴリズムの計算量(O記法)やストリーム処理(Generatorの活用など)でメモリフットプリントを削れないかを常に疑ってください。

    —

    5. まとめ

    PHPのメモリ管理と `memory_limit` の関係をまとめると、以下のようになります。

    • 参照カウントは即座にメモリを解放するが、循環参照には無力。
    • GCはリアルタイムではなく、バッファが満杯になったとき、または明示的に呼ばれたときにしか動かない。
    • `memory_limit` は、その「GCが動く閾値」よりも前に限界を迎えることが多いため、巨大データ処理では自衛が必要。
    • リクエスト終了後のメモリ保持は、多くの場合Zend MMによる次への再利用キャッシュである。

    PHPの裏側にあるZendエンジンの息遣いを感じられるようになると、コードのパフォーマンスを予測し、コントロールする楽しさが何倍にも膨れ上がります。

    「なんとなく動く」から「なぜ動くのかが手に取るようにわかる」へ。
    あなたのエンジニアリングが、さらに研ぎ澄まされるきっかけになれば幸いです。

    タイトルとURLをコピーしました