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

なぜ `memory_limit` に達する前に Linux は PHP を殺すのか?

コードレビューの場で、「`memory_limit` を `2G` に設定したから、この大量データ処理バッチは安全だ」という発言を聞くたびに、私は深い絶望とエンジニアとしての危機感を覚える。

PHPの `memory_limit` は、Zendエンジンが仮想的に定めた「論理的な安全装置」に過ぎない。Linuxカーネルがプロセスの生死を決定する基準は、そんなPHPの都合の良い設定値ではなく、物理・仮想メモリのハードウェア的な実消費量である。

この乖離を理解していないと、大規模なAPIやバッチ処理を本番稼働させた瞬間、の前触れもなく `dmesg` に `Out of memory: Kill process` が刻まれ、静かにプロセスが葬り去られる悪夢を見る事になる。

今回は、Zend VMのメモリ管理機構(ZendMM)と、OSレベルのメモリアロケータ(glibcの `malloc`、`brk`、`mmap`)の深い関係性を紐解き、実務で絶対に踏んづけてはならないメモリ管理の極意を伝授する。

—

ZendMM と glibc アロケータの密談:内部で何が起きているか

PHPスクリプトが動作するとき、メモリは直接OSから細切れに確保されているわけではない。パフォーマンスを最大化するため、Zendエンジンは起動時にOSから巨大なメモリブロックをまとめて一括確保する。これが Zend Memory Manager (ZendMM) の正体だ。

1. チャンクとページの幻想

ZendMMは、OS(glibc)に対して `malloc()` を用いて巨大なチャンク(通常は2MB単位、PHP 8以降ではhuge pagesの活用もある)を要求する。
PHPコード内で配列に要素を追加したり、文字列を結合したりすると、ZendMMはこの事前確保したプール内から細かなメモリ(`zend_alloc`)を高速に切り出す。

ここで最初の乖離が生まれる。

  • PHPの視点 (`memory_limit`): 「今、スクリプトは 128MB しか使っていない(ZendMM内の管理カウンタ値)」
  • OSの視点 (RSS / Virtual Memory): 「いや、プロセス全体の仮想メモリ空間は 512MB に膨らんでいるぞ」

2. `emalloc()` と `efree()` の闇:メモリの返還されない構造

ZendMMは、一度OSから奪ったメモリを、リクエスト処理中に `efree()` さえても容易にOSへ返還(`free()`)しない。
フラグメンテーションを防ぎ、次のアロケーションを高速化するために、ZendMMのプール内に保持し続けるのだ。

つまり、以下のようなループ処理を書いた場合:

