こんにちは。PHPの裏側でZendエンジンがどう息づいているか、その鼓動を感じたことはありますか?
Node.jsやGo、あるいはRustのような非同期・並行処理が当たり前の世界からやってきた優秀なエンジニアほど、PHPの「1リクエスト=1プロセス(またはスレッド)で上から下に同期的に実行される」という古典的なモデルに息苦しさを感じてしまうものですよね。「なぜPHPは、外部のマイクロサービスを複数叩くときに、こんなにも無駄なI/O待ち(Blocked I/O)で時間をドブに捨てなければならないんだろう?」と。
でも、安心してください。PHP 8.1で導入された Fiber(ファイバー) を正しく手なずければ、言語のランタイムを書き換えることなく、PHPランドの中に美しく軽量な非同期タスクランナーを構築できます。
今回は、複数マイクロサービスへの並行リクエストと結果集約をテーマに、FiberがZend VMのスタック上でどう振る舞うのか、その低レイヤの現実を踏まえながら、泥臭くもエレガントな実践知をお伝えしていきますね。ここを理解すると、PHPの見え方がガラリと変わりますよ。
—
1. なぜ「非同期=コール天国」だったのか? そしてFiberの本質
これまでのPHPで並行処理や非同期I/Oをやろうとすると、`ext-async` のようなサードパーティ拡張を入れるか、`ReactPHP` や `Amp` のようなイベントループ駆動のライブラリに頼る必要がありました。これらは素晴らしい成果を上げてきましたが、最大の壁が「コールバック地獄」または「Generator(yield)による複雑な制御フローの隠蔽」でした。
ビジネスロジックを書くだけなのに、コードが非同期の細切れにされてしまう。精神衛生上、あまりよろしくないですよね。
Zend VMのスタックフレームとFiberの正体
ここで少し、PHPの心臓部であるZendエンジンを覗いてみましょう。
通常、PHPの関数呼び出しはC言語レベルのコールスタック(関数が積まれるメモリ領域)を消費します。ある関数が別の関数を呼ぶと、Zend VMは `zend_execute_data` という実行コンテキストを次々に積み上げていきます。
Fiberの本質は何か? 一言で言えば、「ユーザーランド(PHPコード側)で自由に制御できる、独立したコールスタックの切り替え機構」です。
- 通常の関数:呼ばれたら最後まで走りきるか、例外で抜けるしかない。
- Fiber:途中で自分の状態(スタックフレーム)を保持したまま「サスペンド(中断)」し、外側のイベントループに処理を戻せる。そして、準備ができたら「レジューム(再開)」して、中断したその行から実行を続けられる。
つまり、「見かけ上は上から下に流れる同期コード(Imperative Style)」を書きながら、中身は完全にノンブロッキングな非同期処理を実現できるわけです。
—
2. マイクロサービス連携における「I/O待ち」の絶望を断つ
例えば、次のようなシナリオを考えてみましょう。
あるAPIリクエストを処理するために、PHPのバックエンドから3つの異なるマイクロサービス(例:ユーザー基盤、レコメンドエンジン、在庫管理)へHTTPリクエストを飛ばし、すべての結果を集約してレスポンスを組み立てる必要があります。
これを愚直に同期(Sequential)でやると、こうなります。
// 最悪なパターン:合計レイテンシが単純加算される
$user = $httpClient->get(‘/users/123’); // 50ms
$recommendations = $httpClient->get(‘/rec/123’); // 120ms
$inventory = $httpClient->get(‘/inv/SKU-99’); // 80ms
// 合計 250ms のロス!
もし、これらを同時に(Concurrently)投げ、最も遅いプロセスの完了を待つ(`Promise.all` のような挙動)ことができたら、レイテンシは最長の約120msに縮まりますよね。
これをFiberと簡易的なイベントループを使って実装してみましょう。
—
3. 実装:Fiber駆動の非同期タスクランナーと結果集約
今回は外部への非同期ネットワークI/Oを模擬するために、非同期ソケットや `stream_select` をベースにしたミニマムなイベントループをFiberと組み合わせます。
/
class AsyncServiceAggregator
{
/ @var array
private array $fibers = [];
/ @var array
private array $results = [];
/
- 非同期タスクを登録する
- @param callable $task 実行する処理(内部でブロッキングを模擬、または非同期化)
- @param int $taskId タスク識別ID
/
public function addTask(callable $task, int $taskId): void
{
$fiber = new Fiber(function () use ($task, $taskId) {
// タスクを実行し、結果をバッファに収める
try {
$this->results[$taskId] = $task();
} \Throwable $e {
$this->results[$taskId] = $e; // エラーも結果として安全に拾う
}
});
mw_log(“タスク #{$taskId} をFiberにカプセル化しました。”);
$this->fibers[$taskId] = $fiber;
}
/
- すべてのタスクを並行実行し、結果が集約されるまでイベントループを回す
/
public function runAll(): array
{
// すべてのファイバーを一度起動(初回のサスペンドポイントまで走らせる)
foreach ($this->fibers as $id => $fiber) {
if (!$fiber->isStarted()) {
$fiber->start();
}
}
// すべてのファイバーが終了するまでループを回す(簡易的な協調型スケジューラ)
while (count($this->fibers) > 0) {
foreach ($this->fibers as $id => $fiber) {
// ファイバーが終了(terminated)またはサスペンド状態にあるかチェック
if ($fiber->isTerminated()) {
unset($this->fibers[$id]);
continue;
}
if ($fiber->isSuspended()) {
// ここで本来はイベントループ(stream_select等)でI/Oの完了を待ちますが、
// 今回は簡易的にレジュームをかけて処理を進めます
try {
$fiber->resume();
} catch (\Throwable $e) {
$this->results[$id] = $e;
unset($this->fibers[$id]);
}
}
}
// CPUの暴走を防ぐためのマイクロ秒単位のyield(実際にはイベントループがここを調停します)
usleep(1000);
}
return $this->results;
}
}
// 簡易ログ出力用ヘルパー
function mw_log(string $message): void
{
echo “[” . date(‘H:i:s.u’) . “] ” . $message . PHP_EOL;
}
// ==========================================
// 実行シミュレーション
// ==========================================
$aggregator = new AsyncServiceAggregator();
// 1. ユーザーサービス取得タスク(模擬:約50msかかる想定)
$aggregator->addTask(function () {
mw_log(“User Service: リクエスト送信開始…”);
// 本来は非同期HTTPクライアントでここで Fiber::suspend() を挟みます
Fiber::suspend(); // 一旦処理を中断してスケジューラへ戻す
usleep(50000); // 50msのI/O待ちを模倣
mw_log(“User Service: レスポンス受信完了”);
return [‘id’ => 123, ‘name’ => ‘Alice’];
}, 1);
// 2. レコメンドエンジン取得タスク(模擬:約120msかかる想定)
$aggregator->addTask(function () {
mw_log(“Recommend Service: リクエスト送信開始…”);
Fiber::suspend();
usleep(120000); // 120msのI/O待ち
mw_log(“Recommend Service: レスポンス受信完了”);
return [‘item_a’, ‘item_b’, ‘item_c’];
}, 2);
// 3. 在庫確認サービス取得タスク(模擬:約80msかかる想定)
$aggregator->addTask(function () {
mw_log(“Inventory Service: リクエスト送信開始…”);
Fiber::suspend();
usleep(80000); // 80msのI/O待ち
mw_log(“Inventory Service: レスポンス受信完了”);
return [‘SKU-99’ => true];
}, 3);
mw_log(“=== 並行タスクの実行を開始します ===”);
$startTime = microtime(true);
$aggregatedResults = $aggregator->runAll();
$endTime = microtime(true);
mw_log(“=== すべてのタスクが完了しました (所要時間: ” . round(($endTime – $startTime) 1000, 2) . “ms) ===”);
// 結果の確認
print_r($aggregatedResults);
—
4. このコードが教えてくれる「アーキテクチャの美学」
上記のコードを実行すると、次のようなことが起きます。
1. 各タスクがそれぞれのFiberの中で独立して動き始め、一度 `Fiber::suspend()` でメインのスケジューラ(`runAll` メソッド)へ制御を返します。
2. スケジューラはすべてのファイバーの生存確認を行い、準備ができたら `resume()` で続きを実行させます。
3. 結果として、3つの重いI/O待ちが直列ではなく並行して処理され、全体の実行時間は「最も遅いタスク(約120ms+オーバーヘッド)」に収束します。
ここで重要なのは、ビジネスロジックを書いている開発者側から見れば、上から下に流れる普通のコードに見えるという点です。プロミスチェーンを書く必要もなければ、複雑なクロージャのネストに悩まされることもありません。
実運用上の注意点(アーキテクトとしての警告)
ただし、プロダクション環境でこれらを本格的に運用する場合は、いくつか踏むべき地雷があります。
- C言語拡張との組み合わせ(PDOやcURLなど):
標準の `PDO` や同期型の `curl_exec()` は、内部で完全にブロッキング(OSのスレッドを占有)するため、Fiberで包んでも意味がありません。真の非同期化を行うには、`ext-curl` のマルチハンドルフックや、非同期ソケットベースのHTTPクライアント(Amp v3やReactPHPのコンポーネントなど)とFiberを統合する必要があります。
- メモリリークの管理:
Fiberは独自のコールスタックをPHPのヒープ上に確保します。タスクが異常終了した際に参照が残ったままだと、Zendのガベージコレクタが回収するまでメモリを圧迫します。例外時は必ずファイバーの状態をクリーンアップする設計にしてください。
—
最後に:PHPは「古い言語」ではない
「PHPは同期処理しかできないから、モダンなマイクロシステムの連携には向かない」なんて、もう古い神話です。
Zendエンジンの構造を正しく理解し、Fiberという強力なプリミティブを使いこなせば、PHPは驚くほど軽快で、かつスケーラブルな非同期アプリケーションのプラットフォームへと生まれ変わります。
「言語の仕様を嘆くより、ランタイムの仕組みを愛せ」。
今回の知見が、あなたの設計するWebシステムのパフォーマンスを一段上のステージへ引き上げるキッカケになれば、先輩アーキテクトとしてこれ以上の喜びはありません。さあ、次のコードでどんな非同期フローを組み立てましょうか?