PHPのFiberで処理を中断して再開する

Fiberとは、PHP 8.1で追加された、関数の実行を途中で中断して後から再開できる仕組みです。ジェネレータと違い、深くネストした呼び出しの奥からでも中断できます。

非同期ライブラリの中身を読んでいると必ず出てくるのがこのFiberです。ただ「非同期のための機能」という説明だけだと、たぶん半分も伝わりません。Fiber自体は非同期でも並列でもなく、ただの中断と再開ができるコールスタックです。そこを押さえないと、使いどころもハマりどころも見えてこない気がします。

まず最小の往復を見る

Fiberは呼び出し側と中の処理が値をやり取りしながら交互に動きます。start()suspend()resume()の戻り値が誰から誰へ渡るのかを、最初に一度きちんと追っておくと後が楽です。

<?php
$fiber = new Fiber(function (string $name): string {
    // suspend() に渡した値が start() の戻り値になる
    $received = Fiber::suspend("hello {$name}");
    // resume() に渡した値が suspend() の戻り値になる
    return "bye {$received}";
});

$out = $fiber->start('taro');
var_dump($out);                // string(10) "hello taro"

$ret = $fiber->resume('hanako');
var_dump($ret);                // NULL(再開後は中断せず終了したため)
var_dump($fiber->getReturn()); // string(10) "bye hanako"

矢印が二方向あるのがポイントです。start()resume()は「次に中断した地点で渡された値」を返し、Fiberが中断せず終わった場合はnullを返します。コールバックの戻り値はgetReturn()で別途取り出します。ここを混同すると値がひとつずれます。

ジェネレータと何が違うのか

「それってジェネレータでもできるのでは」と思う人は多いはずです。私も最初そう思いました。違いは一点で、中断できる場所です。

yieldはジェネレータ関数の本体にしか書けません。だから途中で中断したくなった瞬間、その関数もそれを呼ぶ関数も全部ジェネレータに書き換える必要が出てきます。いわゆる関数色の伝播ですね。Fiberは自前のCスタックを持つので、何階層下からでも中断できます。

<?php
function fetch(string $url): string
{
    // 深い階層で中断してよい。戻り値の型は string のままでいい
    $chunk = Fiber::suspend(['read', $url]);
    return strtoupper($chunk);
}

$fiber = new Fiber(function (): string {
    return fetch('/a') . fetch('/b');
});

$req1 = $fiber->start();       // ['read', '/a']
$req2 = $fiber->resume('foo'); // ['read', '/b']
$fiber->resume('bar');
echo $fiber->getReturn();      // FOOBAR

fetch()のシグネチャが: stringのまま変わっていないのが、この機能の一番おいしいところです。呼び出し側から見れば普通の同期関数のまま、実行だけが中断される。既存コードに中断点を差し込みやすい理由がここにあります。

内部関数のコールバックの中でも止まれる

意外に知られていない挙動として、array_map()のように内部実装がC側にある関数に渡したコールバックの中からでも中断できます。RFCでも明示されている動作です。

<?php
$fiber = new Fiber(function (): array {
    return array_map(
        fn (int $n): int => Fiber::suspend($n) * $n,
        [1, 2, 3]
    );
});

$fiber->start();     // 1
$fiber->resume(10);  // 2
$fiber->resume(20);  // 3
$fiber->resume(30);
var_dump($fiber->getReturn()); // [10, 40, 90]

ジェネレータではまず書けない形です。foreachで回しているIteratorのメソッドの中でも同じことができます。ライブラリ側が中断可能なコードを裏で仕込めるのは、この自由度のおかげです。

状態を間違えると FiberError が飛ぶ

Fiberは状態機械なので、順番を守らないと即座にFiberErrorになります。実務でよく踏むのは次の四つです(それぞれ独立したケースとして読んでください)。

<?php
// 1. 終了前に戻り値を取ろうとした
$a = new Fiber(fn () => Fiber::suspend('x'));
$a->start();
$a->getReturn();   // FiberError

// 2. 二重に start した
$b = new Fiber(fn () => Fiber::suspend('x'));
$b->start();
$b->start();       // FiberError

// 3. 終了済みの Fiber を再開した
$c = new Fiber(fn () => Fiber::suspend('x'));
$c->start();
$c->resume();
$c->resume();      // FiberError

// 4. Fiber の外側({main})から中断しようとした
Fiber::suspend('y'); // FiberError

