こんにちは。PHPの裏側を覗く旅へようこそ。
Node.jsやGo、Rustといった言語で「非同期処理」や「Promise/Futureによる依存関係管理」の心地よさを知ったあとで、PHPに戻ってきたとき、こう感じたことはありませんか?
「なぜPHPは、外部APIへのリクエストやDBクエリを待つ間、ずっとブロッキングしてしまうのだろう?」と。
かつてのPHPであれば、その疑問はそのまま正解でした。1つのリクエストを1つのプロセス(またはスレッド)が占有し、I/O待ちの間はCPUがアイドル状態になる。それがPHPの伝統的な美徳であり、同時にスケーラビリティの壁でもありました。
しかし、PHP 8.1でFiber(ファイバー)が導入されてから、その世界観は根本から覆りました。
今回は、単なる文法の解説ではなく、Fiberの本質と、それをモダンなPromise/Futureパターンと組み合わせることで「PHPの実行エンジン内部(Zend VM)で何が起きているのか」を、低レイヤの視点を交えながら一緒に紐解いていきましょう。ここを理解すると、PHPの裏側が驚くほど美しく見えてきますよ。
—
1. そもそもFiberとは何か?Zend VMのスタック退避の仕組み
多くの開発者が誤解していますが、Fiberは「マルチスレッド」ではありません。PHPは依然として基本的にはシングルスレッドで動いています。
Fiberの本質は、「スタックを切り替え可能な協調的(Cooperative)な実行コンテナ」です。
通常、PHPの関数がコールされると、Zend VMはその実行コンテキスト(コールスタックやローカル変数等を保持する `zend_execute_data`)をCのコールスタック上に積み上げていきます。関数がネストするほど、スタックは深くなりますよね。
Fiberを使うと、この実行コンテキストの塊をヒープ上に退避(Suspend)させ、別のFiberのコンテキストに切り替えて実行(Resume)させることができます。
[メインプロセス / イベントループ]
│
├──> Fiber A (API-1 を呼び出し中…) ──[Suspend]──┐
│ │
└──> Fiber B (API-2 を呼び出し中…) ──[Suspend]──┼─> [イベントループでI/O待ち]
│
(I/O完了で Resume) <┘
この仕組みを理解すると、「コールバック地獄(Callback Hell)」に陥ることなく、手続き型のきれいなコードのまま非同期処理が書けるというFiberの最大の強みが見えてきます。
—
2. Promise/Futureパターンとの邂逅
とはいえ、生(生の状態)のFiberを直接あちこちで `suspend()` させたり `resume()` させたりしていると、コードベースは一瞬でスパゲッティになります。どのFiberがどのI/Oを待っているのか、依存関係はどうなっているのかが管理できなくなるからです。
そこで登場するのが、Promise/Futureパターンです。
- Future: 「将来完了する予定の値」を表すプレースホルダー。
- Promise: そのFutureに対して「値を解決(fulfill)する」または「エラーを伝える(rejectする)」ためのトリガー。
これらをFiberと組み合わせることで、「複数の非同期処理を並行して走りさせ、すべての完了を待つ(`all`)」や「いずれか一方が終わったら次に進む(`race`)」といった高度な依存関係管理が、PHPのシンタックスで美しく記述できるようになります。
実装のイメージ:ミニマルなFuture/PromiseとFiberの統合
言葉だけではイメージしにくいと思いますので、Zend VMの挙動を脳内トレースしながら読める、実用的な実装例を見てみましょう。ここでは外部ライブラリに頼らず、純粋なPHPのコア機能だけで非同期の骨組みを作ります。
/
class Future
{
private mixed $result = null;
private ?\Throwable $exception = null;
private bool $isResolved = false;
/ @var array
private array $listeners = [];
public function __construct(private readonly callable $executor)
{
}
/
- 非同期処理を実行し、Fiberを起動する
/
public function start(EventLoop $loop): void
{
// Fiberを作成。これが「中断・再開できる処理単位」になります。
$fiber = new \Fiber(function () {
try {
// コールバックに解決(Resolve)と拒否(Reject)のハンドラを渡す
($this->executor)(
fn($value) => $this->resolve($value),
fn(\Throwable $e) => $this->reject($e)
);
} \Throwable $e {
$this->reject($e);
}
});
// Fiberを開始する(最初のサスペンドポイントまで実行される)
try {
$fiber->start();
} catch (\Throwable $e) {
$this->reject($e);
}
}
private function resolve(mixed $value): void
{
$this->result = $value;
$this->isResolved = true;
foreach ($this->listeners as $listener) {
$listener(null, $value);
}
}
private function reject(\Throwable $e): void
{
$this->exception = $e;
$this->isResolved = true;
foreach ($this->listeners as $listener) {
$listener($e, null);
}
}
/
- 結果を待機する(ここで現在のFiberがサスペンドする)
/
public function await(): mixed
{
if ($this->isResolved) {
if ($this->exception) {
throw $this->exception;
}
return $this->result;
}
// 現在実行中のFiberを取得し、解決されるまで中断させる
$currentFiber = \Fiber::getCurrent();
if ($currentFiber !== null) {
$this->listeners[] = function (?\Throwable $e, mixed $value) use ($currentFiber) {
// イベントループ経由でFiberを再開(Resume)させる
// ここが非同期合成のキモです
$currentFiber->resume();
};
\Fiber::suspend(); // 制御をイベントループへ返します
}
if ($this->exception) {
throw $this->exception;
}
return $this->result;
}
}
このコードの最も美しい部分は、`$future->await()` の中に隠されています。
開発者からは「あたかも同期的に処理結果を待っているかのように」見えますが、内部では `\Fiber::suspend()` が呼び出され、Zend VMのスタックが一旦退避し、CPUの実行権がイベントループ側へ返されているのです。
—
3. 依存関係管理の実践:複数タスクの並行解決(All)
実際のWebアプリケーションでは、「ユーザー情報」「タイムライン」「通知数」など、複数のAPIやDBクエリを同時に叩いて、そのすべての結果が出揃ってからレスポンスを組み立てたいシーンが多々あります。
先ほどの仕組みをベースに、複数のFutureをまとめて依存関係を管理する `Future::all()` を実装してみましょう。
/
public static function all(array $futures): Future
{
return new Future(function (callable $resolve, callable $reject) use ($futures) {
$results = [];
$completedCount = 0;
$total = count($futures);
if ($total === 0) {
$resolve([]);
return;
}
foreach ($futures as $index => $future) {
// 各Futureを別のFiber/非同期コンテキストとして走らせるイメージ
// ここではシンプルにawaitを別Fiberで包むか、イベントループに委譲します
go(function () use ($future, $index, &$results, &$completedCount, $total, $resolve, $reject) {
try {
$results[$index] = $future->await();
$completedCount++;
if ($completedCount === $total) {
$resolve($results);
}
} catch (\Throwable $e) {
$reject($e);
}
});
}
});
}
}
このように依存関係を定義すると、開発者は次のような極めて直感的なコードを書くことができます。
// 疑似的な非同期コントローラーのイメージ
function handleUserDashboard(int $userId): array
{
// 3つの独立した重い処理(外部APIコールやDBクエリなど)を定義
$f1 = fetchUserDataAsync($userId);
$f2 = fetchTimelineAsync($userId);
$f3 = fetchNotificationsAsync($userId);
// すべての依存関係が解決するまで、このFiberのみが一時停止(非効率なCPU消費ゼロ)
[$user, $timeline, $notifications] = AsyncUtil::all([$f1, $f2, $f3])->await();
return [
‘user’ => $user,
‘timeline’ => $timeline,
‘notifications’ => $notifications,
];
}
従来のPHPであれば、これら3つの処理を順番に実行(直列処理)するか、あるいは `curl_multi_` などの難解な低レイヤ関数を直接叩いて記述を歪ませる必要がありました。しかし、FiberとPromiseの組み合わせであれば、手続き型の美しさを保ったまま、内部で完全にノンブロッキングな並行処理が成立するのです。
—
4. アーキテクトが知るべき「メモリ空間」と「FPMプロセス」の注意点
最後に、シニア・アーキテクトとして実務で必ず直面する「PHPの非同期処理の現実」についてお伝えしておきます。
Node.jsやGoと異なり、PHPは基本的に「シェアード・ナッシング(Shared Nothing)」のアーキテクチャを長年前提に作られています。1リクエストが終わればメモリはすべて解放され、グローバル状態はリセットされます。
Fiberを使ったイベントループ駆動のコードを書く際、以下の2点に注意してください。
1. メモリリークの温床になりやすいクロージャの参照
FiberやPromiseのコールバック内で意図せず大きなお化け変数(巨大な配列やORMのエンティティなど)をクロージャの `use` でキャプチャし続けると、Fiberが破棄されるまでメモリが解放されません。長期稼働するデーモンプロセス(RoadRunnerやSwoole、ReactPHPなど)上で動かす場合は、循環参照やメモリリークに細心の注意を払う必要があります。
2. ブロッキング関数の混入
非同期イベントループの上で、もし古いレガシーな同期関数(例えば `sleep()` や重い同期的なファイルI/O、一部のドライバでのブロッキング通信など)を呼んでしまうと、その瞬間イベントループ全体のスレッドがブロックされ、他のすべてのFiberの実行が止まります。 非同期の世界に足を踏み入れた瞬間から、コードベース全体を「ノンブロッキング対応」で統一する規律が求められます。
—
まとめ
PHPにおけるFiberとPromise/Futureパターンの統合は、単なる「おしゃれな書き方」の導入ではありません。
- Fiber によって、Zend VMのスタック退避・再開をコントロールし、
- Promise/Future によって、複雑なタスク間の依存関係や順序制御を美しく抽象化する。
ここを理解できたあなたなら、もう「PHPは同期処理しかできない言語だ」という古い固定観念にとらわれる必要はありません。モダンなPHPの限界を押し広げる強力な武器として、ぜひ実際のアーキテクチャ設計に取り入れてみてください。
裏側の仕組みが綺麗に見えたとき、PHPでのプログラミングはもっともっと楽しくなりますよ。