序章:Zend VMの「裏切り」と、C言語拡張によるFiber制圧
コードレビューの場で、もしジュニア・ミドル層のエンジニアが「Fiberを使えば非同期I/Oが簡単に書けます!」と嬉しそうに提案してきたら、私はこう問い返す。
「おい、そのFiber、内部のZend VMスタックとコールフレームがどう管理されているか説明できるか? I/O待ちなのか、それとも単なるユーザーランドの協調マルチタスクの玩具か?」
PHP 8.1で導入されたFiber(ファイバー)は、表面的にはコールスタックを途中でサスペンドし、任意のタイミングでレジュームできる強力なプリミティブだ。しかし、PHPのWebアプリケーションの多くが依然としてマルチプロセス(PHP-FPM)の単一スレッドモデルの上で動いている現実において、Fiberを無考えに導入することは、メモリリークとZendエグゼキュータのコンテキストスイッチのオーバーヘッドという地雷を踏むことに等しい。
真のパフォーマンスと低レイヤの制御を求めるならば、我々はPHPの表層(ユーザーランド)を捨て、C言語によるPHP拡張(Extension)の領域へと踏み込まなければならない。Zend Engineの内部構造をハックし、Cライブラリの非同期イベントループとFiberを直接結びつけることで初めて、PHPは真の「高スループット非同期ランタイム」へと昇華する。
本稿では、Zend VMのC言語APIにおけるFiberの内部構造を暴き、拡張モジュールレベルでコルーチンを制御する極限のテクニックを伝授する。
—
1. Zend EngineにおけるFiberの内部構造(C言語レベルの解剖)
Zend Engine(PHPのコア)において、Fiberは `zend_fiber` 構造体として表現されている。PHPのソースコード(Zend/zend_fibers.h)を覗いたことがある者なら知っているはずだが、Fiberの本質は「独立したCのスタック空間と、Zend VMの実行コンテキスト(`zend_execute_data` や `zend_vm_stack`)の切り替え」に他ならない。
ユーザーランドのPHPコードで `Fiber::suspend()` が呼ばれたとき、内部では何が起きているのか?
1. VMコンテキストの退避: 現在の `EG(current_execute_data)` やグローバルな実行状態が、該当するFiberオブジェクトのコンテキストにシリアライズ(退避)される。
2. スタックポインタのスイッチ: CPUのスタックポインタ(SP)およびフレームポインタ(FP)が、親(あるいは呼び出し元)のコンテキストへと切り替わる。
3. 制御の返還: イベントループやメインのFiberへ処理が戻る。
これをC言語の拡張モジュール側から直接操作する場合、通常のPHP関数呼び出しとは異なり、「Zend VMのライフサイクルをバイパスしてコルーチンを生成・再開する」というアプローチが必要になる。特に、libuvやlibevといったCの非同期I/OライブラリとFiberを統合する際、この低レイヤの構造を理解していないと、セグメンテーションフォールト(SIGSEGV)やZendメモリマネージャ(ZMM)のヒープ破壊を引き起こす。
—
2. 拡張モジュール設計における致命的な罠と設計ルール
C拡張でFiberや非同期処理を扱う際、以下の設計ルールを破る者は、即座にコードレビューでリジェクトされる。
- ルール1: リクエストを跨いだFiber/Cコンテキストの保持の禁止
PHP-FPM環境下では、1つのリクエストが終わればZend MMは全メモリを解放する。Cの静的領域やグローバル変数にFiberのポインタを残存させると、次のリクエストでダングリングポインタとなり、確実にクラッシュする。
- ルール2: ブロッキングC関数の素朴な呼び出しの禁止
Cの拡張内で通常のブロッキングI/O(例: `sleep()` や同期ソケット通信)を実行すると、PHPのプロセス全体がブロックされる。Fiberの意味が完全に失われるため、ノンブロッキングディスクリプタとイベントループの統合が必須となる。
- ルール3: 参照カウント(`Z_ADDREF` / `Z_DELREF`)の厳密な管理
CからPHPのFiberオブジェクトを操作する場合、GC(ガベージコレクション)のタイミングでオブジェクトが勝手に解放されないよう、ハンドルと参照カウントを正確に追跡しなければならない。
—
3. 実装:C拡張とFiberを連携させるコルーチン基盤の構築
ここでは、概念をコードに落とし込む。直接C言語のソースを書く代わりに、「C拡張の内部挙動を模倣しつつ、実務のアーキテクチャ設計に直結するPHPコード(および内部のC的思考を取り入れた堅牢な設計)」を示す。
以下のコードは、イベントループとFiberを組み合わせ、Cライブラリ(イメージとしてはlibuv経由の非同期DB/ネットワークアクセス)からのコールバックを受けてFiberを安全にレジュームするパターンのリファレンス実装である。
/
final class CoroutineDispatcher
{
/ @var array
private array $fibers = [];
/ @var array
private array $results = [];
/ @var int インクリメンタルなFiber IDカウンタ /
private int $nextId = 1;
/
- 新規のコルーチンを登録し、即座に初期実行(サスペンドまで)を行う。
/
public function go(callable $task): int
{
$id = $this->nextId++;
$fiber = new Fiber(function () use ($task, $id) {
try {
// タスクを実行し、結果を保持
$this->results[$id] = $task();
} catch (Throwable $e) {
// 例外はコルーチン内でキャッチし、呼び出し元へ伝播させるための構造化
$this->results[$id] = $e;
} finally {
// 終了時にプールからクリーンアップ(メモリリーク防止)
unset($this->fibers[$id]);
}
});
$this->fibers[$id] = $fiber;
// 初回実行:最初の suspend() に到達するまで動かす
$fiber->start();
return $id;
}
/
- 【C拡張連携ポイント】
- 内部のCライブラリ(非同期I/O)が処理を完了した際、Cのイベントループから
- このメソッドがコールバックとして叩かれる想定の設計。
/
public function resume(int $id, mixed $data = null): void
{
if (!isset($this->fibers[$id])) {
// 既に破棄された、あるいは存在しないIDへのアクセスは安全に弾く
return;
}
$fiber = $this->fibers[$id];
if ($fiber->isSuspended()) {
// C側で取得したデータをFiberに渡し、実行を再開させる
$fiber->resume($data);
}
}
/
- イベントループの駆動(モック)
- 実際のC拡張では、ここで uv_run() などのC関数をブロックせずに回し続ける。
/
public function tick(): bool
{
if (empty($this->fibers)) {
return false;
}
// 実際にはここで非同期I/Oのポーリングを行う
// 例として、中断中のFiberが存在する場合はループを継続
return true;
}
}
// ==========================================
// 実行・検証用スクリプト(実務を想定したユースケース)
// ==========================================
$dispatcher = new CoroutineDispatcher();
// コルーチン1の登録
$fid1 = $dispatcher->go(function () {
echo “[Fiber #1] 処理開始。非同期I/O(DBクエリ等)を模倣して中断します…\n”;
// C拡張側で非同期処理をリクエストし、自分自身をサスペンドする
$result = Fiber::suspend(‘db_query_handle_1’);
echo “[Fiber #1] 再開しました! 取得データ: {$result}\n”;
return ‘Done 1’;
});
// コルーチン2の登録
$fid2 = $dispatcher->go(function () {
echo “[Fiber #2] 処理開始。外部APIコールを模倣して中断します…\n”;
$result = Fiber::suspend(‘http_request_handle_2’);
echo “[Fiber #2] 再開しました! 取得データ: {$result}\n”;
return ‘Done 2’;
});
// — ここから下は「C拡張のイベントループ」が裏で動いている世界線のシミュレーション —
echo “\n— [C EventLoop] 非同期I/Oの完了を検知し、順次Fiberをレジュームします —\n\n”;
// C側(イベントループ)が処理完了を検知し、Fiber #1 にデータを返して再開させる
$dispatcher->resume($fid1, ‘SELECT FROM users (Mock Result)’);
// C側が処理完了を検知し、Fiber #2 にデータを返して再開させる
$dispatcher->resume($fid2, ‘HTTP/1.1 200 OK (Mock JSON)’);
実行結果(脳内トレース用)
[Fiber #1] 処理開始。非同期I/O(DBクエリ等)を模倣して中断します…
[Fiber #2] 処理開始。外部APIコールを模倣して中断します…
— [C EventLoop] 非同期I/Oの完了を検知し、順次Fiberをレジュームします —
[Fiber #1] 再開しました! 取得データ: SELECT FROM users (Mock Result)
[Fiber #2] 再開しました! 取得データ: HTTP/1.1 200 OK (Mock JSON)
—
4. アーキテクチャの結びとエンジニアへの警鐘
ここまで、Fiberの内部構造からC拡張を見据えた非同期並行処理の設計思想までを解説した。
勘違いしてはならないのは、Fiberは「魔法の高速化ツール」ではないということだ。CPUバウンドな処理をFiberで分割しても、シングルスレッドであるPHPの性質上、処理総量が減るわけではない。むしろ、コンテキストスイッチのオーバーヘッド分だけパフォーマンスが劣化するケースすら存在する。
Fiberが真価を発揮するのは、「I/O待ちがボトルネックとなっているマイクロサービスのAPIゲートウェイ」や、「数千のコネクションを効率的にさばくWebSocketサーバ」をC拡張ベースのイベントループと共に構築する場合のみである。
コードレビューで「とりあえずFiberを使いました」というプルリクエストを見かけたら、その背後にあるZend VMのスタック管理とメモリライフサイクルまで論理的に説明できるか、厳しく問い詰めてほしい。それこそが、真にPHPを掌握したアーキテクトの仕事である。