【入門編】Swoole/RoadRunnerにおける`shared memory`とGCの競合:プロセス間共有データとオブジェクトライフサイクルの管理 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

こんにちは。SwooleやRoadRunnerといった常駐型(ロングラン)PHPアーキテクチャの世界へようこそ。

従来のPHP-FPMの「1リクエストごとにプロセスが生まれ、死んでいく」という世界から一歩踏み出し、メモリ上にアプリケーションを常駐させ、超高速な非同期・並行処理を実現しようとしたとき、多くのエンジニアが必ず一つの「壁」にぶつかります。それが「メモリの共有とガベージコレクション(GC)の競合」です。

Node.jsやGoの経験がある方なら、「なぜPHPでプロセス間でデータを共有すると、そんなに苦労するんだろう?」と不思議に思うかもしれません。実はここには、PHPという言語のアイデンティティである「参照カウント」と「Zendエンジン独自のメモリ管理モデル」の深い深い理由が隠されているのです。

今回は、Swooleの共有メモリ(TableやCo\Systemなどではなく、プロセス間でオブジェクトや配列をどう扱うか、あるいはWorker間でデータをどう同期させるか)をテーマに、PHPの内部エンジンが裏側で何を行っているのか、その核心を紐解いていきましょう。ここを理解すれば、あなたのPHPのコードは「ただ動くもの」から「極限まで洗練されたシステム」へと生まれ変わりますよ。

—

1. 伝統的PHP-FPMのメモリ解放モデルはなぜ「安全」だったのか?

まず、私たちが普段当たり前のように使っているPHP-FPMのメモリ管理を振り返ってみましょう。

PHP-FPMでは、1つのHTTPリクエストが来ると、OSのプロセス(またはスレッド)がそれを処理します。
1. スクリプトが実行され、変数が作られ、オブジェクトがインスタンス化される。
2. リクエストが終わる。
3. OSがそのプロセスごとメモリ空間を丸ごと解放する。

このモデルの何が素晴らしいかというと、「プログラマがメモリリークやGCのタイミングを気にする必要が全くなかった」という点です。どれほど複雑な循環参照を作ろうとも、プロセスが死ねばOSのページテーブルがクリアされ、メモリは1バイト残らずOSに返却されます。ZendエンジンのGCは、単にリクエスト寿命の間の「お片付け」に過ぎなかったのです。

常駐型アプリケーション(Swoole)の残酷な現実

しかし、SwooleやRoadRunnerを用いた常駐型モデルでは、この前提がすべて崩れ去ります。
マスタープロセスが起動し、そこからフォークされた複数のWorkerプロセスが、何千、何万というリクエストをプロセスを再起動せずに処理し続けます。

ここで、あるWorkerプロセスがリクエスト処理中に巨大なオブジェクトを生成し、それをグローバルなスコープ(Swooleのテーブルや、静的プロパティ、あるいはプロセス間共有メモリ)に格納したとしましょう。
リクエストが終わっても、プロセスは死にません。つまり、そのオブジェクトの参照が生き続ける限り、Zendエンジンのメモリプールには残り続けます。

ここに、メモリリークの温床と、プロセス間でのデータ共有の難しさの本質があります。

—

2. Zendエンジンの「参照カウント」と共有メモリの決定的なミスマッチ

PHPの変数やオブジェクトは、内部で `zval`(Zend Value)という構造体として管理されています。
この `zval` の中には、その値が今いくつから参照されているかを示す `refcount`(参照カウント)という整数値が含まれています。

/ 概念的なZendエンジンの構造イメージ /
typedef struct _zval_struct {
zend_value value;
union {
uint32_t type_info;
} u1;
union {
uint32_t next;
uint32_t cache_slot;
uint32_t refcount; / <-- これが命取りになる / } u2; } zval; PHP-FPMの世界では、この `refcount` が0になった瞬間にメモリが解放されます。また、親子で循環参照(AがBを指し、BがAを指す)が起きても、PHPの「循環参照GC(Circular Collector)」が定期的に動き出し、孤立したメモリの輪を見つけて回収してくれます。

では、Swooleで「プロセス間共有(Shared Memory / Swoole Table等)」を行おうとすると、何が起きるでしょうか?

Swooleには、複数プロセス間で高速にデータを読み書きするための `Swoole\Table` や、外部の共有メモリ機構が存在します。しかし、ここで知っておかなければならない極めて重要な事実があります。

「PHPのオブジェクト(インスタンス)や複雑な配列を、そのままの状態で生の共有メモリ(Shared Memory)に安全に配置することはできない」

なぜなら、PHPのオブジェクトが持つポインタや `refcount` は、「そのプロセス内のヒープメモリ上のアドレス」を指しているからです。
もしプロセスAのヒープ上にあるオブジェクトのポインタを、共有メモリ経由でプロセスBにそのまま渡したとしたらどうなるでしょう? プロセスBのメモリ空間におけるそのアドレスには、全く別のゴミが入っているか、あるいは不正アクセス(Segmentation Fault)を引き起こしてWorkerが即死します。

そのため、Swooleなどの環境でデータをプロセス間で共有するアプローチには、大きく分けて2つの方法が取られます。

1. シリアライズ(Serialize / Unserialize または MessagePack)して共有メモリに置く
データを一度ただの「バイト列(文字列)」に変換して共有メモリ(`Swoole\Table` のカラムや、IPC機構)に保存し、読むときは反対側で復元する。
2. ステートレスなアプローチ(データの共有を避け、リクエストごとに独立させる)
メモリ上でオブジェクトを共有するのではなく、Redisやデータベース、あるいはSwooleのTableには「プリミティブな値(整数や文字列)」のみを格納し、オブジェクトのライフサイクルは各Workerのローカルメモリに閉じ込める。

