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

はじめに:なぜ「リクエスト終了間際」にメモリが急増するのか

PHPのアプリケーション開発において、`Allowed memory size of X bytes exhausted` という致命的なエラーに直面したことはないだろうか。それも、何故か処理の最終盤、レスポンスを返却する直前という、最も防ぎづらいタイミングで発生することが多い。

多くのエンジニアは「配列が大きすぎたのか」「DBからデータを取得しすぎたのか」と考え、闇雲に `memory_limit` の値を引き上げる。しかし、それでは根本的な解決にはならない。これはPHPのメモリ管理機構、特に参照カウント(Reference Counting)とガベージコレクション(GC)の仕様、そして `memory_limit` との相互作用を理解していないがために起こる、構造的な罠である。

今回は、Zend VMの内部挙動に踏み込み、なぜリクエスト終了時にメモリ解放が遅延し、ピークメモリ使用量が跳ね上がるのか、そのメカニズムと実践的な対策をコードベースで紐解いていく。

—

1. 内部構造:Zend VMにおけるメモリ管理とGCの真実

参照カウントの限界と循環参照(Circular Reference)

PHPのメモリ管理の根底にあるのは、`zval`(Zend Value)構造体と参照カウント方式だ。変数が生成され、別の変数に代入されるたびに `zval` の refcount がインクリメントされ、スコープを抜けるなどして変数破棄されるとデクリメントされる。refcount が 0 になった瞬間、そのメモリは即座にヒープから解放(`emalloc` の逆操作)される。

問題は、オブジェクトや配列が互いを参照し合う循環参照が発生したときだ。
親が子を持ち、子が親のインスタンスをプロパティに保持するような構造を作ると、スコープを抜けてルート変数が消滅しても、お互いの refcount が `1` 残ったままになる。この「孤立した循環参照」は、通常の参照カウントでは絶対に `0` にならない。

`memory_limit` はどうGCのトリガーを引くのか

PHPのガベージコレクション(正確にはリクエスト中のバックグラウンドGC)は、デフォルトでは「バッファがいっぱいになったとき」にのみ作動する。
Zend Engineは、潜在的な循環参照の可能性がある `zval`(主にコンテナ型:配列とオブジェクト)を、ルートバッファ(Root Buffer)と呼ばれる二重リンクリストにバッファリングしていく。

ここで `memory_limit` がどう関わってくるか。
PHPプロセスが消費するメモリ量が `memory_limit` に近づく、あるいは特定のルート数(デフォルトでは `gc_enabled()` の下で一定のしきい値)に達するまで、Zend VMはあえて積極的なGCサイクルを回さない。 なぜなら、GCのマーク・アンド・スウィープ(Mark-and-Sweep)アルゴリズムはCPUサイクルを激しく消費するため、リクエスト処理のスループットを落とす原因になるからだ。

結果として、アプリケーションが巨大なオブジェクトグラフを構築しながら処理を進めると、メモリはどんどん肥大化する。そして、「まだルートバッファの制限に達していない、あるいは明示的なGCが走っていない」状態のままリクエスト終了間際に突入する。

リクエスト終了時の「一括解放」の代償

処理が完了し、SAPI(Server API:FPMやCLIなど)のライフサイクルが `request Shutdown` フェーズに入ると、Zend Engineは残存しているすべてのメモリを一括して解放する。

「最後にまとめて解放されるなら問題ないのでは?」と思うかもしれない。ここに大きな誤解がある。
巨大なオブジェクトグラフが循環参照を抱えたまま放置され、ルートバッファがいっぱいになった瞬間、あるいはシャットダウンの最終局面に突入すると、PHPは溜まりに溜まった数百万個の `zval` に対して一気にGCのマーク・アンド・スウィープを走らせ、OSへメモリを返却しようとする。

この「終了間際のクリーンアップ処理」の重みこそが、プロセスの寿命を不必要に引き伸ばし、ピークメモリ使用量を跳ね上げ、FPMのワーカーを詰まらせる真犯人なのだ。

—

2. アンチパターン:メモリ効率を無視したコード構造

以下のコードを見てほしい。一見、綺麗にカプセル化されたオブジェクト指向のコードに見えるが、メモリ管理の観点からは最悪の構造をしている。

payload = str_repeat(‘A’, 1024 1024 2);
}

public function addChild(Node $child): void {
$child->parent = $this; // ★ここで強烈な循環参照が発生
$this->children[] = $child;
}
}

// 大量のツリー構造を構築する処理
function processTree(): void {
$root = new Node(‘Root’);
for ($i = 0; $i < 50; $i++) { $child = new Node("Child {$i}"); // さらに孫ノードを作るなどして構造を複雑化 for ($j = 0; $j < 10; $j++) { $grandChild = new Node("GrandChild {$i}-{$j}"); $child->addChild($grandChild);
}
$root->addChild($child);
}

// $root がスコープを抜けるが、循環参照のため即座には解放されない!
}

processTree();
// この時点で数百MBのメモリが宙ぶらりんになり、
// リクエスト終了時のシャットダウンフェーズで一気にGCが発動してCPU/メモリを圧迫する。

