PHP 8.x Fiber(ファイバー)のスケジューリング挙動と協調的マルチタスクにおけるメモリ空間の物理メカニズム
開発プロジェクトのテクニカルリードとして、私は日々コードレビューを行っている。そこで時折目にするのが、「Fiberを使えば非同期処理が簡単に書ける」という安易な理解のもとに実装された、メモリリークの地雷原のようなコードだ。
「OSスレッドを起こさないから軽量」「コールバック地獄(Promiseチェーン)からの解放」――世間では美辞麗句ばかりが語られるが、Zend VMのメモリ空間の物理的な挙動、そしてCall Stackの退避・復帰メカニズムを理解していなければ、高負荷時にPHPプロセス(FPM)は一瞬でメモリを食潰し、セグメンテーション違反や予期せぬステータス破壊を引き起こす。
今回は、PHP 8.xで導入されたFiberの本質を、Zend VMの低レイヤの挙動から解き明かし、実務で絶対に破綻しない協調的マルチタスク設計の極意を伝授する。
—
1. 内部構造の真実:Fiberはメモリ上で何をしているのか
従来のPHP(PHP 7以前)において、関数の実行コンテキストは単一のコールスタック上で直列に処理されていた。関数が呼び出されるたびにZend VMはスタックフレーム(`zend_execute_data`)を積み上げ、リターン時にそれを巻き戻す。ここには「中断して後から再開する」という概念は存在しない。
PHP 8で実装されたFiberは、このZend VMの実行コンテキスト(Execution Context)をC言語レベル、ひいてはPHPのヒープ上に完全にオブジェクトとしてカプセル化したものだ。
コールスタックの「切り離し」とヒープ退避
Fiberを起動(`$fiber->start()`)すると、何が起きるか。
Zend VMは、現在実行中のスタックフレームとは別に、Fiber専用の仮想スタック空間(Heap上に確保されたバッファ)を割り当てる。
1. サスペンド(`Fiber::suspend()`)時:
現在Fiber内で動いているZend VMのスタックフレームポインタ群、およびローカル変数を保持するシンボルテーブルの参照状態が、OSスレッドのスタックからヒープメモリ上のFiberオブジェクト内へ退避(Serializeではなく構造体のポインタ退避)される。
2. コントロールの返還:
VMの実行制御は、Fiberを呼び出した側(メインのイベントループや親スコープ)へ一瞬で戻る。OSのスレッドコンテキストスイッチは一切発生しないため、カーネルモードへの遷移コスト(CPUクロックの無駄遣い)がゼロである。
3. レジューム(`$fiber->resume()`)時:
退避されていたヒープ上のスタックフレーム情報が再びZend VMの現行実行コンテキストにマッピングされ、中断されたバイトコードのオペコード(Opcode)の実行位置から処理が再開される。
この挙動を理解していれば、「Fiberの中で重い外部リソースを抱えたまま長時間サスペンドし続けることが、なぜメモリプレッシャーを高めるのか」が直感的に理解できるはずだ。Fiberインスタンス自体が、生きている限りスタックフレームの残骸をヒープ上に抱え込み続けるからだ。
—
2. 設計の罠:なぜ「適当なFiber化」はシステムを破壊するのか
実務でFiberを導入する際、最も多い過ちは「非同期I/O(Streams, sockets)を伴わない、単なるCPUバウンドな処理の分割」や、「コンテキストを跨いだグローバル状態(シングルトンや静的プロパティ)の共有」である。
危険な設計パターン:暗黙的なグローバル依存
Zend VMはマルチスレッドセーフ(ZTS)であっても、通常のFPM環境ではマルチプロセスモデルを採用している。しかし、1つのFPMプロセス内で複数のFiberを動かす場合、それらは同じメモリ空間(ヒープ)を共有する。
もしFiber間で「静的プロパティ」や「グローバル変数」を介してデータをやり取りした場合、協調的マルチタスク(Cooperative Multitasking)のタイミング次第で、あるFiberが書き換えた状態を別のFiberが意図せず上書きする「競合状態(Race Condition)」がPHPのシングルスレッド空間であっても発生し得る。
—
3. 実践:実務に耐えうる非同期イベントループとFiberスケジューラ
机上の空論はここまでだ。ここからは、PHP 8.xのFiberと、ノンブロッキングなストリーム処理を組み合わせた、プロダクション品質の「ミニマム・協調的タスクスケジューラ」の実装コードを示す。
このコードは、複数のHTTPリクエスト(あるいは外部API呼び出し)を擬似的に並行実行し、I/O待ちの間に他のタスクへCPU権を譲る美しい設計になっている。
/
class TaskScheduler
{
/ @var array
private array $queue = [];
/ @var array
private array $readStreams = [];
/ @var array
private array $streamMap = [];
/
- スケジューラに新しいタスク(Fiber)を登録する
/
public function addTask(callable $taskLogic): void
{
$fiber = new Fiber(function () use ($taskLogic) {
$taskLogic();
});
$this->queue[] = $fiber;
}
/
- ノンブロッキングI/Oを伴う処理でタスクを一時停止させる(サスペンド)
- @param resource $stream 監視するソケットやストリーム
/
public static function waitForRead($stream): void
{
$scheduler = self::getCurrentScheduler(); // 簡略化のため静的コンテキストを想定、実際はDIコンテナ等で管理
// 実際のプロダクションコードでは、ここでストリームを非同期モード(stream_set_blocking($stream, false))にする
$fiber = Fiber::getCurrent();
if ($fiber === null) {
throw new RuntimeException(‘Fiberの外側でwaitForReadが呼び出されました。’);
}
// イベントループに処理を戻し、このFiberを中断する
Fiber::suspend($stream);
}
/
- スケジューラのメインループを実行する
/
public function run(): void
{
while (!empty($this->queue) || !empty($this->readStreams)) {
// 1. キューから次のFiberを取り出して実行・再開
$nextQueue = [];
foreach ($this->queue as $fiber) {
if ($fiber->isTerminated()) {
continue; // 既に終了したタスクは破棄
}
try {
if (! $fiber->isStarted()) {
// 初回実行
$value = $fiber->start();
} else {
// サスペンド状態からの復帰
$value = $fiber->resume();
}
// Fiber内でsuspend時にリソースが返された場合、ストリーム監視に登録
if (is_resource($value)) {
$id = (int) $value;
$this->readStreams[$id] = $value;
$this->streamMap[$id] = $fiber;
} else {
// まだ処理が残っている場合は再度キューの末尾へ(ラウンドロビン)
$nextQueue[] = $fiber;
}
} catch (\Throwable $e) {
// 本番環境では適切にロガーへ流すこと
error_log(‘Fiber execution error: ‘ . $e->getMessage());
}
}
$this->queue = $nextQueue;
// 2. stream_selectによるI/O多重化(イベントループの心臓部)
if (!empty($this->readStreams)) {
$read = $this->readStreams;
$write = [];
$except = [];
// タイムアウトを短く設定してブロッキングを防ぐ(例: 10ms)
$numChanged = @stream_select($read, $write, $except, 0, 10000);
if ($numChanged === false) {
continue; // エラー時は継続
}
if ($numChanged > 0) {
foreach ($read as $stream) {
$id = (int) $stream;
if (isset($this->streamMap[$id])) {
// I/Oの準備ができたFiberを再度実行キューに戻す
$targetFiber = $this->streamMap[$id];
$this->queue[] = $targetFiber;
// 監視リストから外す
unset($this->readStreams[$id], $this->streamMap[$id]);
}
}
}
}
}
}
private static function getCurrentScheduler(): self
{
// 実装の都合上、シングルトンインスタンスを返すか、コンテキストを注入する設計にする
static $instance = null;
return $instance ??= new self();
}
}
このコードの優れたアーキテクチャ的ポイント
1. CPUの遊休時間をゼロにする: `stream_select` を用いることで、OSからのI/O応答待ちの間に他の計算タスク(Fiber)をラウンドロビン方式で処理し続ける。
2. メモリリークの防止: タスクが終了(`$fiber->isTerminated()`)した時点で参照を切るため、Zend VMのガベージコレクション(RC: Reference Counting および循環参照コレクタ)が確実にFiberオブジェクトおよび内部のスタックフレームを解放する。
—
4. コードレビューの現場から:絶対に犯してはならない設計ミス
最後に、私がシニアエンジニアとしてコードレビューを行う際に、即座にリジェクト(差し戻し)するアンチパターンの特徴を挙げておく。
- アンチパターンA: Fiber内での例外の握りつぶし
Fiber内部でスローされた例外がキャッチされず外側に漏れた場合、そのFiberは `isTerminated()` が真になりつつも、エラーログを残さずにサイレントキルされることがある。必ず `try-catch` でラップするか、スケジューラ側で `Throwable` をキャッチして監視ログに残す仕組みを強制すること。
- アンチパターンB: 巨大な配列やORMエンティティのFiber間持ち回し
前述の通り、Fiberのコンテキストはヒープ上に退避される。数百MBに及ぶ巨大なデータセットをローカル変数に保持したままFiberをサスペンドさせると、PHPのメモリ制限(`memory_limit`)に一瞬で到達する。Fiber内で扱う変数は、必要最小限のIDやプリミティブ型に留めるのが鉄則だ。
—
結びにかえて
PHP 8.xのFiberは、もはや「オモチャの非同期機能」ではない。底にあるZend VMのメモリ管理とイベントループの物理挙動を完全に掌握した者にとって、極めて強力な武器となる。
フレームワークが隠蔽する魔法の裏側で、CPUとメモリがどのように呼吸しているか。そのリアリティを感じながらコードを書くことこそが、真にスケーラブルなWebシステムを構築唯一の道なのである。