Swoole/RoadRunnerにおけるShared MemoryとGCの競合:プロセス間共有データとオブジェクトライフサイクルの管理
コードレビューを始めよう。君たちが提出したSwooleベースの非同期常駐型APIサーバーのプルリクエスト、動かすことだけを優先してメモリの物理的実態を完全に無視している。
「リクエストごとにメモリが増え続け、数時間でOOM Killerにプロセスが屠られる」
「親プロセスで初期化したはずのキャッシュデータが、なぜか子ワーカー間で汚染され、別ユーザーのデータが漏洩した」
これらのバグに直面したとき、君たちは「Swooleの不具合だ」とため息をつくかもしれない。だが、違う。犯人はSwooleでもRoadRunnerでもなく、Zend VMのメモリ管理とGC(ガベージコレクション)の仕様を理解しないまま、従来のCGI的な発想でコードを書いた君たち自身だ。
今回は、常駐型PHPランタイムにおける「プロセス間共有メモリ」と「参照カウント・GC」の致命的な衝突メカニズムを紐解き、現場で絶対に生き残るためのメモリ設計ルールを授けよう。
—
1. 内部構造の真実:なぜ常駐型PHPでメモリ事故が起きるのか
従来のPHP(PHP-FPM)は、「1リクエスト=1プロセス(またはスレッド)」の使い捨てモデルだ。リクエストが終了すれば、OSがプロセスごとメモリ空間を全開放するため、少々ダーティなコードを書いてもメモリリークはプロセス寿命と共に綺麗に消え去る。
しかし、SwooleやRoadRunnerに代表される常駐型ランタイム(Long-running Application)では話が180度変わる。
[Master Process]
│
├── [Worker Process 1] ── (Zend VM / Shared Memory / GC Cycle)
├── [Worker Process 2] ── (Zend VM / Shared Memory / GC Cycle)
└── [Worker Process 3] ── (Zend VM / Shared Memory / GC Cycle)
Zend VMのメモリ管理と参照カウンティング
PHPのすべての変数、配列、オブジェクトは、Zendエンジン内部で `zval` という構造体として管理されている。オブジェクトの実体は `zend_object` であり、そこには `refcount`(参照カウンタ)が刻まれている。
通常、ローカルスコープの変数はリクエスト終了時にシンボルテーブルの破棄と共に `refcount` がデクリメントされ、0になった瞬間にヒープから消去される。
共有メモリ(Swoole Table / APCu)の罠
Swooleの `Swoole\Table` や、Worker間を跨ぐデータ共有において、我々はしばしば大きな勘違いをする。
「PHPのオブジェクトや配列をそのまま共有メモリセグメントに突っ込める」 と。
残念ながら、Zend VMのオブジェクト構造体はポインタの塊であり、他のプロセスへそのままシリアライズなしに共有メモリ経由で渡すことはできない(Swoole Tableが格納できるのはプリミティブな型か、シリアライズされた文字列のみだ)。
もし無理やりプロセス間でデータを共有しようとしたり、Workerの初期化フェーズ(`onWorkerStart`)で生成した巨大なオブジェクトをグローバル変数や静的プロパティに保持させた場合、以下の地獄が待っている。
1. Copy-on-Write(CoW)の破壊とメモリ肥大化
親プロセスで読み込まれたコードや静的データは、Linuxの `fork()` によって子ワーカーにCoWで共有される。しかし、ワーカー側でそのデータ(配列やオブジェクトのプロパティ)を「書き換え」た瞬間、OSレベルで物理メモリへのコピーが発生し、プロセスのメモリフットプリントが跳ね上がる。
2. 循環参照とGCの不都合な真実
PHPのGCは、参照カウントが0にならず、かつ循環参照(`refcount` の減少が止まった孤立した環)を検知したときに初めて「バッファ(Roots Buffer)」をスキャンして回収を行う。常駐プロセスにおいて、グローバルスコープや永続的な構造体に循環参照を作り込むと、二度とGCの回収対象にならない永久メモリリーク(Memory Leak) と化す。
—
2. 【アンチパターン】やってはいけないメモリ破壊コード
まずは、君たちがやりがちな最悪のコードを見てみよう。
/
class BadCacheManager {
private static array $cache = [];
public static function put(string $key, object $data): void {
// 永続的な静型プロパティにオブジェクトを保持
// プロセスが生存し続ける限り、このオブジェクトのrefcountは0にならない
self::$cache[$key] = $data;
}
public static function get(string $key): ?object {
return self::$cache[$key] ?? null;
}
}
// SwooleのWorker起動時
$server->on(‘WorkerStart’, function ($server, int $workerId) {
// データベース接続や重いオブジェクトを静的領域に保存してしまう
$dbConnection = new PDO(‘mysql:host=localhost;dbname=test’, ‘root’, ”);
// オブジェクト自身がPDOやリクエストコンテキストへの参照を持つ場合、循環参照の温床になる
$holder = new stdClass();
$holder->db = $dbConnection;
$holder->self = $holder; // 致命的な循環参照!
BadCacheManager::put(‘connection’, $holder);
});
何が問題か?
このコードが走った瞬間、`$holder->self = $holder` によってZend VMのGCアルゴリズムを欺く完璧な循環参照が完成する。さらに、`BadCacheManager` はワーカープロセスが生存している限りメモリ上に残り続けるため、このメモリは永遠に解放されない。
—
3. 【実践】安全かつ高速なメモリライフサイクル管理リファレンス
では、SwooleやRoadRunner上で、プロセス間での安全なデータ共有と、確実なメモリ解放(GCとの協調)を実現するプロダクションレディなコードを提示しよう。
ここでは、「Swoole Tableによるプリミティブな共有」 と 「ワーカーローカルなオブジェクトプール、および明示的なスコープアウト」 を組み合わせた設計を採用する。
/
final class SafeSharedMemoryManager
{
private Table $table;
public function __construct(int $size = 1024)
{
// 共有メモリ(Shared Memory)上にアロケートされるSwoole Tableを初期化
// オブジェクトそのものは格納せず、スカラー値(string, int, float)のみを格納する
$this->table = new Table($size);
$this->table->column(‘data’, Table::TYPE_STRING, 4096); // 最大4KBのシリアライズデータ
$this->table->column(‘updated_at’, Table::TYPE_INT);
$this->table->create();
}
/
- データを安全に共有メモリに書き込む(シリアライズ化による参照の分断)
/
public function set(string $key, mixed $value): void
{
// オブジェクトや配列をシリアライズし、Zend VMの参照関係を完全に断ち切る
// これによりプロセス間のメモリ汚染と循環参照の伝播を防ぐ
$serialized = serialize($value);
$this->table->set($key, [
‘data’ => $serialized,
‘updated_at’ => time(),
]);
}
/
- 共有メモリからデータを安全に読み出す
/
public function get(string $key): mixed
{
$row = $this->table->get($key);
if ($row === false) {
return null;
}
// デシリアライズ時に新しいzvalツリーをローカルヒープに構築
return unserialize($row[‘data’], [‘allowed_classes’ => [
// セキュリティのため、許可するDTOクラスのみを明示的に指定する
\App\DTO\UserDTO::class
]]);
}
public function delete(string $key): void
{
$this->table->del($key);
}
}
/
- リクエストごとのライフサイクルとGCを完全にコントロールするハンドラー
/
final class RequestLifecycleHandler
{
public function __construct(
private SafeSharedMemoryManager $memoryManager
) {}
public function handleRequest(mixed $request, mixed $response): void
{
try {
// 1. リクエストスコープのコンテナを生成
$container = new \stdClass();
$container->requestData = $request->get[‘id’] ?? null;
// 2. 共有メモリからの安全なデータ取得
$cachedUser = $this->memoryManager->get(‘user_’ . $container->requestData);
// 3. ビジネスロジックの実行(メモリ消費を伴う処理)
$result = $this->processBusinessLogic($container, $cachedUser);
$response->end(json_encode($result));
} catch (Throwable $e) {
$response->status(500);
$response->end(‘Internal Server Error’);
} finally {
// 4. 【極めて重要】リクエスト終了時の明示的なメモリクリーンアップ
// Zend VMのGC頼みにするのではなく、重いオブジェクトの参照を強制的に断つ
unset($container, $cachedUser, $result);
// 必要に応じて強制GC発動(ただし高頻度なgc_collect_cycles()はCPUを圧迫するため、
// メモリ使用量が閾を超えた場合のみ、あるいは特定の重い処理の後に限定すべき)
if (gc_enabled() && gc_status()[‘buffered’] > 10000) {
gc_collect_cycles();
}
}
}
private function processBusinessLogic(object $container, ?object $cachedUser): array
{
// ローカルで完結する処理
return [
‘status’ => ‘success’,
‘user’ => $cachedUser,
];
}
}
—
4. コードレビューの鉄則:エンジニアが守るべき3つの戒め
今回の実装とアーキテクチャから、シニアエンジニアとして君たちに突きつける最終的な設計ルールは以下の3点だ。
1. 共有メモリには「オブジェクト」を置くな、置くなら「シリアライズされたスカラー値」に徹しろ
Swoole TableやAPCuなどの共有セグメントに、PHPのオブジェクトポインタや複雑な参照を持つ配列をそのまま配置しようとするな。必ず `serialize()` / `unserialize()` を挟み、プロセス間のヒープ空間を完全に分離(Isolate)しろ。
2. 静的プロパティ(`static`)は「書き込み禁止領域」と心得よ
`onWorkerStart` で読み込まれるイミュータブル(不変)な設定値やルーターのインスタンス以外を静的プロパティに保持させるな。特に動的なデータを静的変数に蓄積する設計は、数時間後のOOM Killerへの片道切符だ。
3. GCの「おまかせ」を捨て、リクエスト終端で参照を断ち切れ
常駐型アプリケーションにおいて、GCは万能ではない。特に循環参照を含みうる巨大なDTOやORMエンティティは、リクエストの `finally` ブロックで `unset()` を行い、可能な限り速やかに `refcount` を0に落とす意識を持て。
メモリを支配する者が、非同期・常駐型PHPのパフォーマンスを支配する。
明日からのコードでは、すべての変数のライフサイクルとメモリ上の居場所を頭の中で描き切ってからコードを書くように。以上だ。