// 危険なアンチパターン:巨大な配列を生成・破棄を繰り返す
for ($i = 0; $i < 10000; $i++) { $hugeData = range(1, 100000); // 大量の配列要素をemalloc // 何らかの処理 unset($hugeData); // efreeされるが、OSへの返還は保証されない } このコードを実行すると、PHPの `memory_limit` のカウンタはある程度で安定する(またはガベージコレクションが働く)が、Linuxプロセスの Resident Set Size (RSS) は右肩上がりに膨れ上がり、二度と下がらない。

結果として、`memory_limit` に到達するはるか手前で、OSのOOM Killerが発動する条件が整ってしまうのである。

—

現場で即効性を持つ:堅牢なメモリ管理設計ルール

この非対称なメモリ管理のメカニズムを踏まえ、実務のWebアプリケーションやAPI、CLIバッチにおいて守るべき設計ルールを明示する。

1. 「一括処理(バルク)」の限界を見極める
数百万件のレコードを一度にメモリ上に展開してはならない。必ずカーソルベース、またはチャンク分割によるイテレーション(ジェネレータ)を使用すること。
2. CLIバッチでは `gc_collect_cycles()` を過信するな
循環参照の解放コストは高い。結合度の高いループ内では、定期的にプロセスそのものを再起動する(Workerパターンの適切なライフサイクル管理)設計が最も確実なOOM対策となる。
3. 拡張モジュール(C記述)のリークに目を光らせる
ZendMMの管理外で `malloc()` を直接叩くサードパーティ製拡張モジュール(古いRedisクライアントや画像処理ライブラリなど)は、`memory_limit` に一切カウントされないままOSメモリを喰らい潰す。

—

実務で使える:安全なメモリ制御とチャンク処理のリファレンスコード

以下に、大量データを安全に処理し、かつメモリリークとOSへの過度な負担を防ぐための、プロダクション品質のPHPコードを示す。

  • Class SafeDataProcessor
  • 大規模データを安全にチャンク単位で処理し、ZendMMおよびOSのメモリ肥大化を
  • 抑制しながらイテレーションを行う堅牢なプロセッサのサンプル。
  • /
    class SafeDataProcessor
    {
    private int $chunkSize;

    public function __construct(int $chunkSize = 1000)
    {
    $this->chunkSize = $chunkSize;
    }

    /

    • 巨大なデータソースをジェネレータで少しずつ読み込み、処理する。
    • @param \PDO $pdo
    • @return \Generator

    /
    public function fetchInChunks(\PDO $pdo): \Generator
    {
    $offset = 0;

    while (true) {
    $stmt = $pdo->prepare(‘SELECT id, payload FROM large_table LIMIT :limit OFFSET :offset’);
    $stmt->bindValue(‘:limit’, $this->chunkSize, \PDO::PARAM_INT);
    $stmt->bindValue(‘:offset’, $offset, \PDO::PARAM_INT);
    $stmt->execute();

    $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC);
    if (empty($rows)) {
    break;
    }

    yield $rows;

    // 参照を明示的に断ち切り、ZendMMの解放効率を高める
    unset($rows);
    $stmt->closeCursor();

    $offset += $this->chunkSize;

    // 定期的に明示的なGC発動(循環参照の即時回収)
    // ※コストが高いため、チャンクごとのような粗い粒度でのみ実行する
    if (($offset / $this->chunkSize) % 10 === 0) {
    gc_collect_cycles();
    }
    }
    }

    /

    • 処理を実行するエントリポイント

    /
    public function run(\PDO $pdo): void
    {
    // 現在のプロセスが消費している実メモリ(RSS)をメガバイト単位で取得
    $initialMemory = memory_get_usage(true);
    echo sprintf(“— 処理開始時の実メモリ消費量: %.2F MB —\n”, $initialMemory / 1024 / 1024);

    $batchCount = 0;
    foreach ($this->fetchInChunks($pdo) as $chunk) {
    $batchCount++;

    // チャンクごとのビジネスロジック
    foreach ($chunk as $row) {
    // 例: データの加工や外部APIへの送信
    $this->processRow($row);
    }

    // ログ出力などでメモリの爆発がないか常時監視できるようにする
    $currentMemory = memory_get_usage(true);
    $peakMemory = memory_get_peak_usage(true);

    echo sprintf(
    “Batch #%d 完了. 現在メモリ: %.2F MB (ピーク: %.2F MB)\n”,
    $batchCount,
    $currentMemory / 1024 / 1024,
    $peakMemory / 1024 / 1024
    );
    }

    echo “— 全処理正常終了 —\n”;
    }

    private function processRow(array $row): void
    {
    // ダミー処理
    // 重い文字列結合や配列の複製をループ内で行わないこと
    unset($row[‘payload’]);
    }
    }

    —

    アーキテクトからの最終言

    「動けばいいコード」を書くステージはもう過ぎた。PHPはスクリプト言語であるという手軽さの裏で、C言語ベースの重厚な仮想マシン(Zend VM)とLinuxのメモリ管理機構がせめぎ合う、極めて緻密なランタイム環境の上で動いている。

    `memory_limit` は魔法の盾ではない。OSの挙動、ZendMMの特性、そしてプロセスのライフサイクルまでを俯瞰して初めて、深夜にPagerDutyが鳴り響かない、真に堅牢なWebシステムが構築できるのだ。

    コードレビューの際は、単に「メモリ制限にひっかからないか」ではなく、「この処理のあと、OSから見たメモリは本当に解放される構造になっているか?」という視点を常に持って臨んでほしい。

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