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

こんにちは。PHPの裏側を覗く旅へようこそ。

他の言語、例えばGoやNode.js、Javaあたりからやってきた優秀なエンジニアほど、PHPの「1リクエスト完結型」のモデルに触れたとき、独特の違和感を覚えるものですよね。「リクエストが終わればメモリなんて全解放されるんだから、細かなメモリ管理やGC(ガベージコレクション)なんて気にしなくていいんでしょ?」と。

実は、そこがPHPというエンジンの深遠なる罠であり、面白いところなんです。

今回は、PHPのパフォーマンスチューニングにおいて避けて通れない`memory_limit`とGC、そしてリクエスト終了間際のメモリ解放遅延のメカニズムについて、Zendエンジンの内部構造に潜り込みながら紐解いていきましょう。ここを理解すると、なぜ「突然のAllowed memory size exhausted」が起きるのか、その理由が手に取るようにわかるようになりますよ。

—

1. PHPのメモリ管理の基本:参照カウントと「捨てられないゴミ」

PHP(Zendエンジン)のメモリ管理の基本は、「参照カウント方式(Reference Counting)」です。
変数やオブジェクトが生成されると、C言語の構造体(`zval`)がメモリ上に確保され、そこに「今、何個の変数からこの値が参照されているか」のカウンターが保持されます。

[zval構造体] ──> 値データ (refcount: 1)

この参照カウントが `0` になった瞬間、即座にメモリはOS(正確にはZend Memory Manager:ZMM)に返却されます。非常にシンプルで効率的ですね。

しかし、ここで問題になるのが「循環参照(Circular Reference)」です。
例えば、親オブジェクトが子オブジェクトを持ち、子オブジェクトが親オブジェクトをプロパティとして保持している状態を想像してください。

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

$parent = new Node();
$child = new Node();
$parent->child = $child;
$child->child = $parent; // 循環参照の発生

// ここで変数を破棄(スコープアウト)
unset($parent, $child);

このコードを実行すると、`$parent` と `$child` の変数シンボルは消えますが、お互いを指し合っているため、それぞれの `zval` の参照カウントは `1` のまま 残ります。
参照カウントが `0` にならないため、通常の仕組みでは永遠にメモリから解放されません。これが「メモリリーク」の正体です。

—

2. 救世主か、時限爆弾か:循環参照GCの仕組み

PHPでは、この循環参照を掃除するために「リリーフメカニズムとしてのガベージコレクション(GC)」が搭載されています。

Zendエンジンは、参照カウントが「減ったけれど 0 にならなかった」複雑な構造(コンテナ)を見つけると、それを「ルートバッファ(Root Buffer)」という専用のリストに一旦溜め込んでいきます。

そして、このルートバッファが一定のサイズ(デフォルトでは `10,000` エントリ)に達するか、あるいは開発者が明示的に `gc_collect_cycles()` を呼び出したタイミングで、次のようなアルゴリズム(Concurrent Cycle Collection)が走ります。

1. バッファ内の変数を辿り、参照カウントを一時的にデクリメントしてみる
2. もし参照カウントが 0 になれば、「お互い同士で参照し合っているだけの孤立したゴミ(循環参照)」だと判定する
3. 判定されたゴミを一網打尽にマークし、sweeping(解放)する

このGC自体は非常に賢く機能するのですが、ここに「ある罠」が潜んでいます。

—

3. なぜリクエスト終了間際にメモリが跳ね上がるのか?(本題)

巨大なバッチ処理や、大量のORMエンティティをメモリ上にロード・破棄するようなWebリクエストを書いたとき、「リクエストのライフサイクルの最末端(スクリプト終了直前)」にメモリピークが訪れ、`memory_limit` に到達して死んでしまうという現象に直面したことはありませんか?

これには、Zendエンジンのメモリ管理ポリシーとGCの実行タイミングが深く関係しています。

理由①:ルートバッファが溢れるまでGCが走らない

デフォルトの設定では、循環参照の候補がバッファに10,000件溜まるまで、自動GCは本格的な回収作業を控えようとします。なぜなら、グラフ探索アルゴリズム(GCの走査)はCPUコストがそれなりに高いからです。
そのため、リクエストの途中では「ゴミの候補」がメモリ上にどんどん蓄積され、メモリ使用量のグラフは右肩上がりになっていきます。

理由②:`memory_limit` との絶妙な関係

