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

Swoole / RoadRunner常駐環境における `static` 変数の罠:Zendエンジン内部から紐解くGC競合とメモリリークの全貌

コードレビュー中、私はジュニアやミドルクラスのエンジニアが書いた次のようなコードを見つけて、思わずペンを止めることがよくある。

class UserRepository {
public static function find(int $id): User {
static $cache = [];

if (isset($cache[$id])) {
return $cache[$id];
}

return $cache[$id] = User::loadFromDatabase($id);
}
}

「クエリを減らすために、static変数で簡易キャッシュを持たせました」――一見すると、伝統的なPHP-FPMの文脈では無難な最適化に思えるかもしれない。しかし、君が今から構築しようとしている、あるいはすでに運用しているアプリケーションが Swoole や RoadRunner といった常駐型(Long-running)プロセスモデルの上で動いているとしたら、この数行のコードは本番環境を確実に死に至らしめる「時限爆弾」となる。

今回は、Zend VM(PHPエンジン)のメモリ管理のプリミティブ、参照カウント、そしてGC(ガベージコレクション)のアルゴリズムが、常駐環境における `static` 変数とどう衝突し、なぜ破滅的なメモリリークを引き起こすのか。そのメカニズムを低レイヤの視点から完全に解き明かそう。

—

1. 伝統的PHP-FPMと常駐環境(Swoole/RoadRunner)の決定的な断絶

PHP-FPMのパラダイムでは、1つのHTTPリクエストが到達すると、OSプロセス(またはスレッド)がフォークされ、Zendエンジンが初期化(Request Startup)され、スクリプトが実行され、応答が返された瞬間にプロセス空間のすべてのメモリが容赦なくOSに回収される(Request Shutdown)。つまり、`static` 変数であろうが何であろうが、リクエストの終端とともにすべての動的確保メモリはリセットされる。

しかし、SwooleやRoadRunnerは違う。

これらはプロセスをメモリ上に常駐させ、エントリポイントとなるスクリプトを一度だけメモリにロード(`Bootstrap`)した後、マスター/ワーカープロセスがイベントループを回し続け、次々と飛んでくるリクエストを非同期・並行に処理し続ける。

何が起きるか?

ワーカープロセスがメモリ上に生き続け、そのプロセス空間内で数万、数百万のリクエストを処理するということは、関数やメソッド内の `static` 変数の生存期間(Lifetime)が、プロセス自体のライフサイクルと完全に一致することを意味する。

Zendエンジンにおいて、`static` 変数はリクエストを跨いでその状態(コンテキスト)を維持し続ける。これが「意図しないデータの永続化」であり、マルチテナントなWebアプリケーションにおいて致命的なデータ汚染(テナント間のデータリーク)と、回収不能なメモリ肥大化を引き起こす元凶となる。

—

2. Zendエンジンのメモリ管理と `static` 変数の内部構造

Zend VMにおいて、すべての変数コンテナは `zval`(Zend Value)という構造体として表現されている。この `zval` は、値の型情報と、必要に応じた実体(文字列、配列、オブジェクトなど)へのポインタ、そして最も重要な `refcount`(参照カウント) を保持している。

配列(HashTable)と参照の魔窟

先ほどのコードにあった `$cache` は、内部的には `HashTable` というハッシュマップ構造体としてメモリ上に展開される。
もしこの `$cache` に何千もの `User` オブジェクトが格納され、さらにそのオブジェクトが他のサービスやロガー、PDO接続などのリソースへの参照を保持していた場合、何が起きるか?

1. 参照カウントの非ゼロ化:
リクエストが終了しても、プロセスが死なない限り `static $cache` はスタック・フレームのスコープを超えて、グローバルなシンボルテーブル(または関数スコープの静的領域)から直接指され続ける。そのため、`refcount` は絶対に `0` にならない。
2. GCの対象外、あるいは回収不能:
PHPのガベージコレクタは、主に「循環参照(Circular Reference)」(例:オブジェクトAがオブジェクトBを指し、BがAを指している状態)によって `refcount` が 0 にならなくなったバッファを検出・回収するためのものである。
しかし、`static` 変数によってルートから強固に指され続けているデータは、「現役で使われているデータ」とみなされるため、例えそれが特定の単一リクエストだけで必要な一時的データであっても、GCのルートバッファにすら乗らないか、回収対象から除外され続ける。

