Fiberのキャンセル機構とリソース解放:実行中のFiberを安全に停止させ、メモリリークを防ぐ戦略
PHP 8.1で導入された `Fiber`(ファイバー)は、コールスタックを独立して保持できる軽量な協調的マルチタスキング(Cooperative Multitasking)のプリミティブだ。これにより、従来のプリエンプティブなOSスレッドに頼ることなく、ユーザーランドで非同期処理のフローを制御できるようになった。
しかし、この強力な「実行状態を任意の場所でサスペンド(一時停止)し、後からレジューム(再開)する」という特性は、裏を返せば、「サスペンド中のファイバーが保持しているコンテキストとリソースのライフサイクル管理が、プログラマの責務になる」ことを意味する。
特に、タイムアウトやクライアントの切断、あるいは上位の制御フローによる「キャンセル」が発生した際、中途半端な状態で放置されたファイバーや、解放漏れを起こしたリソース(DBコネクション、ファイルディスクリプタ、ネットワークソケット)は、確実にZend Engineのメモリを蝕み、やがて致命的なメモリリークやリソース枯渇を引き起こす。
本稿では、PHPのZend VM内部におけるコールスタックとメモリ管理の挙動に目を向けながら、実行中のFiberを安全にキャンセルし、リソースを確実・クリーンに解放するための設計戦略を、実務に耐えうるコードと共に徹底解説する。
—
1. Zend VMの視点から見た Fiber のメモリ構造と危険性
コードの解説に入る前に、PHPエンジン内部でFiberがどのように扱われているかを理解しておかなければならない。
通常、PHPの関数呼び出しやメソッド実行は、コールスタック(`zend_execute_data` の連結リスト)上で順次処理され、スコープを抜ければ即座に解放される。しかし、Fiberが生成されると、PHPは専用の独立したコールスタックとヒープ領域の一部を別個に確保する。
[Request Process (FPM)]
└── Main Execution Context (zend_execute_data)
└── Fiber Instance (独立したコールスタック / zend_fiber 構造体)
├── $this->start() ──> 実行コンテキストの退避
└── $this->suspend() ─> コールスタックの凍結と制御の移譲
ここで開発者が陥りがちな最大の罠が、「Fiberオブジェクトがスコープ外に出ても、サスペンド状態のまま参照が残っていると、その内部スタックとローカル変数はメモリ上に残り続ける」という点である。
さらに厄介なのは、Fiber内部でオープンされたリソース(例: `stream_socket_client()` や `PDO` トランザクション)だ。これらはPHPのガーベッジコレクション(GC)の対象外、あるいは特殊なリソース型(Resource / Closed-on-destruct)であることが多く、ファイバーが突然破棄されると、OSレベルのファイル記述子がリークし、C10K問題どころか、数千リクエストでシステム全体がダウンする原因となる。
—
2. 安全な Fiber キャンセルを実現する設計パターン
安全なキャンセル機構を実装するためには、以下の3つの原則を厳守する必要がある。
1. 例外によるスタックのアンワインド(Stack Unwinding): 強制終了ではなく、ファイバー内部に向けて `FiberError` やカスタム例外をスロー(`throw()`)し、`try…finally` ブロックを確実に通過させる。
2. キャンセルトークン(CancellationToken)の共有: イベントループとファイバーの間で状態を同期し、サスペンドポイントごとに中断要求をポーリングする。
3. RAII(Resource Acquisition Is Initialization)の模倣: リソースのライフサイクルをオブジェクトのスコープに紐づけ、`__destruct` や `finally` で確実にクローズする。
実務で使える堅牢な非同期タスクコントローラの設計
以下に、イベントループ上で安全にFiberを駆動し、任意のタイミングで例外を注入してクリーンアップを行わせるためのリファレンス実装を示す。
/
class CancellationToken {
private bool $isCancelled = false;
public function cancel(): void {
$this->isCancelled = true;
}
public function isCancelled(): bool {
return $this->isCancelled;
}
public function throwIfCancelled(): void {
if ($this->isCancelled) {
throw new FiberCancelledException(‘Fiber was cancelled.’);
}
}
}
class FiberCancelledException extends \RuntimeException {}
/
- 管理された安全なFiberランッパー
/
class ManagedTask {
private Fiber $fiber;
private CancellationToken $token;
private bool $isFinished = false;
public function __construct(callable $taskLogic) {
$this->token = new CancellationToken();
$this->fiber = new Fiber(function () use ($taskLogic) {
try {
// タスクロジックを実行。キャンセルトークンを渡す
return $taskLogic($this->token);
} catch (FiberCancelledException $e) {
// キャンセル時のクリーンアップ処理(必要に応じたログなど)
echo “[System] Fiber gracefully terminated by cancellation.\n”;
throw $e;
} finally {
// 最重要: どのような結末であれ、ここでローカルリソースの解放を保証する
$this->isFinished = true;
}
});
}
public function start(): mixed {
return $this->fiber->start();
}
public function resume(mixed $value = null): mixed {
if ($this->isFinished) {
throw new \LogicException(‘Cannot resume a finished or cancelled fiber.’);
}
return $this->fiber->resume($value);
}
/
- 実行中のFiberに対して外部から例外をスローし、キャンセルを試みる
/
public function cancel(): void {
if ($this->isFinished || $this->fiber->isTerminated()) {
return;
}
$this->token->cancel();
try {
// サスペンド中のFiberのコンテキストに例外をインジェクション
$this->fiber->throw(new FiberCancelledException(‘Cancellation requested.’));
} catch (Throwable $e) {
// 例外がファイバー内部でキャッチされずにバブルアップした場合のハンドリング
$this->isFinished = true;
// 必要に応じてここでリソースの強制破棄を行う
}
}
public function isTerminated(): bool {
return $this->fiber->isTerminated() || $this->isFinished;
}
}
—
3. 実践:ネットワークリソースを伴うタスクの安全な破棄
次に、この `ManagedTask` を用いて、ネットワークI/O(ストリーム)を扱う処理の中でキャンセルが発生した際、ソケットが確実に閉じられることを証明するコードを見てみよう。
throwIfCancelled();
echo “[Task] ステップ {$i} 実行中…\n”;
// 非同期的なウェイト(イベントループへの制御返却を模倣)
Fiber::suspend();
}
} finally {
// 【極めて重要】
// 例外発生時、キャンセル時、正常終了時のいずれであっても必ず実行される
echo “[Task Cleanup] リソースを安全にクローズし、メモリを解放します。\n”;
if (is_resource($resource)) {
fclose($resource);
}
}
return “成功”;
});
// 1. タスクの起動
$task->start();
// 2. 数回レジュームするが、途中でキャンセルを決定する
$task->resume(); // ステップ 1 完了
$task->resume(); // ステップ 2 完了
echo “[Main] タイムアウトまたはクライアント切断を検知。キャンセルを実行します。\n”;
// 3. 安全なキャンセルを実行
// これにより、内部の try…finally が発動し、ストリームが確実に閉じられる
$task->cancel();
// 4. 状態の確認
var_dump($task->isTerminated()); // bool(true)
実行結果のトレース
上記のコードを実行すると、コンソールには次のような出力が得られる。
[Task] 処理を開始します…
[Task] ステップ 1 実行中…
[Task] ステップ 2 実行中…
[Main] タイムアウトまたはクライアント切断を検知。キャンセルを実行します。
[System] Fiber gracefully terminated by cancellation.
[Task Cleanup] リソースを安全にクローズし、メモリを解放します。
bool(true)
この出力からわかる通り、メインスレッド側から `cancel()` を呼び出すことで、Fiber内部の `finally` ブロックが即座に呼び出され、`fopen` で確保されたリソースおよびメモリバッファが安全に破棄されている。もしこの `finally` によるクリーンアップ機構を怠っていれば、Fiberが保持していた1MBのバッファおよびファイルハンドルは、PHPリクエストが終了するまで(あるいは長寿命プロセスであれば永続的に)メモリ空間に残留し続けることになっていた。
—
4. チーフアーキテクトからの実践的提言:競合状態(Race Conditions)の回避
Fiberは単一スレッド上で動作するため、いわゆるマルチスレッドプログラミングで発生するような「プリエンプションによるメモリ上の競合」は起きない。しかし、「制御の移譲(Cooperative Yielding)のタイミング」に起因する論理的な競合状態には細心の注意を払う必要がある。
1. 外部イベントループとの二重解放(Double Free)の防止
イベントループ側がタイムアウトによる自動キャンセルを行い、同時にユーザーリクエスト側からもキャンセル要求が飛んできた場合、`Fiber::throw()` が二重に呼び出される危険性がある。必ずタスクのステータスフラグ(`$isFinished` や `$isTerminated`)でガードし、既に終了したFiberに対する操作を完全にブロックすること。
2. トランザクションコンテキストのスコープ分離
データベースのトランザクション(例: `PDO::beginTransaction()`)をFiberの内部でまたいでサスペンドする場合、そのFiberがキャンセルされた瞬間にロールバックを発動させるクリーンアップハンドラを必ず `finally` に含めよ。放置されたトランザクションは、データベースのコネクションプールを枯渇させ、アプリケーション全体をデッドロックに導く。
FiberはPHPにおける非同期処理のゲームチェンジャーだが、その強力さゆえに、リソース管理の規律を破ったときの代償は大きい。エンジン内部の挙動を常に脳内でトレースし、「誰がこのリソースのライフサイクルに責任を持つのか」をコードの構造で明確に示し続けること。それが、プロダクション環境で真に信頼されるWebシステムを構築するための唯一の道である。