こんにちは。普段は他のモダンな言語やフレームワークをバリバリ使いこなしながら、「最近のPHPってどうなってるんだ?」と興味を持って覗きに来てくれたあなたへ。
PHPといえば、「リクエストが終わるたびにプロセスごと綺麗にメモリが解放される安全な言語」というイメージが強いですよね。CGIの時代から続き、Apache+mod_php、そして現代のPHP-FPMに至るまで、私たちは「リクエストの独立性」という強力なセーフティネットに守られてコード書いてきました。極端な話、グローバル変数で多少汚いことをしても、リクエストが終了すれば全てはきれいにリセットされる。それがPHPの気楽なところでした。
しかし、SwooleやRoadRunnerといった常駐型(Application Server)PHP環境に踏み込んだ瞬間、その前提は音を立てて崩れ去ります。
今回は、常駐型環境において最もデバッグが難しく、かつ多くのエンジニアが一度はハマる「`static`変数の永続化とメモリリークリスク」について、PHPのエンジン内部(Zend VM)の動きと合わせて紐解いていきましょう。
ここを理解すれば、あなたのPHPを見る目は一気に低レイヤのものになり、裏側で何が起きているのかが綺麗に見えるようになりますよ。
—
1. 従来のPHP-FPMと、常駐型(Swoole/RoadRunner)の決定的違い
まず、PHPの実行モデルの根本を少しだけ振り返ってみましょう。
PHP-FPMの「リクエスト完全隔離モデル」
通常、PHP-FPM環境では、1つのHTTPリクエストが来ると、OSプロセス(またはスレッド)が割り当てられ、Zendエンジンが初期化(Request Startup)され、スクリプトが実行され、レスポンスを返した瞬間にプロセスが終了するか、あるいはメモリ空間がリセット(Request Shutdown)されます。
この世界では、関数内の `static` 変数は「そのリクエストのライフサイクル中、一度だけ初期化され、関数を抜けても保持される」という挙動をします。リクエストが終わればプロセスごと消えるので、メモリリークの心配は基本的にOSが肩代わりしてくれました。
Swoole / RoadRunnerの「メモリ常駐モデル」
一方、SwooleやRoadRunnerは、PHPプロセスをメモリ上に常駐させます。
サーバーが起動した瞬間にPHPスクリプトが読み込まれてZend VMのメモリ空間に展開され、何万、何十万というリクエストが同じプロセス・同じメモリ空間を共有しながら処理され続けます。
ここで何が起きるか分かりますか?
そう、「リクエストをまたいでも、変数や状態が消えずに残り続ける」のです。これがパフォーマンスを極限まで高める秘密(毎回ブートストラップを走らせなくていい)なのですが、同時に、設計を誤ると「時限爆弾」のようなメモリリークを引き起こす原因になります。
—
2. Zend VMから見た `static` 変数の正体
では、PHPのコード内で定義した `static` 変数は、エンジンの内部でどのように扱われているのでしょうか。
Zend VMの視点に立ってみましょう。私たちが関数やメソッドの中で次のようなコードを書いたとします。
function getCounter(): int {
static $count = 0;
$count++;
return $count;
}
Zendエンジンはコンパイル時、この `static $count` に対して専用のシンボルテーブル(領域)を割り当てます。これは通常のローカル変数(スタックフレーム上に乗るもの)とは異なり、関数やメソッドという「構造体(op_array)」に紐づいた永続的なメモリ領域に保持されます。
PHP-FPMであれば、リクエスト終了時にプロセスごと破棄されるため問題になりません。しかし、常駐型環境では、この関数が呼ばれ続ける限り、あるいはプロセスが生き続ける限り、`$count` はずっとメモリ上に居座り続けます。
これが「データの永続化」というメリットを生む一方で、次のような恐怖のシナリオを引き起こします。
—
3. リクエスト跨ぎによる「データ汚染」と「メモリリーク」の具体例
常駐型環境でやってしまいがちな、典型的なアンチパターンを見てみましょう。あるユーザーのリクエスト処理クラスで、認証情報やリクエストごとのコンテキストを `static` 変数にキャッシュしてしまったケースです。
1, ‘name’ => ‘Alice’, ‘data’ => str_repeat(‘A’, 1024 1024)]); // 1MBのダミーデータ
// … 処理が進み、レスポンスを返す …
// その後、リクエストB(ユーザーID: 2 – ゲスト、あるいは別の人)が同じプロセスで処理される
// 万が一、setUserが呼ばれなかった場合、あるいは別のルートを通った場合…
$user = UserContext::getUser();
// なんと!前のリクエストの「Alice(1MBのデータ付き)」がそのまま残っている!
これが引き起こす2つの大問題
1. 重大なセキュリティインシデント(データ汚染)
別のユーザーのセッションデータや機密情報が、次のリクエストのユーザーに露出する可能性があります。常駐型サーバーでグローバルや `static` にリクエスト依存のデータを保持してはいけない最大の理由はここにあります。
2. サイレント・メモリリーク(Zend HashTableの肥大化)
もしここに「リクエストごとに生成される大きなオブジェクト」「配列への蓄積(ログの溜め込みなど)」が絡むとどうなるでしょう? リクエストを処理するたびに `static` な配列の要素が増え続け、数日稼働した頃にはOOM(Out Of Memory) Killerによって容赦なくプロセスが強制終了させられます。
—
4. 極限の世界を生き抜くための対策とデザインパターン
「じゃあ、常駐型環境では `static` は一切使ってはいけないのか?」というと、答えはノーです。
「リクエストを跨いでも変化しない不変なデータ(設定値、キャッシュ可能なマスターデータ、コネクションプールなど)」であれば、`static` やシングルトン、静的プロパティは非常に強力な武器になります。
問題なのは、「リクエストごとに変化するデータ」が `static` に侵食することです。
対策1: リクエスト終了時の明示的なクリーンアップ(Deinit)
SwooleやRoadRunnerを使う場合、フレームワーク側(Laravel OctaneやSwooleの `onRequest` フックなど)で、リクエストのライフサイクル終端に「クリーンアップ処理」を挟む仕組みが用意されています。
自前で常駐型サーバーを組む場合や、ミドルウェアを書く場合は、次のようにリクエスト終了時に必ず `static` 状態をリセット(あるいはスコープの破棄)するフックを実装します。
/
public static function clear(): void {
// メモリ解放のため配列を空にする
self::$cache = [];
}
}
// サーバーのメインループ内やミドルウェアのfinallyブロックで必ず実行する
try {
// リクエスト処理…
} finally {
RequestScopedCache::clear(); // これを忘れないこと!
}
対策2: 依存性注入(DI)コンテナのスコープ管理を徹底する
モダンなフレームワーク(Laravel Octane, Symfony Runtimeなど)では、サービスコンテナのライフサイクルが「Singleton」「Request」などに厳密に分離されています。
- Singleton: プロセス生存期間中ずっと保持される(DBコネクションなど)
- Request: リクエストごとにインスタンスが破棄される(リクエストパラメータ、認証ユーザーなど)
安易にクラス内で `public static $instance;` のような手動シングルトンを書くのをやめ、フレームワークのDIコンテナにライフサイクル管理を委譲するのが、メモリリークを防ぐ最も確実な近道です。
—
まとめ:PHPの裏側を想像しながらコードを書く
いかがでしたでしょうか?
「`static` 変数」という、普段何気なく使っている小さな文法も、PHPの実行モデルが「プロセス都度破棄」から「メモリ常駐」に変わった途端、メモリ空間を蝕む牙をむきます。
- PHP-FPM: リクエストが終わればすべてが消える優しい世界。
- Swoole/RoadRunner: メモリが共有されるため、自らゴミを掃除し続けなければならないシビアな世界。
「この変数、今のリクエストが終わった後もメモリに残って次のお客さんに引き継がれて困らないか?」
「この配列、リクエストごとに際限なく膨らんでいかないか?」
そうやってZend VMのメモリ空間の動きを脳内でトレースしながらコードを書けるようになったとき、あなたはもう単なる「PHPプログラマー」ではなく、「PHPの挙動を完全に掌握したシステムアーキテクト」の領域に立っています。
常駐型PHPの荒波を、ぜひその確かな知見で優雅に乗りこなしてくださいね。それではまた!