こんにちは。普段、Node.jsやGo、あるいはRustなどで非同期処理やコルーチンをバリバリ書きこなしているあなたなら、PHPの「ブロックするI/O」という昔ながらの挙動に、どこかど歯痒さを感じたことがあるかもしれませんね。
「PHPだって、もうFiber(ファイバー)があるじゃないか」
そう思って調べたものの、公式ドキュメントにあるのは簡単なサスペンド・レジュームの例ばかり。実務でどうイベントループと統合するのか、あるいは既存のC拡張やレガシーなブロッキングI/Oとどう戦うべきなのか、その核心に迫る情報はなかなか見つからない……。そんな壁にぶつかっていませんか?
大丈夫です。今回は、PHPの内部エンジン(Zend VM)の裏側を覗き込み、C言語レベルでFiberがどうハンドリングされているのか、そのメカニズムを紐解いていきましょう。ここを理解すると、PHPという言語の見え方が劇的に変わりますよ。
—
1. そもそもFiberとは何か? Zend VMのスタック構造から理解する
私たちが普段書いているPHPのコードは、Zend Engineによって「オペコード(Opcode)」にコンパイルされ、Zend VMの上で実行されます。通常、関数呼び出しや制御構文のネストは、Cのコールスタック、あるいはZend VMが管理するコールスタック(`zend_execute_data`の連結リスト)の上で一直線に処理されます。
従来のPHPには「スタックの巻き戻し(Unwinding)」の概念がありませんでした。例外を投げない限り、一度入った関数からは、最後まで処理を完了させて戻るしかなかったのです。
そこに登場したのがFiberです。Fiberの本質は何か? 一言で言えば、「独自のコールスタックを持つ、ユーザースペースの実行コンテキスト(Cooperative Multitasking)」です。
従来の関数呼び出し vs Fiberのメモリ空間
通常の世界では、関数がネストするたびにスタックフレームが積み上がります。
[Global Scope] -> [Controller] -> [Service] -> [Database Query (ここでブロック!)]
この「Database Query」の待ち時間の間、PHPプロセス(あるいはFPMのワーカー)はOSスレッドを占有し、CPUは何もせずに待機(あるいはコンテキストスイッチのコストを支払う)します。
しかし、Fiberを使うと、このスタックフレームの塊(`zend_execute_data`とそれに紐づく実行状態)をごっそりヒープメモリ上に退避させることができます。
[Fiber 1 (Suspended)] <-- ヒープに退避! [Main / Event Loop] <-- 別の処理を実行可能! この切り替え(Suspension & Resumption)が、C言語レベルでどのように行われているのか、拡張開発の視点から見てみましょう。 ---
2. C言語拡張(zend_fiber)の内部構造を覗く
PHP 8.1で導入されたFiberは、PHPのコアに組み込まれた `zend_fiber` 拡張(内部API)によって支えられています。
PHPのソースコード(Zend/zend_fibers.cなど)を覗くと、Fiberの実体は `zend_fiber_context` という構造体であることが分かります。この構造体は、OSやアーキテクチャに依存する低レイヤのコンテキストスイッチ機構(Boost.Contextに類似したアプローチや、ucontext、あるいはWindowsのFiber API)をラップしています。
C言語レベルでFiberを扱う際の、概念的なライフサイクルは以下のようになっています。
1. 生成 (`zend_fiber_create`): 新しいスタック領域を割り当て、エントリポイントとなる関数を指すコンテキストを初期化する。
2. 切替 (`zend_fiber_switch`): 現在のCPUレジスタ(スタックポインタ、プログラムカウンタなど)を退避し、ターゲットとなるFiberのレジスタ状態を復元する。
3. 破棄 (`zend_fiber_destroy`): Fiberが終了した際、動的に割り当てられたスタックメモリを解放する。
もしあなたが独自のPHP拡張(C言語)を書いていて、既存のCライブラリ(例えば、libcurlやlibpqなどのノンブロッキングAPI)をPHPのFiberと統合したい場合、この `zend_fiber` APIとイベントループ(libuvなど)をブリッジする必要があります。
C拡張における非同期ブリッジの概念コード(イメージ)
/
- C言語拡張側でFiberのサスペンドをフックするイメージ
- (※実際のZend APIのシグネチャとは一部異なります)
/
void php_my_custom_io_suspend(zend_execute_data execute_data) {
// 1. 現在実行中のFiberコンテキストを取得
zend_fiber current_fiber = zend_get_current_fiber();
if (current_fiber) {
// 2. イベントループ(例: libuv)にファイルdescriptorの監視を登録
// I/Oが完了したら、後でこのFiberをレジュームするコールバックを仕込む
my_event_loop_watch_fd(fd, PHP_IO_POLL_READ, resume_callback, current_fiber);
// 3. Zend VMに対して、現在のFiberをサスペンド(中断)するシグナルを送る
zend_fiber_suspend(current_fiber);
}
}
このように、CレベルでI/O待ちを検知した瞬間にZend VMのコンテキストスイッチを走らせることで、「PHPのコード側からは同期処理のように書けていながら、裏側では完全に非同期でイベント駆動に動く」という、理想的なコルーチン環境が構築できるのです。
—
3. 実践:PHPユーザーランドから見るFiberとイベントループの調和
では、この強力な仕組みを、私たちPHPプログラマはどのように活用すべきでしょうか。
生のFiberだけを扱っても、ただの「手動で行ったり来たりできるサブルーチン」でしかありません。真価を発揮させるには、非同期イベントループ( Revolt や Amp v3 など)と組み合わせる必要があります。
以下のコードは、イベントループとFiberを組み合わせた、モダンで効率的な非同期HTTPリクエストのシミュレーションです。
/
class AsyncScheduler {
private \SplQueue $queue;
private array $waitingForIo = [];
public function __construct() {
$this->queue = new \SplQueue();
}
/
- 新しいタスク(Fiber)をスケジューラに登録する
/
public function spawn(callable $task): void {
$fiber = new \Fiber($task);
$this->queue->enqueue($fiber);
}
/
- イベントループを開始する
/
public function run(): void {
while (!$this->queue->isEmpty() || !empty($this->waitingForIo)) {
// 1. キューに溜まっているFiberを順次実行(または再開)する
while (!$this->queue->isEmpty()) {
/ @var \Fiber $fiber /
$fiber = $this->queue->dequeue();
if (!$fiber->isTerminated()) {
try {
// 初回実行、またはsuspendからの復帰
if (!$fiber->isStarted()) {
$fiber->start();
} else {
$fiber->resume();
}
// 実行後にまだ終了していなければ、何らかのI/O待ち状態とみなす
if (!$fiber->isTerminated() && $fiber->isSuspended()) {
// 本来はここでイベントループ(libuv等)へ登録する
}
} catch (\Throwable $e) {
echo “エラー発生: ” . $e->getMessage() . “\n”;
}
}
}
// 2. 擬似的なI/O待機のシミュレーション(実際のプロダクションではここでselect/epollを待つ)
if (!empty($this->waitingForIo)) {
usleep(10000); // 10ms待機
}
}
}
}
// — 実際の使用例 —
$scheduler = new AsyncScheduler();
// タスク1
$scheduler->spawn(function () {
echo “タスク1: 開始\n”;
// 擬似的な非同期I/O待ち(ここでFiberがサスペンドするイメージ)
// 実際にはここに Revolt\EventLoop などを挟みます
\Fiber::suspend(‘io_wait_1’);
echo “タスク1: I/O完了して再開!\n”;
});
// タスク2
$scheduler->spawn(function () {
echo “タスク2: 開始\n”;
\Fiber::suspend(‘io_wait_2’);
echo “タスク2: 再開!\n”;
});
// スケジューラ実行
echo “=== イベントループ駆動開始 ===\n”;
$scheduler->run();
echo “=== 全タスク完了 ===\n”;
このコードが内部でやっていることの脳内トレース
1. `$fiber->start()` が呼ばれると、Zend VMはそのFiber専用のスタック上でコードの実行を開始します。
2. 途中で `\Fiber::suspend()` に到達すると、処理は即座に呼び出し元(この場合はスケジューラの `run()` メソッド)へと制御を返します。
3. スケジューラは別のタスクを実行し、CPUを遊ばせません。
4. I/Oが完了したタイミングで `$fiber->resume()` を叩けば、Zend VMは先ほど中断した正確な命令(PC:プログラムカウンタ)と変数スコープの状態を完全に復元し、何事もなかったかのように処理を続行します。
これが、PHPの低レイヤで起きてことの全貌です。
—
4. アーキテクトとして知っておくべき「注意点」と限界
最後に、シニアエンジニアとして知っておくべき、PHP Fiberの現実的な制約についても触れておきましょう。
- C言語スタックの保護(Stack Overflowの危険性):
PHPのFiberは「ユーザースペースのコールスタック」をヒープ上に確保しますが、C拡張の関数(内部関数や外部ライブラリ)の呼び出しは、依然としてOSスレッド側のCスタックを使います。そのため、深すぎる再帰やC拡張内での重い処理は、依然としてSegmentation Faultを引き起こすリスクがあります。
- サードパーティ製拡張の対応:
すべてのPHP拡張がFiberセーフ(Fiber-aware)なわけではありません。例えば、一部のデータベースドライバや古い拡張は、グローバル変数やリソースIDをスレッドセーフ(あるいはプロセスセーフ)に保持しているため、同一スレッド内でFiberを行き来させると予期せぬバグを踏むことがあります。モダンな拡張(PDOやSwoole/OpenSwoole、Revoltエコシステムのドライバなど)を選ぶことが極めて重要です。
—
まとめ
Fiberは、単なる「便利な言語機能」ではありません。それは、PHPという歴史ある言語が、ブロッキングI/Oの呪縛から解放され、真の非同期・並行処理ランタイムへと進化するための低レイヤの基盤です。
Zend VMのスタック構造とC言語レベルのコンテキストスイッチの仕組みを頭に入れておけば、フレームワークが裏側で何をしているのかが手に取るようにわかるはずです。
「ここをこうすれば、もっとメモリ効率よくイベントループと統合できるな」
そんなアイデアがあなたの頭に浮かんだなら、もうPHPの裏側は綺麗にあなたの掌の中に見えていますよ。さあ、次のプロダクトでこの知見を爆発させてみてください。