—

3. 実践:Swoole環境下でのメモリ管理とGC対策のコードパターン

百聞は一見にしかず。SwooleのWorker環境で陥りがちなメモリリークの罠と、それを回避する実践的なコードパターンを見てみましょう。

以下のコードは、SwooleのHTTPサーバー内で、リクエストごとにオブジェクトを静的プロパティに蓄積させてしまい、メモリリークを引き起こす危険な例と、その対策版です。

❌ 危険なコード:静的プロパティによるメモリリーク

on(“Request”, function (Request $request, Response $response) {
// リクエストごとにオブジェクトを生成し、静的配列に追加していく
$heavyData = new stdClass();
$heavyData->ip = $request->server[‘remote_addr’];
$heavyData->payload = str_repeat(‘A’, 1024 1024); // 1MBのダミーデータ

// ★ここで無限に蓄積され、Workerのメモリがパンクする(メモリリーク)
BadWorker::$requestLog[] = $heavyData;

$response->end(“Processed. Current log count: ” . count(BadWorker::$requestLog));
});

$server->start();

このコードを実行すると、リクエストが飛ぶたびにWorkerプロセスのメモリ消費量が1MBずつ増えていき、最終的に `Allowed memory size exhausted` でクラッシュします。PHP-FPMであればリクエスト終了時にすべて消えるものが、常駐型では蓄積されてしまう典型例です。

—

⭕ 堅牢なコード:明示的なライフサイクル管理とSwoole Tableの活用

では、これを安全に、かつ複数プロセス間で安全にデータを共有(または隔離)するにはどうすればよいでしょうか?
プリミティブなデータのみを `Swoole\Table`(内部でLock-freeな共有メモリを使用)に格納し、重いオブジェクトはリクエストスコープ内で完全に完結させます。

column(‘ip’, Table::TYPE_STRING, 64);
$table->column(‘hit_count’, Table::TYPE_INT);
$table->create();

$server = new Server(“127.0.0.1”, 9501);
// サーバーオブジェクトにテーブルを持たせる
$server->sharedTable = $table;

$server->on(“Request”, function (Request $request, Response $response) use ($server) {
$clientIp = $request->server[‘remote_addr’] ?? ‘unknown’;

// 2. 共有メモリ(Table)からアトミックにデータを取得・更新
$row = $server->sharedTable->get($clientIp);
if ($row === false) {
$server->sharedTable->set($clientIp, [
‘ip’ => $clientIp,
‘hit_count’ => 1
]);
$count = 1;
} else {
$newCount = $row[‘hit_count’] + 1;
$server->sharedTable->set($clientIp, [
‘ip’ => $clientIp,
‘hit_count’ => $newCount
]);
$count = $newCount;
}

// 3. リクエスト内で生成した重いオブジェクトは、このスコープ限定にする
// リクエストが終了した瞬間($response->endのあと)、自動的に参照カウントが0になり
// Zendエンジンのローカルヒープから安全に解放されます。
$temporaryData = new stdClass();
$temporaryData->message = “Hello, your hit count is {$count}”;

$response->header(‘Content-Type’, ‘text/plain’);
$response->end($temporaryData->message);

// ※明示的にunsetしなくてもスコープを抜ければ消えますが、
// 巨大なデータを扱う場合は早めのunsetがZendエンジンのGCを助けます
unset($temporaryData);
});

$server->start();

—

4. チーフアーキテクトからの実践的アドバイス

SwooleやRoadRunnerを本番環境に投入する際、エンジンの挙動を完全に手なずけるための黄金律をいくつか授けましょう。

1. 「静的変数(static)」と「シングルトンパターン」の再定義
これらはFPM時代には安全なキャッシュ機構として愛用されましたが、常駐型アプリにおいては「メモリリークの温床」へと変貌します。状態(State)を持つシングルトンは原則として禁止し、サービスコンテナ等は「リクエストスコープ」で正しくコンテキストがリセットされる設計にしてください。
2. Swoole Tableにはオブジェクトを入れない
前述した通り、Swoole Tableは排他制御を伴う超高速な共有メモリ空間ですが、格納できるのは文字列と数値のみです。オブジェクトを無理やりシリアライズして詰め込むことも可能ですが、シリアライズ/アンシリアライズのCPUコストが乗るため、パフォーマンスの恩恵が薄れます。データ共有は必要最小限に留めましょう。
3. 定期的なWorkerの再起動(Max Requests)を活用する
どれほど気をつけてコード書いても、サードパーティ製ライブラリのどこかで微小なメモリリーク(C拡張モジュールレベルのもの含む)が起きることはゼロではありません。Swooleの `$serv->set([‘max_request’ => 10000])` などを設定し、一定リクエストを処理したらWorkerが穏やかに自死し、マスタープロセスが新しい新鮮なWorkerをフォークする仕組みを必ず導入してください。これは可用性を高めるための極めて有効な防衛策です。

—

おわりに

PHPのガベージコレクションとメモリ管理は、一見すると黒魔術のようにおぼろげに見えるかもしれません。しかし、その裏にある「Zendエンジンの構造」「プロセスとヒープの関係」「参照カウントの仕組み」を一段深いレイヤから眺めてみると、すべての挙動が必然的で、美しい論理で動いていることに気づくはずです。

ここをクリアできれば、PHPはもはや「遅いスクリプト言語」ではなく、GoやNode.jsに匹敵する、いやそれ以上に堅牢で爆速なWebアプリケーションプラットフォームへと化けます。

ぜひ、あなたのアーキテクチャの引き出しにこの知見を加え、最高にモダンなPHPシステムを構築してくださいね。

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