序:PHPにおける「非同期」のパラダイムシフトとFiberの正体
テックリードの私が生ぬるいコードレビューで最も絶望するのは、「PHPだからIO待ちで処理が直列化するのは仕方ない」という敗北主義だ。数年前までのPHPであれば、それは現実だった。1つのリクエストサイクル(Zend Engineのライフサイクル)内において、外部APIへのHTTPリクエストが1秒かかれば、その間PHPのプロセスは完全にブロックされ、CPUはただ空回りを強いられてきた。
だが、PHP 8.1でFiber(ファイバー)が導入されて以降、その言い訳は通用しなくなった。
ReactPHPやAmpといったユーザースペースのイベントループとFiberを組み合わせることで、私たちは「プリエンプティブではないが、協調的な(Cooperative)軽量マルチスレッド」を手に入れた。コードの見た目は同期処理の直感性を保ちながら、内部のZend VM上ではコンテキストスイッチが行われ、ネットワークIOの待ち時間を完全に隠蔽する――これこそが、現代の高負荷Webシステムに求められるアーキテクチャだ。
本稿では、数多の外部API呼び出しが乱れ飛ぶエンタープライズ環境において、Fiberを用いた並行処理、厳密なタイムアウト制御、そしてシステムを死守する指数バックオフ(Exponential Backoff)リトライ戦略を、Zend VMのメモリ効率とイベントループの挙動を踏まえて徹底的に解説する。
—
内部構造の理解:FiberがZend VMとメモリに与える影響
Fiberを雑に扱うエンジニアは、コールスタック(Call Stack)の概念を理解していない。
通常の関数呼び出しは、Zend VMの実行スタック(`zend_execute_data`)に積まれ、リターンと共に破棄される。しかし、Fiberはその実行コンテキスト(スタックフレーム、ローカル変数、実行ポインタ)をヒープメモリ上に切り出す。
[通常のリクエスト]
Zend VM Stack —> [Function A] -> [Function B] (Block) -> 待ち…
[Fiberを用いた並行処理]
Heap Memory
├── Fiber 1 Context [API Call A] (Suspended) ──┐
├── Fiber 2 Context [API Call B] (Suspended) ──┼–> Event Loop (epoll)
└── Fiber 3 Context [API Call C] (Suspended) ──┘
つまり、Fiberを生成しすぎると、PHPプロセスのヒープメモリ(`memory_get_usage()`で計測可能な範囲)を圧迫する。また、各Fiberが保持する変数の参照がガベージコレクション(GC)のサイクルにどう影響するかを意識しなければ、メモリリークの温床となる。
高負荷API連携における3大要件
1. ノンブロッキング並行実行: 直列化されたHTTPリクエストをFiberで束ね、I/O待機時間をオーバーラップさせる。
2. 精密なタイムアウト制御: 外部APIの障害に引きずられて自システムのスレッドプールやPHP-FPMのプロセスを枯渇させない。
3. 指数バックオフとジッター(Jitter): 一時的なネットワーク断に対し、サーバーに負荷を再爆発(Thundering Herd)させずにリトライする。
—
実装:エンタープライズグレードのFiber非同期HTTPクライアント
ここから示すのは、外部ライブラリ(Guzzle等)の同期的振る舞いを捨て、低レイヤの非同期イベントループとFiberを結合させた完全なプロダクション品質のコードだ。ストリームソケットと`stream_select`(あるいはイベント拡張)をベースに、タイムアウトとリトライを内包したクラスを構築する。
declare(strict_types=1);
namespace App\Concurrency;
use Fiber;
use Throwable;
/
- 簡易的な非同期イベントループとFiberを管理するタスクランナー
/
class AsyncDispatcher
{
private array $tasks = [];
private array $results = [];
private array $errors = [];
/
- Fiberを使ったタスクの登録
/
public function addTask(string $id, callable $callback): void
{
$this->tasks[$id] = new Fiber(function () use ($id, $callback) {
try {
// コールバック内のサスペンドポイントを実行
$this->results[$id] = $callback();
} catch (Throwable $e) {
$this->errors[$id] = $e;
}
});
}
/
- すべてのタスクを並行実行し、結果を回収する
/
public function run(): array
{
// Fiberの初期起動
foreach ($this->tasks as $id => $fiber) {
$fiber->start();
}
// イベントループの模擬(実際のプロダクションでは Revolt や ReactPHP を推奨)
while (!empty($this->tasks)) {
foreach ($this->tasks as $id => $fiber) {
if ($fiber->isTerminated()) {
unset($this->tasks[$id]);
continue;
}
if ($fiber->isSuspended()) {
// ここでI/Oの完了を待つ、あるいはタイムアウト判定を行う
// デモ用としてレジュームを試行
try {
$fiber->resume();
} catch (Throwable $e) {
$this->errors[$id] = $e;
unset($this->tasks[$id]);
}
}
}
// CPUの爆発的消費を防ぐためのマイクロリープ
usleep(1000);
}
return [
‘results’ => $this->results,
‘errors’ => $this->errors,
];
}
}
/
- 指数バックオフとタイムアウトを実装した堅牢なAPIクライアント
/
class ResilientApiClient
{
private int $maxRetries;
private int $timeoutMs;
public function __construct(int $maxRetries = 3, int $timeoutMs = 2000)
{
$this->maxRetries = $maxRetries;
$this->timeoutMs = $timeoutMs;
}
/
- Fiberコンテキスト内で非同期に実行されるHTTPリクエスト(擬似実装)
/
public function call(string $url): array
{
$attempt = 0;
while (true) {
$attempt++;
$startTime = microtime(true);
try {
// 【内部挙動】実務ではここでstream_socket_clientとNon-Blocking設定、
// stream_selectによるイベント待機(Fiber::suspend())を行う。
$result = $this->executeNonBlockingHttp($url);
return $result;
} catch (Throwable $e) {
if ($attempt >= $this->maxRetries) {
// 最大リトライ回数到達時は例外を伝播
throw new \RuntimeException(“API Call failed after {$attempt} attempts: ” . $e->getMessage(), 0, $e);
}
// 指数バックオフの計算: 2^attempt 100ms + ランダムジッター
$backoffMs = (int) (pow(2, $attempt) 100 + mt_rand(0, 50));
// ログ出力(本来はPSR-3 Loggerを使用)
echo “[Fiber ID: ” . Fiber::getCurrent()->getId() . “] Retry {$attempt} for {$url} after {$backoffMs}ms\n”;
// 【極意】ここでスリープする際、sleep()を使うとプロセス全体がブロックされる。
// Fiber環境下では、イベントループに処理を返すサスペンドを行うべきである。
$suspendUntil = microtime(true) + ($backoffMs / 1000);
while (microtime(true) < $suspendUntil) {
Fiber::suspend(); // コントロールをイベントループに返却
}
}
}
}
private function executeNonBlockingHttp(string $url): array
{
// ネットワークI/Oのシミュレーション(ランダムでタイムアウトや失敗を発生させる)
// 実際のコードではここでソケット通信とストリームのノンブロッキング化を行う
$elapsed = (microtime(true) 1000);
// 擬似的なサスペンド(I/O待ちを表現)
Fiber::suspend();
if (mt_rand(1, 10) > 7) {
throw new \RuntimeException(“Connection Timeout or 5xx Error”);
}
return [‘status’ => 200, ‘body’ => “Data from {$url}”];
}
}
// ==========================================
// 実行スクリプト(コントローラー層のイメージ)
// ==========================================
$dispatcher = new AsyncDispatcher();
$client = new ResilientApiClient(maxRetries: 3);
$endpoints = [
‘user_api’ => ‘https://api.example.com/v1/users’,
‘order_api’ => ‘https://api.example.com/v1/orders’,
‘item_api’ => ‘https://api.example.com/v1/items’,
];
// 各API呼び出しをFiberタスクとして登録
foreach ($endpoints as $key => $url) {
$dispatcher->addTask($key, function () use ($client, $url) {
return $client->call($url);
});
}
// 並行実行の開始
$startTime = microtime(true);
$response = $dispatcher->run();
$duration = microtime(true) – $startTime;
echo “— Execution Completed in ” . round($duration, 4) . ” seconds —\n”;
print_r($response);
—
アーキテクトが指摘する「絶対にやってはいけないアンチパターン」
コードレビューの現場で、Fiberを導入したエンジニアがやりがちな致命的ミスを挙げておく。
1. 同期ブロッキング関数の混入
Fiber内で `file_get_contents()` や同期型の `PDO` クエリ、重い `sleep()` を実行した瞬間、そのFiberだけでなく、PHPのメイン実行スレッド全体のイベントループが凍結する。Fiberは「協調的」であるため、コードを書く人間がノンブロッキングなI/Oプリミティブを選定する責任がある。
2. 例外処理の漏れ(Uncaught Fiber Exception)
Fiber内部でスローされた例外がキャッチされないままFiberが終了すると、Zend Engineは致命的なエラーとしてプロセスを強制終了させることがある。タスクランナー層で必ず `try-catch` でラップし、エラーを隔離せよ。
3. 過剰なファイバー生成によるメモリリーク
数万件のループで毎回Fiberを生成・破棄すると、Zend VMのメモリ管理機構に無用なプレッシャーがかかる。プール機構(Pool)を設けるか、バッチサイズを制御(例: 同時実行数最大10件に制限)するのがインフラエンジニアとしての良識だ。
—
結び:PHPを「最高速のWebアプリケーションサーバー」へ昇華させるために
「PHPは遅い」「I/Oに弱い」――それは、古い知識にとどまったプログラマの言い訳にすぎない。
Fiberをマスターし、Zend VMのメモリ構造とイベントループの調和を理解したあなたには、もはや直列処理の呪縛はない。外部APIの遅延に怯えることなく、高スループットで堅牢な非同期APIクライアントを構築し、システム全体のパフォーマンスを極限まで引き上げてほしい。
コードレビューの場で「なぜこの設計なのか」と問われたとき、メモリ空間とスタックの挙動まで含めてロジカルに説明できるエンジニアであれ。それが、真のPHPプロフェッショナルである。