【入門編】Swoole/RoadRunnerにおける`static`変数の永続化とGCの競合:意図しないデータ保持とメモリリークのリスク管理 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

こんにちは。普段はNode.jsやGo、あるいはJavaあたりの非同期・常駐型ランタイムをバリバリ触っていて、「PHPってリクエストごとにプロセスが死んでメモリが綺麗に掃除されるんでしょ?」という感覚でモダンなSwooleやRoadRunnerの世界に飛び込んできた優秀なエンジニアの皆さん。

――実は、その油断が本番環境での「じわじわと進行するメモリリーク」や「他人のリクエストセッションが別の客に漏れ出すという致命的なデータ汚染」を引き起こす最大の原因になります。

今回は、PHPの裏側を支配するZendエンジンと、常駐型プロセス(APMやSwoole等)が交差する地帯で何が起きているのか。そのメカニズムを、メモリの底から紐解いていきましょう。ここを理解すれば、PHPの裏側が驚くほどクリアに見えるようになりますよ。

—

1. 伝統的なCGI/FPMと、常駐型ランタイム(Swoole/RoadRunner)の決定的な違い

まずは、PHPが背負ってきた歴史と、モダンな常駐化の仕組みを軽く整理しておきましょう。

私たちが長年慣れ親しんできた传统的なPHP-FPMアーキテクチャでは、1つのHTTPリクエストが来ると、OSプロセス(またはスレッド)が立ち上がり、スクリプトが読み込まれ、パースされ、ZendVM上でオペコード(Opcode)が実行され、レスポンスを返した瞬間にプロセスが完全に破棄されます。
プロセスが死ねば、OSがそのプロセスが占有していたすべてのヒープメモリを一括回収するため、多少のメモリリークがあ実質的に「無かったこと」になっていました。

しかし、SwooleやRoadRunnerのような常駐型ランタイム(Application Server)の世界では話が全く違います。
マスタープロセスからフォークされた、あるいは常駐し続けるワーカープロセス(Worker Process)のメモリ空間上に、一度ロードされたPHPのコードや変数がそのまま保持され続け、何万、何百万というリクエストを同じプロセスが処理し続けます。

この「プロセスが死なない世界」において、最も牙を向くのが、私たちが何気なく使う `static` 変数と、PHPのガベージコレクション(GC)の仕様のミスマッチなんです。

—

2. ZendVMのメモリ空間における `static` 変数の正体

PHPの関数内やクラス内で宣言される `static` 変数。これは、ZendVMの内部構造においてどのように扱われているのでしょうか。

ZendVMは、スクリプトを実行する際、変数や関数、クラスなどのシンボルを HashTable という内部ハッシュ構造で管理しています。
通常のローカル変数は、関数が呼び出されるたびにスタックフレーム(Execution Context)上に生成され、関数が終了すればZendVMのメモリ管理機構によって速やかに解放されます。

しかし、`static` 変数は違います。
`static` 変数は、その関数やメソッドが属するコンパイル済みの関数構造体(`zend_function`)の永続的なメモリ領域に紐づけられ、プロセスが生存している限り、一度初期化されたメモリアドレスがそのまま維持されます。

これが何を意味するか、簡単なコードで見てみましょう。

Swooleのワーカープロセス上でこれが動き続けると、リクエストを跨ぐたびに `$cache` の配列サイズは肥大化し続けます。
新しいユーザーが来るたびにキーが増え、数日稼働させればワーカーのメモリは爆発し、最終的に `Allowed memory size exhausted` でプロセスが非業の死を遂げることになります。これが常駐環境における `static` によるメモリリークの正体です。

—

3. PHPのガベージコレクション(GC)はなぜ `static` 変数を救えないのか?

「いやいや、PHPには世代別ガベージコレクションがあるから、使われなくなった変数は勝手に回収してくれるのでは?」

そう思われた方、非常に鋭い着眼点です。しかし、ここにPHPのGCの仕様の罠があります。

PHPのGC(正確には循環参照コレクター)は、「参照カウント(Refcount)がゼロにならなかったものの、配列やオブジェクト同士が循環参照を起こして孤立しているメモリブロック」を回収するためのものです。

