こんにちは。PHPの裏側で動いているZendエンジンや、1リクエストの重みにロマンを感じるエンジニアの皆さん。
普段、LaravelやSymfonyといったモダンなフレームワークを使いこなし、Node.jsやGoなどで非同期処理やコルーチンの概念に触れてきた方なら、「PHP 8.xで導入されたFiber(ファイバー)って、一体内部でどう動いているんだろう?」と一度は気になったことがあるのではないでしょうか。
「非同期処理ができるらしいけれど、メモリは一体どう管理されているの?」
「コールスタックの切り替えって、Zend VMのCレベルでは何が起きているの?」
今回は、そんな知的好奇心旺盛なあなたに向けて、PHP 8.x Fiberのコルーチンスタックのメモリ管理、そしてガベージコレクション(GC)との相互作用について、C言語レベルのZendエンジンの挙動まで踏み込んで優しく紐解いていきたいと思います。ここを理解すると、PHPの裏側が驚くほど美しく見えてきますよ。
—
1. Fiberの本質:Zend VMにおける「実行コンテキストの分離」
まず、PHPのFiberが他の言語(例えばGoのゴルーチンなど)と何が違うのか、その前提を共有しておきましょう。
GoのゴルーチンがGoのランタイムによってスケジューリングされる緑色のスレッドであるのに対し、PHP 8のFiberは「スタックを持つ協調型マルチタスク(Cooperative Multitasking)のプリミティブ」です。つまり、OSスレッドとも、Webサーバーのプロセスとも独立した、純粋なPHPの実行状態(コールスタック)のコンテナに過ぎません。
Zend VMの内部(C言語のソースコード上)において、PHPの関数実行は `zend_execute_data` という構造体の連結リスト(コールスタック)によって管理されています。通常、PHPのスクリプトが上から下へ流れるときは、このリストが一本の木のように伸び縮みします。
しかし、Fiberを生成するとどうなるでしょうか?
start();
echo “親側で受け取った値: {$value}\n”;
// Fiberを再開
$fiber->resume();
このコードが実行されるとき、Zendエンジンは通常のメイン実行コンテキストとは別に、Fiber専用の独立した実行コンテキスト(zend_fiber構造体)をヒープ上に割り当てます。
—
2. コルーチンスタックのメモリ管理と確保の仕組み
「独立したスタック領域」と聞くと、メモリを大量消費するのではないかと心配になりますよね。でも、安心してください。Zendエンジンは非常に効率的に設計されています。
ヒープ上に確保されるスタック領域
Fiberが生成されると、内部的にはOSのスレッドスタックのような巨大なメモリではなく、Zendのメモリマネージャー(ZendMM)を介して、あるいはシステムコール(`mmap`など)を利用して、必要なサイズのメモリチャンクがヒープ上に確保されます。
PHP 8のFiberデフォルトスタックサイズは、プラットフォームやコンパイル時の設定にも依存しますが、通常は十分に小さく最適化されています。これにより、数千・数万のFiberをインスタンス化しても、OSのメモリ制限に即座に抵触するようなことはありません。
実行中のFiber間におけるメモリ参照の追跡
ここで重要なのが、「Fiberをまたいだ変数のスコープとメモリの寿命」です。
JavaScriptのクロージャやGoのゴルーチンと同様に、Fiberの内部関数が外部の変数をuse句などでキャプチャした場合、それらの変数はZendエンジンのシンボルテーブルやプレースホルダーから切り離され、ヒープ上のzval(PHPの変数を表す基本構造体)として適切に参照カウント(refcount)が維持されます。
42, ‘name’ => ‘Zend Engine’];
$fiber = new Fiber(function () use ($data) {
// $data はFiberのヒープコンテキストにコピー/参照保持される
Fiber::suspend();
print_r($data);
});
$fiber->start();
// 親側で $data を破棄しても、Fiberが生存している限りzvalのrefcountは0にならない
unset($data);
$fiber->resume();
この挙動のおかげで、「Fiberが中断している間に親側の変数が消えてセグメンテーションフォルト(Segmentation Fault)を起こす」といったC言語的な恐怖とは無縁でいられます。Zend VMのメモリ管理機構が、Fiberのライフサイクルとzvalの寿命を完璧に同期させてくれているからです。
—
3. ガベージコレクション(GC)とFiberの切っても切れない関係
さて、ここからが本題の核心です。PHPのガベージコレクション(参照循環の回収を行うコンカレントGC)は、Fiberのライフサイクルとどのように相互作用するのでしょうか?
循環参照の罠
PHPの通常のスクリプトでは、オブジェクト同士が循環参照(例:AがBを指し、BがAを指す)を持つと、参照カウントが0にならず、GCのバッファ(緩衝地帯)に送られて回収されます。
では、「Fiber内部で循環参照が発生したまま、Fiber自体が捨てられた(参照されなくなった)」場合はどうなるでしょうか?
b = $b;
$b->a = $a; // 循環参照の発生
Fiber::suspend();
// この後、親側で $fiber が破棄されると…?
});
$fiber->start();
// $fiber への参照をここで一切失わせる(スコープアウト)
$fiber = null;
このシナリオにおいて、Zendエンジンは非常にスマートに動きます。
1. `$fiber = null` となった瞬間、Fiberオブジェクト自体の参照カウントが0になります。
2. Fiberオブジェクトのデストラクタ(内部的には `zend_fiber_dtor`)が発火します。
3. このデストラクタは、Fiberが保持していた実行コンテキスト(zend_execution_context)や、そのスタック上に残されていたすべてのzvalを一網打尽に解放(zval_ptr_dtor)します。
つまり、Fiberそのものがスコープアウトして解放されるとき、そのFiberの内部スタックに閉じていた循環参照は、GCのフルスキャンを待つことなく、即座に確定破棄(Destruction)の対象となります。
注意すべきポイント:グローバルな状態やイベントループとの絡み
ただし、実務でReAmpやReactPHPなどのイベントループとFiberを組み合わせる際、よくあるアンチパターンとして「Fiber内でイベントループやグローバルなレジストリへ自分自身( `$this` や `$fiber` )を登録したまま放置する」ケースがあります。
start();
もしイベントループ側がこの `$runningFibers` の参照を保持し続けた場合、Fiber自体が終了している(あるいは中断したまま放置されている)にもかかわらず、Fiberオブジェクトのzvalのrefcountは0になりません。結果として、Fiberのスタック領域や内部変数がメモリ上に居座り続け、メモリリークの原因となります。
非同期処理を書くときは、「誰がこのFiberの参照を握っているのか(ライフサイクルの所有権はどこにあるのか)」を常に意識することが、堅牢なアーキテクチャを作る上での極意となります。
—
まとめ:PHPの裏側を掌握し、優雅な非同期アーキテクチャへ
今回は、PHP 8.x Fiberのスタックメモリ管理とガベージコレクションの相互作用について、Zendエンジンの内部構造を交えながら解説しました。
- Fiberは軽量なヒープ上の実行コンテキストであり、OSスレッドとは異なり効率的に管理されている。
- 変数の寿命はFiberのライフサイクルと連動しており、ZendVMが安全に参照カウントを維持してくれる。
- Fiberが破棄されると内部のスタックも一括解放されるが、グローバルな参照リークには注意が必要である。
PHPは単なる「動的なWebスクリプト言語」の枠を完全に脱却し、極めて洗練されたモダンな仮想マシンへと進化を遂げています。Fiberの内部挙動を脳内トレースできるようになったあなたなら、パフォーマンスとメモリ効率を極限まで高めた美しい非同期Webアプリケーションを設計できるはずです。
ぜひ、日々の開発やアーキテクチャ設計の引き出しに、この「Zendエンジンの視点」を取り入れてみてくださいね。