PHP 8 Fiberの深淵:スタック管理とコンテキストスイッチのオーバーヘッドを支配する
コードレビューをしていて、未だに「Fiberを使えばノンブロッキングなIOが簡単に書ける、Node.jsやGoのゴルーチンと同じだ」という誤解を持った実装に出くわすことがある。
ハッキリ言おう。PHPのFiberはゴルーチンではない。
OSスレッドやユーザーランドスレッドのようなプリエンプティブ(強制剥奪型)な並行処理モデルではなく、あくまでユーザー空間における協調的マルチタスク(Cooperative Multitasking)のプリミティブに過ぎない。
今回は、PHP 8で導入されたFiberが内部のZendエンジンにおいてどのようにスタック領域を確保し、コンテキストスイッチの際にどれほどのメモリ・CPUオーバーヘッドを支払っているのかを、低レイヤの視点から完全に剥き出しにして解説する。
プロダクション環境でFiberを誤用し、予期せぬメモリリークやCPUの張り付きに悩まされたくないエンジニアだけ、この先を読み進めてほしい。
—
1. Zend VMのコールスタックとFiberのメモリ空間
従来のPHP(PHP 7/8の通常のスクリプト実行)では、コールスタックはCのコールスタック(ヒープまたはOSが管理するスタック領域)にべったりと依存し、関数呼び出しのたびに`zend_execute_data`構造体が積み上げられていく。
しかし、Fiberが生成されると、Zendエンジンは通常のフローから切り離された専用のFiber用コールスタック(Heap上に確保されたチャンク)を割り当てる。
[通常のPHPリクエストライフサイクル]
Request Start -> Zend VM Stack (Linear / Monolithic) -> Response
[Fiber導入後のメモリ空間]
Main Fiber Stack ─── (Yield) ───> Dedicated Fiber Stack (Heap Allocation)
▲ │
└────────────── (Resume) ─────────────┘
スタックの動的割り当てとコスト
Fiberをインスタンス化(`new Fiber(…)`)した瞬間、PHPはCレベルでヒープメモリを確保し、その中にFiber専用の実行コンテキストを構築する。
ここで重要なのは、「Fiberの作成=軽量スレッドの生成」ではないという点だ。Node.jsのPromiseやGoのGoroutineが数キロバイトの初期スタックで軽やかに動くのに対し、ZendエンジンのFiberは構造体自体のオーバヘッドに加え、コールスタックの管理テーブル、変数テーブル(Symbol Table)の退避領域を抱える。
数千、数万のFiberを無計画に生成・破棄(`new Fiber` 乱発)するアーキテクチャは、Zendメモリマネージャー(ZMM)に対して細かなヒープアロケーションの嵐を引き起こし、結果としてメモリアロケータの断片化(Fragmentation)を招く。これが「Fiberを使ったのにメモリ消費量が想定以上に膨れ上がる」最大の原因だ。
—
2. コンテキストスイッチの裏側:何が起きているのか?
`Fiber::suspend()` が呼ばれ、親コンテキストへ制御が戻る瞬間、そして `Fiber::resume()` で再び処理が飛び込む瞬間、Zendエンジン内部では何が行われているのか。
1. CPUレジスタおよびVMステートの退避:
現在の `execute_data` ポインタ、opcodeのオフロケーション、グローバルな実行コンテキストのポインタ群が、現在実行中のFiberオブジェクトの内部構造体にセーブされる。
2. スタックポインタの切り替え:
CPUの実行コンテキスト(レジスタ値)が、切り替え先のFiberが保持していたものにスワップされる。
3. VMステートの復元:
スイッチ先の `execute_data` がロードされ、中断されていた位置(opcodeの続き)から実行が再開される。
この一連の処理は、OSのコンテキストスイッチよりは圧倒的に高速(ユーザーランドで行われるため)だが、純粋な関数呼び出しと比較すれば、数倍から数十倍のCPUオーバーヘッドが確実に乗る。
安易な粒度でFiberを切り替える設計にすると、コンテキストスイッチのオーバーヘッドがビジネスロジックの実行時間を凌駕し、CPU使用率が100%に張り付くという本末転倒な事態に陥る。
—
3. 【実務リファレンス】安全かつ高速な非同期HTTPクライアントの設計
では、このFiberの特性を理解した上で、実務のAPI連携やバッチ処理で「安全に」メモリとCPUを制御するためのコードを見てみよう。
以下のコードは、大量の外部APIリクエストをFiberプールで協調的に並行処理しつつ、メモリリークやスタックの肥大化を防ぐ設計を取り入れたプロダクション品質のリファレンスである。
declare(strict_types=1);
namespace App\Concurrency;
use Fiber;
use Throwable;
/
- 堅牢なファイバープールマネージャー
- 大量のアタッチされたタスクを限定された同時実行数(Concurrecy Limit)で制御し、
- メモリ断片化と過剰なコンテキストスイッチを防ぐ。
/
final class FiberPool
{
/ @var array
private array $pool = [];
/ @var int 同時実行数の上限 /
private int $maxConcurrent;
private int $activeCount = 0;
public function __construct(int $maxConcurrent = 10)
{
$this->maxConcurrent = max(1, $maxConcurrent);
}
/
- タスク(コールバック)をプールに登録する
/
public function add(callable $task): void
{
$fiber = new Fiber(function() use ($task) {
try {
// タスクを実行し、結果を返す
$result = $task();
Fiber::suspend([‘status’ => ‘fulfilled’, ‘value’ => $result]);
} catch (Throwable $e) {
// 例外をキャッチし、親コンテキストへ安全に伝播させる
Fiber::suspend([‘status’ => ‘rejected’, ‘reason’ => $e]);
}
});
$this->pool[] = $fiber;
}
/
- プール内のすべてのタスクを協調的に実行する
- @return array 実行結果のリスト
/
public function run(): array
{
$results = [];
$queue = $this->pool;
// 処理すべきファイバーがなくなるか、アクティブが尽きるまでループ
while (!empty($queue) || $this->activeCount > 0) {
// 同時実行数に達するまで、キューからファイバーを取り出して起動
while ($this->activeCount < $this->maxConcurrent && !empty($queue)) {
/ @var Fiber $fiber /
$fiber = array_shift($queue);
$this->activeCount++;
// 初回スタート
$result = $fiber->start();
$this->handleFiberYield($fiber, $result, $results);
}
// イベントループのプレースホルダー(実際のIO待機時はここでSelect/Pollを行う)
// ここでは擬似的にCPUを譲歩
usleep(1000);
}
return $results;
}
/
- ファイバーからのYield(中断)をハンドリングし、状態に応じた処理を行う
/
private function handleFiberYield(Fiber $fiber, mixed $yieldedValue, array &$results): void
{
if (is_array($yieldedValue) && isset($yieldedValue[‘status’])) {
$results[] = $yieldedValue;
}
// ファイバーが終了しているかチェック
if ($fiber->isTerminated()) {
$this->activeCount–;
return;
}
// まだ終了していない場合(非同期IO待ちなど)の再開ロジックをここに挟む
// 今回のシンプルモデルでは一撃で終了させる設計のため割愛
if (!$fiber->isSuspended()) {
$this->activeCount–;
}
}
}
// ==========================================
// 使用例(実務でのAPIバッチ処理を想定)
// ==========================================
require ‘vendor/autoload.php’;
$pool = new FiberPool(maxConcurrent: 3);
$urls = [
‘https://api.example.com/item/1’,
‘https://api.example.com/item/2’,
‘https://api.example.com/item/3’,
‘https://api.example.com/item/4’,
‘https://api.example.com/item/5’,
];
foreach ($urls as $index => $url) {
$pool->add(function() use ($url, $index) {
// [注意] 実務ではここでcurl_multi等を用いたノンブロッキングIOと組み合わせる
// 擬似的なネットワーク遅延
usleep(rand(50000, 200000));
// 成功レスポンスを返す想定
return “Data from {$url} (Index: {$index})”;
});
}
$startTime = microtime(true);
$responses = $pool->run();
$endTime = microtime(true);
foreach ($responses as $response) {
echo “Status: {$response[‘status’]}, Value: ” . ($response[‘value’] ?? $response[‘reason’]->getMessage()) . PHP_EOL;
}
echo sprintf(“Execution Time: %.4f sec\n”, $endTime – $startTime);
echo sprintf(“Peak Memory Usage: %.2f MB\n”, memory_get_peak_usage(true) / 1024 / 1024);
—
4. テクニカルリードとしての設計ルール:Fiberを使うべき時、使ってはならない時
コードレビューにおいて、Fiberを導入しようとするメンバーには以下のルールを徹底させてほしい。
1. 「CPUバウンドな処理」にFiberを持ち込んではならない
画像処理、暗号化、重いアルゴリズムの計算などは、コンテキストスイッチのオーバーヘッドが乗るだけで、処理時間はむしろ悪化する。Fiberが真価を発揮するのは、「I/Oバウンドな処理(DBクエリ、外部APIコール、ファイル読み書き)をノンブロッキングに束ねる場合」だけである。
2. メモリリーク(循環参照とクロージャの束縛)への恐怖心を持つこと
Fiberの中で大きな配列やオブジェクトをクロージャに `use` でキャプチャし続けると、そのFiberが終了(`isTerminated()`)して破棄されるまでの間、Zendエンジンのガベージコレクションのスコープ外、あるいは参照カウントの保持対象としてメモリに居座り続ける。
大量のデータを扱うバッチ処理でこれをやると、あっという間にPHPの `memory_limit` に到達する。不要になった変数は明示的にアンセットするか、Fiberのライフサイクルを最小限に設計すること。
3. 「同期的なライブラリ」をそのままFiberで包んでも意味がない
PDOや標準の `file_get_contents` はブロッキング関数である。これらを単純にFiberで囲んでも、結局OSスレッドがブロックされるため、協調的マルチタスクの恩恵はゼロである。Fiberを導入するなら、底層で非同期IO(Event拡張やAmp、ReactPHPなどのエコシステム)と完全に統合されている必要がある。
—
PHPは進化し、単なる「Webページのテンプレートエンジン」から、高度な非同期処理を内包できるシステム基盤へと変貌を遂げた。しかし、道具がどれだけ洗練されても、それを握るエンジニアが内部のメモリ構造やスタックの振る舞いを理解していなければ、システムはいとも簡単に崩壊する。
「動くからいいや」ではなく、「なぜこのメモリ消費量で収まるのか」「なぜこの粒度でコンテキストスイッチさせるべきなのか」を語れるエンジニアであれ。コードレビューの基準は、いつだってそこにある。