こんにちは。PHPの裏側を覗く旅へようこそ。
他の言語(Node.jsやGo、Pythonなど)で非同期処理やコルーチンの概念をバリバリ使いこなしてきた優秀なエンジニアほど、PHPでFiber(ファイバー)に触れたとき、こう思うはずです。「おっ、スタックを切り替えて中断・再開できるのか。これで非同期I/Oがスッキリ書けるぞ」と。
しかし、実際にFiberを使った本格的な非同期アプリケーションを組み上げ、高負荷なベンチマークを回してみた瞬間、背筋が凍るような光景に直面します。
――そう、「メモリが解放されない。リクエストが終わっても、なぜかプロセスが肥大化していく」という現象です。
今回は、XdebugやBlackfireといったプロファイリングツールの向こう側で、PHPのZendエンジンとFiberがメモリ空間で何をやらかしているのか、その低レイヤの真実を一緒に紐解いていきましょう。ここを理解すれば、PHPの裏側が驚くほど綺麗に見えるようになりますよ。
—
1. そもそもFiberの「スタック」はどこに確保されるのか?
Node.jsのPromiseやGoのGoroutineとは異なり、PHP 8.1で導入されたFiberは「スタックレス(厳密にはユーザーランド管理のスタック)」なコルーチンではありません。Fiberは独自のコールスタックを持ちます。
C言語やOSスレッドのコールスタックはOSが管理しますが、FiberのスタックはZendエンジンがヒープ領域(emalloc)上に直接割り当てたメモリブロックです。
実行エンジンから見たFiberの正体
1. `new Fiber(callable)` が実行されると、Zendエンジンはコールスタックを保持するための構造体をヒープ上に生成します。
2. `Fiber::start()` が呼ばれると、現在の実行コンテキスト(`zend_execute_data` やシンボルテーブルのポインタなど)が退避され、Fiber用のスタックコンテキストにスイッチします。
3. このとき、Fiber内で生成された変数、オブジェクト、クロージャは、すべてそのFiber専用のコンテキスト(あるいは親スコープ)に紐づきます。
ここで重要なのは、「Fiber内の変数は、ガベージコレクション(GC)やリクエスト終了時の自動解放のタイミングが通常の同期処理とは異なる」という点です。
—
2. なぜFiberでメモリリークが起きるのか?(3大アンチパターン)
非同期処理におけるメモリリークのほとんどは、次の3つの構造的欠陥から生まれます。これらはコード上は美しく見えても、Zendエンジンのメモリ管理機構にとっては「爆弾」になり得ます。
① 循環参照とFiberのライフサイクル
Fiberは「中断(Suspend)」した状態で変数やオブジェクトへの参照を保持し続けます。もしFiberのインスタンス自体がどこかのグローバルなイベントループや配列(例: 非同期タスクのキュー)に保持されたまま、その中でクロージャが外側のスコープの巨大なオブジェクトをキャプチャ(use)しているとどうなるでしょう?
Fiberが `resume()` されない限り、あるいは明示的に破棄(unset)されない限り、そのクロージャが掴んでいるメモリは一切解放されません。
② 例外とスタックトレースの残存
Fiber内で例外(`Exception` または `Throwable`)が発生し、それをキャッチしたままFiberが終了、あるいは中断した場合、例外オブジェクトが持つスタックトレース(バックトレース)には、その時点でのすべての変数が参照として含まれます。
これがイベントループ上で蓄積していくと、瞬く間にメモリが食い潰されます。
③ 静的プロパティやグローバルレジストリへの蓄積
「イベントループを回す」という性質上、多くの非同期PHPフレームワーク(ReactPHPやAmpのFiberベースラッパーなど)は、グローバルなマネージャーやイベントループインスタンスを持ちます。そこにFiberの参照がぶら下がり続けると、Zendエンジンのリクエストライフサイクル(FPMの `request_init` から `request_shutdown` まで)の境界を越えてメモリが残留し、Workerプロセスの肥大化(Memory Bloat)を引き起こします。
—
3. 実践:BlackfireとXdebugで「見えないメモリ」を暴く
では、実際にプロファイリングツールを使って、このメモリの迷宮を暴いてみましょう。
ここでは、Fiberがメモリをリークしている典型的なコード片と、それをBlackfireやXdebugでどのように検知するかを解説します。
リークを内包した非同期タスクランナーの例
taskQueue[] = $fiber;
$fiber->start();
}
public function getQueueCount(): int {
return count($this->taskQueue);
}
}
// 実行シミュレーション
$leaker = new MemoryLeaker();
for ($i = 0; $i < 5; $i++) {
$leaker->enqueueTask();
}
// キューに残ったままなので、50MB分の巨大データがヒープに居座り続けます。
Blackfireを用いたメモリプロファイリング手順
Blackfireなどのプロファイラを使う場合、CPU時間だけでなく「Memory(メモリ使用量)」と「Peak Memory(ピークメモリ)」のコールグラフを確認するのが定石です。
1. コールグラフの「Memory」モードへの切り替え
Blackfireのダッシュボードで、関数の実行時間ではなくメモリ消費量に焦点を当てます。
2. `emalloc` のスパイクを探す
Fiberが生成される箇所(`new Fiber` や `Fiber::start()`)を起点として、メモリが右肩上がりに増え、かつリクエスト終了(`zend_request_shutdown`)時に解放されていないノードを探します。
3. リファレンスカウントの確認
Xdebugを使用している場合は、特定のポイントで `gc_collect_cycles()` を呼び出し、回収される循環参照の数や、`memory_get_usage(true)` の推移をログに出力するデバッグコードを挟むのが効果的です。
—
4. 現場で使える!Fiberメモリリークの防衛策とアーキテクチャ設計
ファイバーを安全に、かつ爆速で動かすためのアーキテクチャ上の鉄則をいくつか共有しておきますね。
1. Fiberのライフサイクルは「短命」に保つ
Fiberは長々と状態を保持するためのものではありません。「処理を細切れにして協調 multitasking を行うための軽量な仕組み」です。
タスクが完了したら、あるいは中断から復帰して不要になったら、必ず `$fiber = null;` や `unset($taskQueue[$id]);` を行い、Zendエンジンのリファレンスカウントを即座に `0` に落としてください。
2. クロージャのキャプチャ変数は最小限に
Fiberに渡す無名関数(Closure)の中で、外側のスコープのオブジェクトを無造作に `use ($this)` や `$largeObject` でキャプチャしないこと。必要なのはプリミティブなIDや、状態を引数として渡すことです。
// 悪い例:外側の巨大なオブジェクトを丸ごとキャプチャ
$bigObject = new VeryHeavyService();
$fiber = new Fiber(function() use ($bigObject) {
$bigObject->doSomething();
});
// 良い例:必要なIDだけを渡し、サービスは都度解決する(あるいはスコープを汚染しない)
$serviceId = $bigObject->getId();
$fiber = new Fiber(function(int $id) {
$service = ServiceLocator::get($id);
$service->doSomething();
}, [$serviceId]);
3. イベントループ終了時の明示的なクリーンアップ
Swoole, RoadRunner, あるいは純粋なPHPベースのイベントループを書く場合、リクエストやタスクのバッチ処理が完了したタイミングで、すべての保留中Fiberを強制終了(あるいは例外をスローして巻き戻し)させるガーベージコレクション的アプローチをフックとして仕込んでおくと、メモリリークを根絶できます。
—
おわりに:PHPの裏側を掌握するということ
Fiberは、PHPを単なる「リクエストごとに破棄されるスクリプト言語」から、常駐型の強力な非同期アプリケーションプラットフォームへと押し上げる起爆剤です。
しかし、便利さの裏側には、Zendエンジンが裏で管理するメモリの挙動や、スコープの寿命に対する深い理解が求められます。「なぜここでメモリが減らないのか?」と悩んだときは、コードの上層を見るのではなく、「今、どの変数への参照がどのFiberのスタックコンテキストに掴まれているか」という低レイヤのメモリ空間を脳内でイメージしてみてください。
この視点さえ手に入れれば、どんなに複雑な非同期・並行処理アーキテクチャであっても、完全にコントロールできるようになりますよ。さあ、次のコードをより美しく、よりセキュアに書き換えていきましょう。