`memory_limit`(例: `512M`)は、PHPスクリプトが消費できる最大メモリ量を定めています。
もしスクリプトが大量のオブジェクトを生成・破棄(あるいは循環参照を発生させながら処理)を繰り返していると、「実際に使われている有効なメモリ」+「まだ回収されていないゴミの山」がこの上限に近づいていきます。

そして、スクリプトの実行が終わり、いよいよ「さあ、後片付けをしよう」というリクエスト終了間際(SHUTDOWNフェーズ)になったとき、何が起きるでしょうか?

1. Zendエンジンが残存するすべての変数を強制的に片付けようとする。
2. その過程で、巨大なルートバッファや未回収の循環参照が一気に処理され、あるいはシャットダウン時の最終クリーンアップが走る。
3. この瞬間、「一時的なメモリの爆発(ピーク)」が起きる。すでに `memory_limit` のギリギリを推移していた場合、この最後の片付けのオーバーヘッドでリミットを超過し、無慈悲なFatal Errorが放たれるのです。

> 「処理自体は正常に終わったはずなのに、最後の最後で落ちる」という現象の多くは、このリクエスト終了間際のメモリ解放遅延とGCの遅延実行が原因です。

—

4. 実戦でこの壁を突破するためのアーキテクチャ知見

このメモリ解放の遅延とピーク増大を防ぎ、安定したWebシステムを構築するためには、フレームワークやライブラリのデフォルトに頼るだけでなく、以下の低レイヤを意識した設計が不可欠です。

① 大量データ処理では「明示的なGCの制御」を行う

もし数万件のレコードを処理するバッチスクリプトや重いAPIエンドポイントを書く場合、バッファが満杯になるのを待つのではなく、一定のチャンク(区切り)ごとに手動でGCを回し、メモリをこまめに地面に叩き落とすのが定石です。

// 大量データ処理のイディオム
foreach ($chunkGenerator as $chunk) {
// 重いオブジェクトの処理…
process_heavy_data($chunk);

// 参照を切る
unset($chunk);

// 必要に応じて、自動GCを補助するか、
// 循環参照が疑われる場合は明示的に回収を促す
// gc_collect_cycles(); // ※コストとトレードオフなので頻繁には呼ばない
}

※ただし、やみくもな `gc_collect_cycles()` の多用はCPUを圧迫するため、メモリリークが確実なループの脱出時などにピンポイントで使うのがプロの技です。

② そもそも「循環参照を作らない」データ構造を意識する

ORM(DoctrineやEloquentなど)の双方向リレーション(親→子、子→親)は、非常に便利ですが、まさに循環参照の温床です。
長寿命なプロセスや大量データを扱うドメインでは、不要になったタイミングでリレーションのプロパティを明示的に `null` に上書きして参照を切る、あるいは配列やDTO(Data Transfer Object)など、グラフ構造にならないシンプルなデータ構造にマッピングし直して処理するアプローチが、メモリをクリーンに保つ最大の特効薬になります。

③ FPM環境におけるプロセスのライフサイクルを理解する

PHP-FPMで稼働している場合、1つのリクエストが終わっても、メモリが即座にOSへ返還されるとは限りません(ZendMMが内部のヒープ領域として保持し続けることがあります)。
そのため、1つのリクエスト内でメモリを極限まで使い果たすような設計にしていると、連続するリクエストの処理においてフラグメンテーション(断片化)を引き起こし、じわじわとプロセス全体のメモリ消費量が肥大化していきます。`pm.max_requests` を適切に設定し、プロセスを定期的にリフレッシュすることも忘れないでください。

—

さいごに

PHPのメモリ管理やGC、そして `memory_limit` の挙動は、一見するとブラックボックスのように思えるかもしれません。しかし、その裏にある「参照カウント」と「Zendエンジンのクリーンアップのタイミング」という原理原則を知ってしまえば、もう恐れるものは何もありません。

「なぜここでメモリが跳ね上がるのか?」を脳内でコードのオペコードや `zval` の動きに変換しながら追えるようになると、あなたのPHPコードは、より美しく、より強靭なものへと生まれ変わります。

ここを制したとき、PHPはあなたにとって「ただ書きやすい言語」から「極限まで最適化できる信頼性の高いプラットフォーム」へと変わるはずです。
ぜひ、次の設計やデバッグの現場でこの知見を活かしてみてください。

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