【実務・中級編】Swoole/RoadRunner環境におけるリクエスト間でのメモリ状態の分離とクリーンアップ – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

伝統的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における真のシニアエンジニアの条件である。

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