伝統的PHPの「リクエスト完全破壊」という甘えと、常駐型ランタイムの現実
PHPという言語は、古くから「1リクエスト=1プロセス(または1スレッド)の完全な使い捨て」という思想のもとに設計されてきた。NGINXからFastCGI経由で叩き落されたPHP-FPMのワーカースクリプトは、スクリプトの実行が終了した瞬間、OSレベルでメモリ空間をごっそり解放される。Zend Engineがどれだけ泥臭くメモリを断片化させようが、循環参照のゴミを残そうが、OSがプロセスを屠った瞬間にすべては無に帰す。
――だが、SwooleやRoadRunnerといった常駐型(Long-running)ランタイムを用いるモダンなPHPアーキテクチャにおいて、この「甘え」は致命的なメモリリーク(Memory Leak)という名の毒となり、システムを静かに死に至らしめる。
常駐型プロセスでは、PHPのライフサイクルはリクエストを跨いで永続化される。つまり、1回目のリクエストでグローバルスコープや静的プロパティ(`static`)に溜め込んだゴミは、2回目、3回目のリクエストでも残り続ける。Zend VMのメモリマネージャ(ZMM)は賢いが、プログラマが意図的に破棄しない限り、プロセスが生存している限りメモリを手放さない。
本稿では、SwooleやRoadRunner環境下において、Zend VMの内部挙動(参照カウントとGC)を踏まえ、リクエスト間でメモリ状態を完全に分離し、安全にクリーンアップするための極限の設計戦略をコードとともに叩き込む。
—
Zend VMのメモリ管理と「永続化」の罠
PHPの根幹であるZend Engineは、すべての変数値を `zval`(Zend Value)という構造体で表現している。この `zval` は、値の型やサイズ、そして「参照カウント(`refcount`)」を保持している。
通常のWebアプリケーションでは、スクリプト終了時にすべての `zval` は解放されるが、常駐型アプリケーションでは以下のデータ構造が意図せずプロセスに残留する。
1. static変数を伴うクラスプロパティ(シングルトンやDIコンテナのキャッシュ)
2. グローバルスコープの配列やオブジェクト
3. クロージャにバインドされた `use` 変数
4. Doctrine ORMなどのアイデンティティマップ(永続的なエンティティキャッシュ)
これらが循環参照(Circular Reference)を形成した場合、単純な参照カウントのデクリメントでは `zval` は解放されず、Zend GC(周期ガベージコレクション)が発動するまでの間、メモリを圧迫し続ける。さらに、マルチテナントやマルチユーザーを扱うAPIサーバーにおいて、1つのリクエストで取得した機密データやユーザー固有のステータスが次のリクエストに持ち越された場合、深刻なセキュリティインシデント(データリーク)に直結する。
—
堅牢なクリーンアップ戦略:設計の3大原則
SwooleやRoadRunnerで安全なシステムを構築するための原則は以下の3つに集約される。
1. 状態のスコープ化(Request Scopeの徹底)
リクエストごとにDIコンテナやサービスを完全にインスタンス化し直す。リクエスト終了時にルートとなるオブジェクトを破棄し、参照の連鎖を断ち切る。
2. `unset()` と明示的なプロパティクリア
特に大きな配列やDOMDocument、外部リソースを保持したオブジェクトは、リクエスト終了ハンドラ内で明示的に `unset()` し、`refcount` を強制的に0にする。
3. 静的キャッシュのTTLまたはリクエスト毎のリセット機構
ORMのキャッシュや設定のスタティック保持は厳禁。どうしてもキャッシュが必要な場合は、リクエストの境界でクリアするフックを用意する。
—
実装例:RoadRunner / Swoole対応のセキュアなリクエストハンドラ
ここでは、RoadRunner(Worker PSR-7)環境を想定し、リクエストごとにメモリを完全に分離・浄化するアーキテクチャの骨格を示す。
declare(strict_types=1);
namespace App\Core;
use Nyholm\Psr7\Response;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Container\ContainerInterface;
use Throwable;
class RequestLifecycleManager
{
private ContainerInterface $rootContainer;
public function __construct(ContainerInterface $rootContainer)
{
// アプリ起動時にロードされる不変のルートコンテナ
$this->rootContainer = $rootContainer;
}
/
- リクエストを処理し、確実にメモリをクリーンアップする
/
public function handle(ServerRequestInterface $request): ResponseInterface
{
// 【重要】リクエストごとに独立した子コンテナ(スコープ)を切る
// これにより、サービスが保持するステータスがリクエスト間で共有されるのを防ぐ
$requestContainer = clone $this->rootContainer;
try {
// アプリケーションロジックの実行
$kernel = $requestContainer->get(HttpKernelInterface::class);
$response = $kernel->handle($request);
} catch (Throwable $e) {
$response = $this->handleException($e);
} finally {
// 【最重要】メモリリークを防ぐための強制クリーンアップ
$this->cleanupMemory($requestContainer);
}
return $response;
}
private function cleanupMemory(object …$objectsToFlush): void
{
foreach ($objectsToFlush as $object) {
// オブジェクトが持つプロパティを再帰的または明示的にnull化・unsetする
if (method_exists($object, ‘destructScope’)) {
$object->destructScope();
}
}
// 循環参照を強制的に回収するため、Zend GCを明示的に走らせる選択肢もある
// (ただしパフォーマンスとトレードオフになるため、高負荷時は閾値調整が必要)
if (gc_enabled()) {
gc_collect_cycles();
}
}
private function handleException(Throwable $e): ResponseInterface
{
// エラーレスポンスの生成
return new Response(500, [‘Content-Type’ => ‘application/json’], json_encode([
‘error’ => ‘Internal Server Error’,
‘message’ => $e->getMessage(),
]));
}
}
オブジェクト側での明示的な参照切断(Destruct Scope)
DIコンテナから生成されるサービスやコントローラーは、リクエストスコープのインタフェースを実装し、終了時に内部プロパティを空にする設計にする。
namespace App\Http;
use App\Database\EntityManagerWrapper;
class ScopedOrderController
{
private ?EntityManagerWrapper $entityManager;
private array $requestCache = [];
public function __construct(EntityManagerWrapper $entityManager)
{
$this->entityManager = $entityManager;
}
public function execute(array $payload): array
{
// 処理の過程で大きなデータを保持
$this->requestCache = range(1, 100000);
return [‘status’ => ‘success’, ‘count’ => count($this->requestCache)];
}
/
- リクエスト終了時にコンテナやマネージャーから呼び出される
- 循環参照の根源を断ち切る
/
public function destructScope(): void
{
// 配列を空にしてメモリ上の zval を解放
$this->requestCache = [];
// 外部リソースやDB接続のラップオブジェクトへの参照を破棄
$this->entityManager = null;
}
}
—
コードレビューの現場から:「やってはいけない」アンチパターン
もしあなたのチームのコードレビューで以下のような実装を見つけたら、即座に差し戻しを命じてほしい。
1. static変数によるリクエスト間のデータ保持
class UserContext {
private static ?User $currentUser = null;
public static function setUser(User $user): void {
// ⚠️ 危険:前のリクエストのユーザーデータが次のリクエストに漏洩する!
self::$currentUser = $user;
}
}
【対策】 データは必ずリクエストオブジェクトやスコープ付きコンテナにバインドし、静的プロパティにビジネスデータを保持させない。
2. イベントリスナーやクロージャへの巨大なスコープのバインド
$app->onEvent(‘data_processed’, function($event) use ($hugeArray) {
// ⚠️ 危険:$hugeArray がクロージャのレキシカルスコープにキャプチャされ続け、
// プロセス生存中ずっとメモリに居座る。
});
【対策】 クロージャに外部の巨大な配列やオブジェクトを `use` する際は、必要なプリミティブ値だけを抽出するか、イベントハンドラ自体をリクエストごとに破棄できる設計にする。
—
アーキテクトとしての結び
SwooleやRoadRunnerを用いた高スループットなPHPアプリケーション開発は、正しく扱えばNode.jsやGoをも凌駕する圧倒的なパフォーマンスを発揮する。しかしそれは、PHPという言語が長年隠蔽してくれていた「メモリ管理」というエンジニアの原点に立ち返ることを意味する。
Zend VMの挙動を脳内にトレースし、「いつ作られ、どこで参照され、どの瞬間に解放されるべきか」をコードの行間から感じ取ること。それこそが、モダンPHPにおける真のシニアエンジニアの条件である。