ここで重要なのは、「`static` 変数自体がルート(Root)として強力に生存し続けている」という点です。
ZendVMから見て、`static` 変数(上の例で言えば `self::$cache`)は、生きているプロセスのシンボルテーブルから直接指し示されている「現役のルート変数」です。

つまり、以下のような状態が発生します。

1. リクエストAで一時的に生成された巨大なオブジェクトや配列が、`static` 変数に代入される、あるいは紐づく。
2. リクエストAが終了する。
3. 通常のローカル変数は参照カウントが 0 になり即座に解放されるが、`static` 変数経由で参照されているオブジェクトは、参照カウントが 1 以上残ったままになる。
4. プロセスが生存している限り、ZendVMは「この `static` 変数はまだ使われている」と判断するため、GCの回収対象(Collectorのルートリスト)に入らない、あるいは入ったとしてもルートから到達可能(Reachable)なため絶対に解放されない。

結果として、「メモリリークしているのに、PHPのGC統計情報には何も検知されない」という、最もデバッグが困難な状態に陥ります。

—

4. 実戦で使える回避策と、モダンPHPの設計思想

では、SwooleやRoadRunnerといった常駐環境で、私たちはどのようにこのリスクをコントロールすべきなのでしょうか。
現場のアーキテクチャ設計において、以下の3つのアプローチを厳格に使い分ける必要があります。

A. リクエスト終了時の明示的なクリーンアップ(Deconstructor / Middleware Pattern)

もっとも確実で現実的なアプローチは、フレームワーク(Laravel SwooleやHyperfなど)が提供する「リクエスト終了フック(OnRequestEnd)」を利用し、リクエストごとにグローバルや静的プロバイダの状態をリセットすることです。

B. `static` 変数を「真の定数・設定情報」以外に使わない規約を作る

そもそも、アプリケーション層のビジネスロジックで可変のデータを保持する目的で `static` 変数を使うこと自体が、常駐環境においてはアンチパターンです。
`static` は、以下のような「プロセスライフサイクルを通じて変更されない読み取り専用のデータ(キャッシュや設定)」にのみ限定すべきです。

  • 外部設定ファイルのパース結果(一度読み込んだら不変)
  • 重いマスタデータのルックアップテーブル(Read-only)

可変の状態(State)を保持したい場合は、`static` に頼るのではなく、リクエストごとにインスタンス化されるDIコンテナ(依存性注入コンテナ)のスコープ内、あるいは専用のセッションストレージに閉じ込めるべきです。

C. リークの早期検知とメモリ監視

どれほど気をつけていても、サードパーティ製ライブラリが内部で静的キャッシュを持っており、そこからリークするケースはゼロにはなりません。
そのため、本番環境のSwoole/RoadRunnerワーカーでは、以下のようなメトリクスを常に監視し、一定のリクエスト数やメモリ使用量を超えたら、安全にワーカープロセスを再起動(Graceful Reload)する仕組みを必ず組み込んでおきましょう。

  • `memory_get_usage(true)` や `memory_get_peak_usage(true)` の定期ロギング
  • ワーカーあたりの最大リクエスト処理数(`max_request` 設定)によるプロセスの自動リフレッシュ

—

5. おわりに:PHPの「裏側」を掌握するということ

今回は、SwooleやRoadRunnerといった常駐環境における `static` 変数の永続化と、ZendVMのメモリ管理・GCの競合について深く掘り下げてみました。

他の言語(例えばJavaやGo)からやってきたエンジニアにとって、PHPのプロセスモデルの変遷は時に混乱の元になります。「リクエストごとにすべてが消える世界」から「プロセスがすべてを記憶し続ける世界」へシフトした今、私たちが書くコードの1行1行が、ZendVMのどのメモリ領域にどう刻まれるのかを想像する力こそが、シニアエンジニアとそうでない者を分ける境界線となります。

「なぜこの変数は消えないのか?」
「このデータはどのライフサイクルに属しているべきか?」

迷ったときは、いつもZendVMのメモリ空間とHashTableの構造に思いを馳せてみてください。きっと、美しく堅牢なアーキテクチャの答えが見えてくるはずです。
それでは、快適なモダンPHPライフを!

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