【入門編】PHPのメモリ制限(memory_limit)とOSレベルのメモリ管理の乖離 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

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

他のモダンな言語やフレームワークを深く渡り歩いてきたあなたなら、PHPのコードを書くこと自体は朝飯前でしょう。しかし、本番環境のトラフィックが急増した際や、バッチ処理で大量のデータを飲み込ませたときに、突如として `Allowed memory size of X bytes exhausted` という見慣れたエラーに直面したり、あるいはエラーすら吐かずにLinuxのOOM Killer(Out-Of-Memory Killer)によってプロセスが容赦なく刈り取られたりして、頭を抱えた経験はないでしょうか?

「設定ファイル(`php.ini`)で `memory_limit = 512M` にしているのだから、PHPはこのサイズを超えた瞬間にエラーを出すはずだよね?」

もしそう思われているなら、ここから先のお話は、あなたのPHPに対する解像度を劇的に変えるものになります。今回は、Zendエンジンがメモリをどう扱い、それがOSのカーネル空間でどう振る舞っているのか、その深淵を一緒に覗いてみましょう。ここを理解すると、メモリ管理のバグや予期せぬリソース枯渇に対する「怖さ」が綺麗に消え去りますよ。

—

1. PHPの `memory_limit` とOSの `malloc` の間にある「大きな誤解」

私たちが普段何気なく設定している `memory_limit`。これは、PHPスクリプトが消費する最大メモリ量を制限するためのものです。しかし、この制限はOSのメモリ管理機構とはまったく異なるレイヤーで動いています。

PHPのソースコード(C言語)を覗くと、変数の生成、配列(HashTable)の拡張、オブジェクトのインスタンス化など、あらゆるメモリ確保はZendエンジン独自のメモリマネージャである emalloc() を通して行われています。`memory_limit` が監視しているのは、まさにこの `emalloc()` が累計でどれだけのメモリを要求したかというカウンター値に過ぎません。

OSから見たメモリの現実

一方、LinuxなどのOSカーネルは、PHPがどれだけ独自のカウンターを持っていこうと関係ありません。プロセスが実際にOSへメモリを要求する際(つまり `malloc()` 経由、あるいは内部の `mmap()` や `brk()`)、OSは「仮想メモリ空間」を割り当てます。

ここで重要なポイントがあります。
PHPが `emalloc()` で確保したメモリを解放(efree)しても、OSから見たプロセスの物理メモリ使用量(RSS: Resident Set Size)はすぐには減らないケースがほとんどである という点です。

Zendエンジンは、パフォーマンスを最大化するために、一度OSから獲得したメモリチャンクを簡単には手放しません。内部のプール(Zend Memory Manager)に保持し、次の `emalloc()` リクエストに備えて使い回そうとします。そのため、トップ画面などで `memory_get_usage()` が示す数値と、OSの `top` コマンドや `ps` コマンドが示すプロセスごとのメモリ使用量には、常に乖離が生じるのです。

—

2. 巨大配列とHashTableのメモリ爆発メカニズム

では、なぜPHPは時としてメモリを食いつぶし、OSのOOM Killerを召喚してしまうのでしょうか。その主原因は、PHPの根幹である HashTable の構造にあります。

PHPの配列(`array`)は、ただの連続したメモリ領域ではありません。順序付きハッシュマップであり、キーと値のペア、衝突解決のためのポインタ、そしてハッシュテーブル自体のメタデータが複雑に絡み合っています。

例えば、数百万件のレコードをデータベースから一気にフェッチし、プレーンな配列に格納する次のようなコードを考えてみましょう。

query(“SELECT FROM huge_table”);
$accumulator = [];

// 100万件のレコードを配列に蓄積していく
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
// 各行が配列としてラップされ、さらに全体の大規模配列に組み込まれる
$accumulator[] = $row;
}

return $accumulator;
}

// 実行すると、Zendエンジン内で凄まじい数のzval構造体が生成される
$data = loadMassiveData($pdo);

このコードが実行されるとき、裏では何が起きているでしょうか?
1. 各行の連想配列が生成されるたびに、Zendエンジンは `zval`(PHPのあらゆる変数を表現する構造体)を動的にアロケートします。
2. 配列のサイズが動的に拡張(Rehash)されるたびに、既存のメモリ領域の2倍の領域が新しく確保され、古いデータがコピーされます。
3. この過程で、`memory_limit` の天井に向かってカウンターが猛烈な勢いで上昇します。

