こんにちは。PHPの表側のフレームワークの使い方を一通りマスターし、次のステップとして「PHPという言語が、裏側のOSやメモリ空間でどう息をしているのか」に興味を持ってくれたあなたへ。今日は、普段私たちが何気なく書いているPHPコードが、エンジン内部でどのようにメモリを喰らい、そしてなぜ大規模常駐プロセス(SwooleやReactPHPなど)で突然メモリ枯渇の牙をむくのか、その核心を解き明かしていきましょう。
ここを理解すると、PHPの裏側の世界が驚くほど美しく、そして残酷なまでに合理的であることに気づくはずです。一緒にZend VMとメモリの深淵を覗いてみましょう。
—
1. Zend MM(メモリマネージャ)の正体を知る
私たちは普段、PHPで配列にデータを詰め込んだり、巨大な文字列を結合したりするとき、メモリ管理の苦労をほとんど意識しません。なぜなら、Zend Engineが誇るZend MM(Zend Memory Manager)が、私たちの代わりにOSからメモリを塊(Chunk)単位でごっそり取得し、細かく切り分けて管理してくれているからです。
従来のPHP(Web Request)と、Swoole等の常駐プロセスの決定的な違い
従来のPHP-FPMモデルでは、1つのリクエストが終わればプロセスごと(厳密にはリクエスト処理の終了時に)メモリはOSに全返却され、すべてが綺麗にリセットされていました。つまり、多少のメモリリークや断片化があっても、リクエスト寿命という「リセットボタン」が強制的にすべてを無かったことにしてくれたのです。
しかし、SwooleやRoadRunnerのような常駐型プロセス(Long-running process)の世界では、この「リセットボタン」が押されません。数百万回のリクエストを処理する間、同じプロセスがメモリ空間を保持し続けます。
ここで悪さをするのが、Zend MM内部での「メモリの断片化(Fragmentation)」です。
—
2. Bucketとアロケーション:Zend MMがメモリを刻む仕組み
Zend MMは、OSへ細かく`malloc`や`free`を繰り返すとシステムコール(カーネル空間へのコンテキストスイッチ)のオーバーヘッドで性能が致命的に落ちるため、起動時にOSから巨大なメモリブロック(通常は2MB単位のChunk)をあらかじめ確保します。
その巨大な塊を、Zend MMは内部でサイズごとに細分化して管理しています。これがよく耳にする「Heap(ヒープ)」であり、その管理単位がBucketやサイズクラスです。
メモリ断片化(Fragmentation)が起きるメカニズム
言葉だけだとイメージしにくいですよね。次のようなシナリオを頭の中でトレースしてみてください。
1. 巨大なデータ構造の作成:APIから数万件のレコード(連想配列)を一気に取得し、メモリ上に展開する。
2. 部分的な解放:そのうちの特定の不要なキーや、小さなオブジェクト群だけを随時 `unset()` する。
3. 隙間の発生:Zend MMのヒープ空間上において、「使用中のメモリ領域」と「解放された小さな隙間(空きスロット)」がパッチワークのように点在する。
この状態を「外部断片化」と呼びます。
[ 確保 (大) ][ 解消された隙間 (小) ][ 確保 (中) ][ 解消された隙間 (小) ][ 確保 (大) ]
この状態で、次に「少し大きめのまとまったメモリ(例えば新しいリクエストでの大きなバッファ)」が必要になったとします。
不思議なことに、トータルの空き容量は十分にあるのに、連続した十分なサイズの隙間がないという現象が起きます。Zend MMは「うわ、入る場所がないぞ」となり、仕方なくOSからさらに新しいChunkを追加で要求します。
結果として、アプリケーションが実際に保持しているデータ量は大したことがないのに、プロセス全体のRSS(Resident Set Size:物理メモリ使用量)だけが膨れ上がり、やがてOOM Killerにプロセスを強制終了(Killed)されてしまうのです。これが、常駐型PHP環境におけるメモリ枯渇の真因です。
—
3. 実践:断片化を加速させるアンチパターンとコードの検証
では、どのようなコードがこのメモリ断片化を加速させるのでしょうか。Swooleなどの常駐環境でやってしまいがちな、典型的なメモリ肥大化のパターンをコードで見てみましょう。
on(“Request”, function (Request $request, Response $response) {
// 【アンチパターン】
// リクエストごとに、サイズがバラバラの巨大な連想配列を作り、
// 部分的に要素を消したり足したりを繰り返す
$data = [];
for ($i = 0; $i < 100000; $i++) {
$data["key_" . $i] = str_repeat("A", rand(10, 500));
}
// ランダムに半分ほどをunsetして隙間(断片化)を作る
for ($i = 0; $i < 100000; $i += 2) {
unset($data["key_" . $i]);
}
// この時点でZend MMのヒープ内には細かな隙間が無数に空いている
$memoryUsage = memory_get_usage(true);
$response->end(“Current Memory: ” . number_format($memoryUsage) . ” bytes\n”);
});
$server->start();
このコードをSwoole上で動かし、何千回もリクエストを送り続けると何が起きるでしょうか。
`unset()` をしているため、PHPのロジック上はメモリを解放しているつもりですが、Zend MMのヒープ構造体の中では、その解放された細かい領域が綺麗に再結合(Coalescing)されず、再利用しにくい「断片のゴミ屋敷」になっていきます。
—
4. この壁を突破するためのアーキテクチャ設計と対策
「じゃあ、常駐型PHPで安全に生きるにはどうすればいいんだよ」という声が聞こえてきそうですね。安心してください。Zend VMの性質を逆手に取った、プロフェッショナルな回避策がちゃんと存在します。
対策1: 「メモリプール(プレアロケーション)」の発想を持つ
可変長な配列にデータを足したり引いたりするのではなく、あらかじめ必要なサイズ分の構造を予測できるのであれば、一度だけ生成して使い回す、あるいはデータ構造をフラットに保つ工夫をします。
対策2: プロセスの「寿命(Life cycle)」を適切に管理する
Swoole等を使う場合でも、ワークワーカープロセスが一定回数(例えば数千リクエスト)を処理したら、安全にプロセスを再起動させる仕組み(`max_request` のような設定)を必ず組み込みましょう。
「PHPのメモリリークや断片化は、完全にゼロにはできないものとしてデザインし、プロセスをリフレッシュする」というのは、実はGoやNode.jsの世界でも使われる極めて堅実なアーキテクチャパターンです。
対策3: `gc_collect_cycles()` の適切なタイミングでの介入
PHPには循環参照を検知して解放するガベージコレクタ(GC)がありますが、常駐プロセスではこれが動作するタイミングが非常に重要です。オブジェクトのグラフが複雑になるバッチ処理や非同期タスクの終了時には、明示的に `gc_collect_cycles()` を呼び出し、Zend MMへの解放を促すことが有効です。
—
5. まとめ:PHPの裏側を愛せるエンジニアへ
今回は、Zend MMのBucket管理とヒープの断片化という、一歩踏み込んだレイヤのお話をしました。
「なぜこのコードでメモリが増え続けるのか?」
その疑問にぶつかったとき、画面の向こう側のZend VMが、OSのメモリ空間とどのように対話しているかを脳内でイメージできるようになると、あなたのエンジニアとしての視座は劇的に上がります。
PHPは、決して「おもちゃのスクリプト言語」などではありません。その内部エンジンは緻密に最適化されており、仕様の裏側を正しく理解すれば、極めて堅牢でハイパフォーマンスなシステムを支える強力な武器になります。
ここを理解したあなたなら、もうメモリリークやOOM Killerに怯える必要はありません。ぜひ、次の設計やデバッグの現場でこの知見を活かしてみてください。それでは、また深いコードの世界でお会いしましょう。