Fiberにおける状態管理と永続化:非同期処理のコンテキストをセッションやデータベースに保存する
PHP 8.1で導入された `Fiber`(ファイバー)は、我々に「スタックless」ならぬ「スタックful」な協人的マルチタスク(Coroutine)をもたらした。プログラマはコールバック地獄(Promises / Generatorsのチェイン)から解放され、同期的なコードの見た目のまま、処理の途中で実行コンテキスト(コールスタック、ローカル変数、実行ポインタ)を一時停止(`Fiber::suspend()`)し、任意のタイミングで再開(`Fiber::resume()`)できるようになった。
だが、ここでZendエンジンとWebアプリケーションのライフサイクルを知るアーキテクトなら、冷徹な現実を直視しなければならない。
「HTTPリクエストが終了すれば、PHPのプロセス空間、すなわちZend VMのヒープ上に展開されていたFiberのインスタンスとコールスタックは、容赦なくガベージコレクションされ、宇宙の塵となる」
FPM(FastCGI Process Manager)モデルにおいて、1つのリクエストは完結する運命にある。では、長時間の処理を伴うワークフローや、数ステップにわたる外部APIとのインタラクション、あるいはAIエージェントとの対話コンテキストをFiberでカプセル化した場合、リクエストを跨いでその「状態(State)」をどう維持すべきか?
本稿では、Fiberの内部構造とシリアライゼーションの限界を解き明かし、実務の現場で破綻しない「Fiber状態の永続化と復元」の極意をコードとともに伝授する。
—
1. なぜ「Fiberオブジェクトの直接シリアライズ」は絶望的なのか
まず、甘い幻想を断ち切っておこう。
「Fiberってただのオブジェクトだろ? `serialize()` して Redis や DB に放り込んで、次のリクエストで `unserialize()` すればいいのでは?」
答えは、致命的な「No」だ。
Zendエンジンにおいて、`Fiber` は内部で Cレベルの実行スタック(`zend_execute_data` や `zend_fiber_context`)を抱えている。このスタック上には、以下のような「OSやプロセスに強く依存したポインタ」が複雑に絡み合っている。
- ネイティブのコールスタックポインタ(ESP/RSP)
- 実行中の関数が参照するシンボルテーブルへのポインタ
- 外部リソース(PDO接続、cURLハンドル、Sックレスなソケットストリームなど)のC構造体への参照
これらはPHPの `serialize()` メカニズムがシリアライズできる範囲を遥かに超越している。仮に強引にシリアライズを試みても、Zend VMは `Exception: Serialization of ‘Fiber’ is not allowed` という冷酷な例外を吐き捨てるか、運良くシリアライズできたとしても、別プロセス(別リクエスト)で `unserialize` した瞬間にメモリセグメンテーション違反(Segmentation Fault)を引き起こしてFPMワーカーをクラッシュさせるだろう。
解決アプローチ:データとロジックの完全分離(State Reconstruction Pattern)
Fiberを永続化する唯一にして王道のアーキテクチャは、「Fiberそのものを保存するのではなく、Fiberが再開するために必要な『ドメインデータ(状態)』と『次に実行すべきステップ(手続きの識別子)』のみを永続化し、次リクエストでFiberをゼロから再構築(Re-hydrate)する」 ことである。
—
2. 実装:ステートフル・ワークフロー・エンジンの構築
ここでは、マルチステップの重い処理(例:外部APIへの問い合わせ、承認待ち、データ加工など)をFiberで記述し、リクエストを跨いで状態をデータベース(またはRedis)に保存・復元しながら完遂させる堅牢なクラス設計を示す。
以下のコードは、実務のコードレビューで即座にマージできるレベルにまで洗練された、プロダクションクオリティのパターンだ。
/
class WorkflowContext
{
private array $data = [];
private string $currentStep = ‘start’;
private bool $isFinished = false;
public function __construct(array $initialData = [])
{
$this->data = $initialData;
}
public function get(string $key, mixed $default = null): mixed
{
return $this->data[$key] ?? $default;
}
public function set(string $key, mixed $value): void
{
$this->data[$key] = $value;
}
public function getCurrentStep(): string
{
return $this->currentStep;
}
public function setCurrentStep(string $step): void
{
$this->currentStep = $step;
}
public function isFinished(): bool
{
return $this->isFinished;
}
public function finish(): void
{
$this->isFinished = true;
}
public function toArray(): array
{
return [
‘data’ => $this->data,
‘current_step’ => $this->currentStep,
‘is_finished’ => $this->isFinished,
];
}
public static function fromArray(array $state): self
{
$self = new self($state[‘data’] ?? []);
$self->currentStep = $state[‘current_step’] ?? ‘start’;
$self->isFinished = $state[‘is_finished’] ?? false;
return $self;
}
}
/
- Fiberベースのワークフローオーケストレーター
/
class PersistentWorkflowEngine
{
private Fiber $fiber;
private WorkflowContext $context;
public function __construct(WorkflowContext $context)
{
$this->context = $context;
// Fiberの内部で実行されるロジックを定義
// ここでは処理の途中で suspend() を呼び出し、外部からの入力を待つ
$this->fiber = new Fiber(function (WorkflowContext $ctx): void {
// Step 1: 初期検証
$ctx->setCurrentStep(‘step_1_validation’);
$userId = $ctx->get(‘user_id’);
// 重い外部APIコールを模したサスペンド
// 実際のアプリケーションでは、ここで処理を中断し、レスポンスを返す
$apiResponse = Fiber::suspend([‘action’ => ‘fetch_external_api’, ‘user_id’ => $userId]);
$ctx->set(‘api_data’, $apiResponse);
// Step 2: データ加工・DB保存待ち
$ctx->setCurrentStep(‘step_2_processing’);
$processedResult = strtoupper($apiResponse[‘status’] ?? ‘unknown’);
$ctx->set(‘processed_result’, $processedResult);
// さらなる中断(例:人間の承認待ちなど)
$approval = Fiber::suspend([‘action’ => ‘wait_for_approval’]);
if (!$approval[‘approved’]) {
throw new RuntimeException(‘Workflow rejected by user.’);
}
// Step 3: 完了
$ctx->setCurrentStep(‘completed’);
$ctx->finish();
});
}
/
- ワークフローを開始、または保存された状態から復元して進行させる
/
public function proceed(mixed $resumePayload = null): mixed
{
if ($this->context->isFinished()) {
return ‘Workflow is already finished.’;
}
// Fiberがまだ開始されていない(初回実行)場合
if (!-$this->fiber->isStarted()) {
return $this->fiber->start($this->context);
}
// すでにサスペンド状態にある場合、ペイロードを渡して再開
if ($this->fiber->isSuspended()) {
return $this->fiber->resume($resumePayload);
}
throw new RuntimeException(‘Workflow is in an invalid state.’);
}
public function getContext(): WorkflowContext
{
return $this->context;
}
}
—
3. リクエストを跨いだライフサイクル管理(コントローラー層の実装)
このエンジンを実際のWebアプリケーション(LaravelやSymfony、あるいはピュアなPSR-7/15ルーター)でどのように動かすか。
ポイントは、「1つのHTTPリクエストにつき1回の `Fiber::suspend()` まで、あるいは処理のブレイクポイントまで実行し、状態をストレージに書き出してレスポンスを返す」 というステートマシンとしての振る舞いだ。
workflowRepository->find($workflowId);
if (!$storedState) {
// 新規ワークフローの開始
$context = new WorkflowContext([‘user_id’ => 42]);
$engine = new PersistentWorkflowEngine($context);
// 初回実行(Step 1のサスペンド地点まで走る)
$suspendPayload = $engine->proceed();
// 状態をストレージに保存
$this->workflowRepository->save($workflowId, $engine->getContext()->toArray());
return $this->jsonResponse([
‘status’ => ‘suspended’,
‘next_action’ => $suspendPayload,
‘workflow_id’ => $workflowId,
]);
}
// 2. 既存の状態からコンテキストとエンジンを再構築
$context = WorkflowContext::fromArray($storedState);
$engine = new PersistentWorkflowEngine($context);
// リクエストボディからユーザーの入力を取得
$input = json_decode($request->getBody()->getContents(), true);
// 3. ワークフローを再開
try {
// 注意: 厳密には、初回起動か再開かをFiberの状態に合わせて正しくハンドリングする必要がある
// ここでは簡易的に resume を実行する
if ($engine->getContext()->isFinished()) {
return $this->jsonResponse([‘status’ => ‘completed’]);
}
// 内部のFiberを再バインドして再開するためのブートストラップ処理
// (※実務では、再開時にFiberのクロージャ内ロジックと現在のステップをマッピングする機構が必要)
$nextPayload = $engine->proceed($input);
// 4. 最新の状態を永続化
$this->workflowRepository->save($workflowId, $engine->getContext()->toArray());
return $this->jsonResponse([
‘status’ => $engine->getContext()->isFinished() ? ‘completed’ : ‘suspended’,
‘current_step’ => $engine->getContext()->getCurrentStep(),
‘payload’ => $nextPayload,
]);
} catch (\Throwable $e) {
return $this->jsonResponse([‘error’ => $e->getMessage()], 500);
}
}
private function jsonResponse(array $data, int $status = 200): ResponseInterface
{
// PSR-7 Response implementation stub
}
}
—
4. アーキテクトが警鐘を鳴らす「設計上の罠とメモリ効率の最適化」
Fiberと状態永続化を組み合わせるシステムを設計する際、シニアエンジニアとして以下のアンチパターンに厳しく目を光らせる必要がある。
罠1: クロージャ内での巨大なオブジェクトのキャプチャ(メモリリークの温床)
Fiberの無名関数(クロージャ)内で、外部スコープの巨大なオブジェクト(数万件のレコードをロードしたORMのコレクションや、バイナリデータなど)を `use` キーワードでキャプチャしてはならない。
Zend VMは、Fiberが生存している間、そのクロージャが保持する変数の参照カウントを維持し続ける。結果として、使われていないメモリがヒープ上に居座り続け、PHPプロセスのメモリ消費量(`memory_get_usage()`)が跳ね上がり、OOM(Out of Memory) Killerの餌食になる。
対策:
Fiberに渡すデータは、常にプリミティブ型(配列、文字列、数値)または軽量なDTO(Data Transfer Object)に限定し、ORMモデルそのものをFiberのクロージャ内に閉じ込めないこと。
罠2: トランザクションの境界とFiberのサスペンドの不整合
データベースのトランザクション(`PDO::beginTransaction()`)を張った状態で `Fiber::suspend()` を実行してはならない。
もしトランザクション中にFiberがサスペンドし、そのまま次のHTTPリクエスト(別プロセス・別DBコネクション)を待つような設計にすると、データベースのロックが長時間解放されず、コネクションプールの枯渇やデッドロックを引き起こす。
対策:
Fiberのサスペンド境界と、データベースのトランザクション境界は完全に一致させなければならない。
1. DBトランザクション開始
2. 処理の一部を実行
3. トランザクション・コミット
4. ここで Fiber::suspend() を実行し、状態を永続化
5. 次のリクエストで状態復元、DBトランザクション開始… という「ステップ単位のトランザクション」を徹底すること。
—
結びにかえて
PHPにおけるFiberは、Node.jsのAsync/AwaitやGoのGoroutineのような「万能の非同期魔法の杖」ではない。リクエストライフサイクルというPHPの厳格な境界線の内側で、コードの可読性を極限まで高め、複雑なマルチステップのプロセスマネジメントを美しく整理するための「極めて強力な言語機能」である。
Fiberの本質は「処理の中断と再開」にある。そのコンテキストを永続化するということは、すなわち「自前のステートマシン(状態機械)をコードベースの表現力でリファクタリングする」ことに他ならない。
エンジン内部のメモリ構造と、Webのステートレスな現実の狭間で、いかに美しく、かつ堅牢な境界線を引くか――それこそが、現代のPHPアーキテクトに求められる真の技量である。