そして、仮にスクリプトの処理が終わり、巨大な `$accumulator` 変数がスコープを抜けて破棄(`efree`)されたとしても、前述の通り、その巨大なメモリ空間の大部分はZendエンジンのプール内に留まり続けます。

—

3. FPM環境におけるOOM Killerの恐怖

Webアプリケーションの本番環境の多くは、PHP-FPM(FastCGI Process Manager) のプロセスプールとして稼働しています。ここが、メモリ管理における最大の戦場です。

PHP-FPMのワーカープロセスは、1つのリクエストを処理して終わりではありません。`pm.max_requests` に到達するまで、あるいはプロセスが生き続ける限り、何千回ものリクエストを同じプロセス空間で処理し続けます。

もし、先ほどのような「メモリを大量消費する重い処理」が特定のURLへのアクセスで実行された場合、次のような悲劇が起きます。

1. リクエストAが来入し、巨大なメモリを消費する。
2. 処理が終わり、スクリプトは終了する(`memory_limit` の枠内なのでエラーにはならない)。
3. しかし、Zendエンジンが抱え込んだ大量のメモリはOSへ返還されず、そのFPMワーカープロセスのRSS(実メモリ)は肥大化したまま残る。
4. 次のリクエストBが別の軽量な処理のためにそのワーカーに割り振られるが、プロセスはすでに肥大化しているため、サーバー全体の空きメモリがじわじわと圧迫される。
5. ある日、トラフィックのピーク時に複数のワーカーが同時に肥大化し、OSの利用可能な物理メモリ(およびスワップ)が枯渇する。
6. Linuxカーネルの OOM Killer が発動し、最もメモリを喰っている(ように見える)PHP-FPMワーカープロセスを容赦なく強制終了(SIGKILL)する。

NginxやApacheのエラーログに突如として `11: Resource temporarily unavailable` や、上流サーバーからの突然の切断(502 Bad Gateway)が記録される原因の多くは、このメカニズムにあります。`memory_limit` に達する前に、OSの物理メモリが先に耐え切れなくなっているのですね。

—

4. エンジニアが実践すべき極限のメモリ防衛術

この乖離とメカニズムを理解したあなたなら、もう「とりあえず `memory_limit` を無限(`-1`)にする」という暴挙に出ることはないはずです。現場で実践すべき具体的なアプローチを見ていきましょう。

① ジェネレータ(`yield`)によるストリーミング処理の徹底

メモリ使用量を一定(O(1)の空間計算量)に抑えるための最強の武器がジェネレータです。データをすべて配列に抱え込むのではなく、1行ずつイテレートして処理を流します。

query(“SELECT FROM huge_table”);

// フェッチモードをFETCH_ASSOCにしつつ、配列として一括保持しない
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
// 1行分のメモリだけを消費し、呼び出し元へ制御を渡す
yield $row;
}
}

// 処理ループ:メモリ使用量は常に一定を保つ
foreach (yieldMassiveData($pdo) as $row) {
processRow($row);
// ループの各ステップで不要になったzvalは速やかに解放される
}

② FPMプロセスのライフサイクル管理(`pm.max_requests`)

どんなに気をつけていても、外部ライブラリの肥大化や細かなメモリリーク(C言語レベルの拡張モジュール等のバグなど)によって、長期間稼働するプロセスは徐々にメモリを消耗します。

これを防ぐため、`pool.conf` でのプロセスリサイクル設定を適切に行いましょう。

; 例: 500リクエストを処理したら、このWorkerプロセスを安全に再起動する
pm.max_requests = 500

これにより、プロセスが抱え込んだメモリの断片化や肥大化を定期的にリセットし、OSへメモリを完全に返還することができます。

—

5. まとめ

PHPの `memory_limit` は、あくまでZendエンジン内部の安全弁(ブレーキ)であり、OSが管理する物理メモリの世界とは別次元のルールで動いています。

  • `memory_limit`: Zend内部の `emalloc()` のカウンター制限。
  • OSのメモリ管理 / OOM Killer: 実際に確保された仮想・物理メモリ(RSS)の限界。

この2つのレイヤーの違いを頭の片隅に置いておくだけで、本番環境でのトラブルシューティングのスピードが圧倒的に変わります。「なぜメモリが解放されないのか」「なぜ突然プロセスが落ちるのか」という問いに対し、Zend VMとLinuxカーネルの対話が脳内で綺麗に再生されるはずです。

さあ、この知見を武器に、より堅牢で無駄のない美しいPHPアプリケーションを構築していきましょう。あなたの設計するシステムが、極限の負荷の中でも美しくしなやかに動き続けることを、心から応援しています。

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