特に最後のやつが厄介で、同じ関数がFiberの中からも外からも呼ばれる設計だと簡単に落ちます。共通関数を書くときはFiber::getCurrent()で判定して、外から呼ばれたら同期的に振る舞わせるのが安全だと思います。

<?php
function maybeSuspend(mixed $value): mixed
{
    if (Fiber::getCurrent() === null) {
        return $value;              // Fiberの外。中断せずそのまま返す
    }
    return Fiber::suspend($value);  // Fiberの中。呼び出し側に制御を返す
}

状態の確認にはisStarted()isSuspended()isRunning()isTerminated()が用意されています。汎用のスケジューラを書くなら、resumeする前にisSuspended()を見るのが定番です。

中断中の例外と後片付けはどうなる?

中断しているFiberの中に、外から例外を投げ込めます。タイムアウトやキャンセルの実装はだいたいこれです。

<?php
$fiber = new Fiber(function (): void {
    try {
        Fiber::suspend();
    } finally {
        echo "cleanup\n";
    }
});

$fiber->start();

try {
    $fiber->throw(new RuntimeException('timeout'));
} catch (RuntimeException $e) {
    echo $e->getMessage(), "\n";
}
// cleanup
// timeout

投げた例外はFiber内のsuspend()の地点から送出され、finallyを通ってから呼び出し側へ抜けてきます。中断したまま二度と再開されずFiberオブジェクトが破棄された場合も、未完了のジェネレータと同じくfinallyは実行されます。リソース解放をfinallyに置いておけば、取りこぼしはしにくいということですね。

ただし制約もあって、PHP 8.4より前はデストラクタの実行中にFiberを切り替えることができませんでした。8.4でこの制限は緩和されています。デストラクタで後始末をするタイプのオブジェクトを長時間走るFiberと組み合わせるなら、動かすPHPのバージョンは確認しておいたほうがいいです。

Fiberは並列実行ではない

ここを誤解したまま導入すると、たぶん期待した速度は出ません。同時に走るFiberは常にひとつだけです。CPUバウンドな処理をFiberに分けても速くなりませんし、そもそもPHP本体にはスケジューラが入っていません。誰がいつresume()するかは、全部自分で書くかライブラリに任せるかのどちらかです。

実際にI/Oの並行処理をやりたいなら、Revoltのようなイベントループと、その上のAMPHPを使うことになります。Fiberはあくまでその土台で、私たちが直接触ることはそんなに多くない、という位置づけだと考えています。それでも仕組みを知っていると、非同期ライブラリのスタックトレースを読むときの見通しがまったく違ってきます。

あとメモリの話も一応。Fiberはひとつごとに専用のスタックをヒープ上に確保します。上限はfiber.stack_sizeで設定でき、多くの環境では実際に使った分だけ物理メモリが消費される作りです。とはいえ数万個のFiberを同時に抱えるような設計にするなら、そこは測ってから決めるべきところだと思います。

まとめ

Fiberは中断と再開ができるコールスタックで、それ以上でも以下でもありません。ジェネレータとの決定的な違いは、深くネストした呼び出しの中からでも中断できることと、そのために関数のシグネチャを変えずに済むことです。使うときは、状態遷移を間違えるとFiberErrorで落ちること、Fiberの外から呼ばれる可能性のある関数はFiber::getCurrent()で守ること、そして並列実行ではないこと。この三つを覚えておけば大きくは踏み外さないはずです。日常の業務コードに直接書く機会は少ないと思いますが、使っているライブラリの下で何が起きているかを知る意味では、一度自分で往復を書いてみる価値はあります。

よくある質問

Q. FiberとGeneratorはどちらを使えばいいですか?
A. 値の列を遅延生成したいだけならGeneratorで十分です。処理の途中で制御を呼び出し側に返したい、しかも中断点が関数の奥にあるならFiberが向いています。

Q. Fiberを使えば複数のHTTPリクエストが並列に飛びますか?
A. Fiber単体では飛びません。実際に待ち時間を重ねるには、ノンブロッキングI/Oとイベントループが必要です。RevoltやAMPHPといったライブラリがその役割を担います。

Q. 中断しようとすると FiberError になります。
A. Fiberの外側、つまり{main}からFiber::suspend()を呼んでいる可能性が高いです。共通関数の中で呼んでいるなら、Fiber::getCurrent()がnullでないことを確認してから中断するようにしてください。

類似投稿

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です