結果として、リクエストを処理すればするほど、`static` 変数の `HashTable` は膨張し続け、ワーカーのメモリ使用量は右肩上がりに上昇する。これが常駐環境における真のメモリリークの正体である。

—

3. 実務で即座に使える安全な設計パターン

では、常駐環境においてキャッシュや状態管理を行いたい場合、どう設計すべきか。
答えは明確だ。「リクエストのライフサイクルに完全に依存するスコープ管理」を実装するか、専用のインメモリ・データストア(Swoole TableやAPCuなど)を適切に用いることである。

ここでは、SwooleやRoadRunner環境を前提とし、`static` 変数の悪霊を完全に排除しつつ、極限までパフォーマンスを最適化した堅牢なリポジトリ層・キャッシュ層の実装例を示す。

実装例:リクエストスコープを強制するコルーチンセーフなキャッシュ機構

以下のコードは、Swooleのコルーチンコンテキスト(Coroutine Context)や、リクエスト単位のDIコンテナの概念を利用し、リクエスト終了時に確実にメモリが解放される仕組みを模した実務レベルのリファレンスである。

  • Class RequestContextCache
  • Swooleなどの常駐環境において、 static変数の代わりにリクエストスコープで
  • データを安全に保持・自動破棄するためのマネージャー。
  • /
    final class RequestContextCache
    {
    /

    • リクエストごとのストレージを取得する
    • @return array

    /
    private static function getStorage(): array
    {
    // SwooleのコルーチンIDを取得(非Swoole環境の場合はプロセス全体のフォールバック)
    $cid = class_exists(Coroutine::class) ? Coroutine::getCid() : -1;

    // コルーチン環境でない(=通常のCLIや古いFPMなど)場合は静的配列を返す
    if ($cid < 0) { static $fallback = []; return $fallback; } // Swooleのコルーチンコンテキストにデータをバインドする。 // コルーチンが終了(リクエスト処理完了)した瞬間、このストレージは自動的に破棄される。 $context = Coroutine::getContext($cid); if (!isset($context['request_cache'])) { $context['request_cache'] = []; } return $context['request_cache']; } public static function set(string $key, mixed $value): void { $storage = &self::getStorage(); $storage[$key] = $value; } public static function get(string $key): mixed { $storage = self::getStorage(); return $storage[$key] ?? null; } public static function has(string $key): bool { $storage = self::getStorage(); return isset($storage[$key]); } } これを先ほどの `UserRepository` に適用してみよう。 この設計が極めて安全な理由

    1. リクエスト間のデータ汚染の完全な防止:
    あるユーザー(テナントA)のリクエストでロードされたデータが、同じワーカーで次に処理される別のユーザー(テナントB)のリクエストに持ち越されることが構造的に不可能になる。
    2. メモリリークの根絶:
    Swooleのコルーチンが終了した際、Zendエンジンは `Coroutine::getContext()` に紐づいていたすべての `zval` の参照を切り離す。これにより、リクエスト終了とともにメモリは確実にOS/プールに返還される(あるいはZendのフリーリストに戻る)。

    —

    4. テクニカルリードからの最終提言

    常駐型PHPアプリケーションの開発において、`static` 変数は「手軽な最適化の道具」ではなく、「プロセスを徐々に蝕む毒」として扱わなければならない。

    もしあなたがコードレビューで以下のような記述を見かけたら、即座に修正を命じるべきだ。

    • クラスメソッド内での `static $cache = [];`
    • 明示的なクリア処理(`reset()` や `null` 代入など)のない静的プロパティでのデータ保持
    • シングルトンパターン内でのリクエスト固有データの蓄積

    PHPはスクリプト言語の顔をした強力な仮想マシン(Zend VM)である。その下層でメモリがどう生まれ、どう消えていくのか。そのイメージを脳内に常に描けるエンジニアだけが、SwooleやRoadRunnerといったモンスター級の高速ランタイムを真に手なずけることができる。

    コードを書くときは常に自問せよ――「この変数は、100万回目のリクエストの時にも美しく消えるか?」と。

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