こんにちは。大規模なWebシステムの裏側を支えていると、ふと「PHPって、リクエストが終わったら勝手にメモリをキレイにしてくれるんでしょ?」という幻想にぶつかる瞬間がやってきますよね。JavaやNode.jsのような常駐型アプリケーションのメモリ管理に慣れている優秀なエンジニアほど、PHPの「1リクエスト=使い捨ての王国」という独特のライフサイクルに油断しがちです。
でも、近年のPHP 8.x環境では、長大なバッチ処理、APIのループ、あるいは重厚長大なフレームワークの多重ブートによって、1リクエストの寿命がかつてないほど長く、重くなっています。ここで問題になるのが、Zendエンジンが裏側でひっそりと行っているガベージコレクション(GC)の挙動です。
今回は、PHP 8.xにおけるGCの内部メカニズムと、`gc_param`(`zend.gc_max_cycles`など)をチューニングして「メモリ安全」と「CPU性能」の極限のバランスを取る方法について、エンジン内部のZend VMの息づかいを感じながら紐解いていきましょう。ここを理解すると、PHPの裏側がまるで精密機械のように美しく見えてきますよ。
—
1. Zendエンジンのメモリ管理の基本:参照カウントと「黒い羊」
PHPのメモリ管理の根底にあるのは、お馴染みの参照カウント(Reference Counting)です。変数や配列、オブジェクトが生成されると、Zendエンジン内部の構造体(`zval`)にその参照数が記録されます。
通常、変数がスコープを抜けるか、`unset()`されると、その参照カウントはデクリメントされ、ゼロになった瞬間にメモリ(emalloc空間)へ即座に返却されます。これは非常に高速で、C言語的なアロケーションの美しさを持っています。
しかし、ここで「循環参照(Circular Reference)」という厄介な問題が生まれます。
child = $b;
$b->child = $a;
unset($a, $b);
// ここで $a と $b の実体はメモリ上に「幽霊」として取り残される!
`unset($a, $b)`を実行しても、お互いを指し示すポインタが消えないため、それぞれの参照カウントは `0` になりません。このまま放置すると、ロングランするデーモンプロセスや巨大なバッチ処理では、確実にメモリリーク(Out of Memory)を引き起こします。
この「参照カウントの網の目をすり抜けた循環参照のゾンビたち(Zendエンジン用語では Buffered Cycles と呼びます)」を回収するのが、PHPのガベージコレクタの役割です。
—
2. PHPのGCアルゴリズム:停止・マーク・スイープの裏側
PHPのGCは、PythonやGo、Javaのようなリアルタイムな全世代型GCとは異なり、非常に割り切りた König な「バッファリング型・遅延回収アルゴリズム」を採用しています。
Zendエンジンの内部では、以下のようなステップでGCが動作しています。
1. バッファへの蓄積(Buffer Allocation)
参照カウントが減ったものの、ゼロにならなかった `zval`(可能性のあるコンテナ)は、自動的にGCバッファ(デフォルトで10,000スロット)にリング状に記録されます。
2. バッファの満杯検知
このバッファが一杯になると、あるいは明示的に `gc_collect_cycles()` が呼ばれたときに、GCの巨人が目を覚まします。
3. 三色マーキング(Tricolor Marking)
エンジンはバッファ内のノードを辿り、
- 白色(White): ゴミの候補
- 灰色(Grey): スキャン中
- 黒色(Black): 外部から参照されている健全なデータ
としてマークしていきます。
4. スイープと解放
最終的に白色のまま残ったノードだけを、循環参照の塊として一網打尽にメモリから削ぎ落とします。
この仕組みはWebリクエストのライフサイクルを邪魔しないように絶妙に設計されていますが、「バッファが溢れるタイミング」や「GCが走る頻度」をデフォルトのまま放置していると、予期せぬCPUスパイクを引き起こす原因になります。
—
3. `gc_param`(`zend.gc_`)の正体とチューニングの指針
PHP 8.xでは、`php.ini` や実行時の `ini_set()` を通じて、GCの挙動を精密にコントロールするためのディレクティブが用意されています。代表的なものを整理してみましょう。
| ディレクティブ名 | デフォルト値 | 役割と意味 |
| :— | :— | :— |
| `zend.enable_gc` | `On` | GC自体の有効/無効。基本はOnですが、極限のベンチマーク時はオフにすることも。 |
| `zend.gc_max_cycles` | `10000` | GCバッファの最大サイズ。この数に達すると自動的にGCサイクルが発動します。 |
なぜ `gc_max_cycles` の調整が必要なのか?
デフォルトの `10000` という値は、一般的なWebアプリケーション(WordPressや通常のCRUDフレームワーク)においては妥協点として優れています。しかし、次のような現場では致命的なボトルネックになります。
- 巨大なオブジェクトグラフを操作するAPI / GraphQLサーバー
数万件のORMエンティティをメモリ上にロードし、リレーションを張り巡らせるような処理では、あっという間に `10000` のバッファが埋まります。結果として、「ビジネスロジックの最中に、予期せぬタイミングで重いGC(マーク&スイープ)が頻発する」という現象が起きます。これがCPU使用率を跳ね上げ、レイテンシを悪化させる犯人です。
逆に、値をむやみに大きくしすぎると、今度はGCが走ったときの1回あたりの負荷(処理時間)が跳ね上がり、レスポンスタイムに「プチフリーズ」のような揺らぎを生むことになります。
—
4. 実戦:メモリとCPUのトレードオフを制御するコードと設計
では、実際の開発現場やアーキテクチャ設計において、どのようにこの特性と向き合えばよいでしょうか。
例えば、大量のデータをバッチ処理でループさせるスクリプトを書いてみましょう。
id = $i;
$node->self = $node; // 循環参照
// 定期的な手動制御(バッファが溢れてCPUがロックするのを防ぐ)
if ($i % 50000 === 0) {
$collected = gc_collect_cycles();
// ここで回収されたサイクル数をメトリクスとしてロギングすると極めて美しい
// error_log(“Processed: {$i}, Collected cycles: {$collected}, Memory: ” . memory_get_usage(true));
}
}
// 処理終了間際に明示的クリーンアップ
gc_enable();
$finalCollected = gc_collect_cycles();
$executionTime = microtime(true) – $startTime;
echo “実行時間: ” . round($executionTime, 4) . ” 秒\n”;
echo “最終GC回収サイクル数: {$finalCollected}\n”;
echo “ピークメモリ使用量: ” . round(memory_get_peak_usage(true) / 1024 / 1024, 2) . ” MB\n”;
アーキテクトが現場で実践すべきアプローチ
1. ロングランプロセス(RoadRunnerやSwoole、ReactPHPなど)では必須のケア
PHP-FPMのように「リクエストごとにプロセスが破棄される環境」であれば、最悪の場合でもリクエスト終了時にOSへメモリが返却されるためGCのチューニングシビアさは低いです。しかし、常駐型プロセス(Async PHP)では、GCのチューニングを怠ると確実にメモリリークが蓄積し、数日単位でプロセスがクラッシュします。
常駐型アプリでは、リクエストの切れ目やアイドルタイムに手動で `gc_collect_cycles()` を呼び出す設計が鉄則です。
2. `ini_set(‘zend.gc_max_cycles’, …)` の動的調整
メモリを大量消費することが分かっているバッチジョブの冒頭で、バッファサイズをあらかじめ拡大(例: `50000` や `100000`)させておくことで、細かいGCの割り込みを防ぎ、スループットを向上させることができます。
—
5. 先輩アーキテクトからのメッセージ
PHPのガベージコレクションや `gc_param` の調整は、普段の開発ではあまり日の目を見ない地味な領域かもしれません。しかし、システムがスケールし、ミリ単位のパフォーマンスや、クラウドサーバーのコスト(メモリ・CPUリソース)をシビアに最適化しなければならないフェーズにおいて、このレイヤの知見は「他のエンジニアには絶対に真似できない強力な武器」になります。
「なぜ今、このサーバーのCPUが跳ね上がったのか?」
「なぜこのバッチは終盤に差し掛かると急に遅くなるのか?」
その答えの多くは、Zendエンジンの小さなバッファと、メモリの深淵で静かにうごめく `zval` たちの物語の中に隠されています。
裏側の仕組みを解き明かし、コードとインフラを意のままに操る快感を、ぜひあなたのプロジェクトでも味わってみてくださいね。