このコードでは、親から子、子から親へ双方向の参照があるため、`processTree()` を抜けてもメモリは解放されない。ピークメモリは跳ね上がり、FPMのレスポンス返却が数ミリ秒遅延することになる。

—

3. 堅牢な設計とリファレンスコード

テクニカルリードとして、このようなメモリリークおよびピークメモリの肥大化を防ぐための実践的な設計ルールを提示する。

黄金律

1. 双方向参照(Parent-Child)を極力避ける。 どうしても必要な場合は、弱参照(WeakReference)を活用する。
2. 巨大なデータ構造を扱う処理は、スコープを細かく分割し、不要になったら明示的に `unset()` する。
3. バッチ処理やAPIのエンドポイントで大量のオブジェクトを生成する場合は、適宜 `gc_collect_cycles()` を手動で叩くか、処理をチャンク(分割)する。

以下に、PHP 7.4以降で導入された `WeakReference` を用いて循環参照を完全に断ち切った、実務に耐えうる美しいリファレンスコードを示す。

  • 循環参照を回避しつつ、安全に階層構造を構築するクラス
  • /
    class SafeNode
    {
    / @var WeakReference|null /
    private ?WeakReference $parentRef = null;

    / @var array /
    private array $children = [];

    private string $payload;

    public function __construct(string $payload)
    {
    // 2MBのペイロードを仮定
    $this->payload = str_repeat(‘X’, 1024 1024 2);
    }

    public function addChild(SafeNode $child): void
    {
    // 親から子への参照は通常の強参照
    $this->children[] = $child;

    // 子から親への参照には WeakReference を使用する
    // これにより循環参照カウントが発生せず、メモリが即座に解放可能になる
    $child->parentRef = WeakReference::create($this);
    }

    public function getParent(): ?self
    {
    return $this->parentRef?->get();
    }

    /

    • メモリ使用状況を監視しつつ安全にツリー処理を実行する例

    /
    public static function executeWorkload(): void
    {
    $root = new self(‘Root’);

    for ($i = 0; $i < 20; $i++) { $child = new self("Child {$i}"); $root->addChild($child);
    }

    // 処理完了後、ルートを明示的に解放
    // WeakReferenceのおかげで、ここで一瞬にして全ノードのメモリが開放される
    unset($root);

    // 必要に応じて明示的なGCを誘発(高負荷バッチなどの場合)
    $collected = gc_collect_cycles();

    // 開発・デバッグ時のログ出力(本番ではメトリクスへ送信)
    error_log(sprintf(‘GC executed: %d cycles collected. Current memory: %s MB’,
    $collected,
    number_format(memory_get_usage(true) / 1024 / 1024, 2)
    ));
    }
    }

    // 実行
    SafeNode::executeWorkload();

    —

    4. 運用・監視の知見:メトリクスとチューニング

    コードレベルの対策に加え、Webアプリケーション全体のアーキテクチャとして以下の設定と監視を取り入れるべきだ。

    1. `memory_limit` の適切なサイジング

    `memory_limit = 512M` のように安易に大きく設定するのは、メモリリークを隠蔽するだけであり、OSのOOM Killer(Out of Memory Killer)に突然プロセスが撃ち落とされるリスクを先送りしているに過ぎない。
    通常のAPIであれば `128M ~ 256M` 程度に絞り、それを超えるような巨大なデータ処理が必要な場合は、ワーカープロセスを分けるか、ストリーミング処理(Generator)へリファクタリングするべきだ。

    2. APM(Application Performance Monitoring)によるピークメモリ監視

    DatadogやNew RelicなどのAPMツールを導入し、「Webトランザクションごとのピークメモリ使用量(Peak Memory)」を必ずダッシュボードに常駐させよ。
    平均メモリ使用量が低くても、ピークメモリが右肩上がりに上昇しているエンドポイントがあれば、それは遅延したGCや隠れた循環参照のシグナルである。

    3. FPMプロセスのリクエスト制限(`pm.max_requests`)

    PHP-FPMの `pm.max_requests` を適切に設定する(例:`1000` または `5000`)。これにより、どれほど完璧にコードを書いても防ぎきれないごく微小なフラグメンテーションや、拡張モジュール(C言語製ライブラリ)由来のメモリリークであっても、一定リクエストごとにプロセスごと綺麗に消滅・再起動され、システム全体が健全に保たれる。

    —

    おわりに

    PHPのガベージコレクションとメモリ管理は、単なる「言語の裏側の仕組み」ではない。それらを無視したコードは、高負荷時に突如としてレイテンシの悪化やサーバーダウンを引き起こす時限爆弾となる。

    「なぜこのデータ構造なのか」「この参照は本当に強参照である必要があるのか」。
    コードレビューの場において、常にZend VMのメモリ空間の動きを脳内トレースし、リクエスト終了間際の静けさに騙されない堅牢な設計を貫いてほしい。真のエンジニアリングとは、表面上の動作確認の先にある、システム全体のライフサイクルに対する徹底的な制御にある。

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