【入門編】Fiberを用いたリアクティブプログラミングフレームワークの構築:イベントストリームとオペレーター – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

こんにちは。日々のコードリーディングやパフォーマンスチューニング、本当にお疲れ様です。

Node.jsやGo、あるいはRustといった他言語の非同期・並行処理モデルに触れてきた優秀なエンジニアほど、PHPのコードを書くときに「なぜ1リクエストごとにプロセス(あるいはスレッド)が閉じ、I/O待ちのたびにCPUがブロックされるのか」というもどかしさを感じたことがあるのではないでしょうか。

「PHPでも、モダンなリアクティブプログラミングやストリーム処理を美しく書きたい」

その願いを叶える鍵こそが、PHP 8.1でひそかに導入された Fiber(ファイバー) です。今回は、表面的なAPIの使い方ではなく、ZendエンジンがFiberをどう扱い、イベントループと組み合わせることで「PHPによる真のリアクティブ・フレームワーク」を構築できるのか、その核心に迫っていきましょう。

ここを理解すると、PHPの裏側が驚くほど綺麗に見えてきますよ。

—

1. なぜFiberなのか? Zend VMとスタックレス・コールバックの限界

これまでのPHP(特にReactPHPやAmpなどの第1世代非同期ライブラリ)は、イベントループと「コールバック・地獄(あるいはPromisesのチェイン)」を組み合わせることで非同期性を実現してきました。

しかし、コールバックベースのコードには致命的な問題があります。それは、コールやリターンが関数を跨ぐため、スタックトレースが断絶し、例外処理やデバッグが極めて困難になるという点です。また、ビジネスロジックが非同期の細切れの関数に分断され、人間が直感的に追える「手続き型の流れ」が破壊されてしまいます。

Fiberの本質:協調的マルチタスク(Cooperative Multitasking)

Fiberは、Zend VMの実行コンテキスト(コールスタック、変数テーブル、実行ポインタ)をユーザーランド(PHPコード側)で完全にキャプチャし、一時停止(Suspend)と再開(Resume)を自在に行えるようにする仕組みです。

OSスレッドとは異なり、OSのスケジューラではなく開発者が記述したコードの意図通りにCPUの制御権を譲り合う(協調する)ため、コンテキストスイッチのオーバーヘッドが極めて小さく、メモリ効率も抜群に高いのが特徴です。

—

2. リアクティブ・フレームワークの心臓部を作る

それでは、Fiberをベースにして、RxJSやRxPHPのような「イベントストリーム(Observable)とオペレーター」を持つ超軽量リアクティブフレームワークを自作してみましょう。

リアクティブプログラミングの核心は、「データの流れ(ストリーム)を時間軸に沿って抽象化し、非同期に流れてくる値を変形・フィルタリングする」ことにあります。

以下に、Fiberのサスペンド機構を隠蔽し、直感的な非同期ストリームを構築するコアクラスの実装を示します。

  • イベントストリームを表現するObservable
  • 内部でFiberを駆動し、非同期な値のプッシュとプルを調停します。
  • /
    class Observable
    {
    private Closure $producer;

    public function __construct(callable $producer)
    {
    $this->producer = Closure::fromCallable($producer);
    }

    /

    • ストリームにオペレーターを適用して新しいObservableを返す(パイプライン構築)

    /
    public function pipe(callable …$operators): self
    {
    $stream = $this;
    foreach ($operators as $operator) {
    $stream = $operator($stream);
    }
    return $stream;
    }

    /

    • ストリームを購読(Subscribe)し、イベントの消費を開始する

    /
    public function subscribe(callable $onNext, ?callable $onError = null, ?callable $onComplete = null): void
    {
    // 各購読ごとに独立したFiberを生成し、Zend VMのスタック空間を隔離する
    $fiber = new Fiber(function () {
    ($this->producer)(function ($value) {
    // 値が流れてきたら、親のFiber(イベントループ側)へ一時停止して値を渡す
    Fiber::suspend($value);
    });
    });

    try {
    // 初回実行
    $value = $fiber->start();

    // イベントループ的な駆動:Fiberが終了するまで値を消費し続ける
    while (!$fiber->isTerminated()) {
    if ($value !== null) {
    $onNext($value);
    }
    // 次の値を要求してFiberを再開
    $value = $fiber->resume();
    }

    if ($onComplete) {
    $onComplete();
    }
    } catch (Throwable $e) {
    if ($onError) {
    $onError($e);
    } else {
    throw $e;
    }
    }
    }
    }

    このコードの美しいところは、`$producer` の内部でどれだけ複雑な非同期I/O待ちやタイマー処理があっても、呼び出し側(Consumer)からは「まるで同期処理のように、上から下に流れるストリーム」に見える点です。Fiberがコールスタックを丸ごと保持しているため、例外が発生してもスタックトレースが綺麗に保たれます。

    —

    3. オペレーター(map, filter)の実装:関数型パターンの統合

    リアクティブプログラミングの真骨頂は、`map` や `filter` といった高階関数オペレーターをチェイン(パイプ)できることにあります。先ほどの `Observable` に流れるデータを加工するオペレーターを実装してみましょう。

    namespace TinyRx;

    class Operators
    {
    /

    • 流れてくるデータを変換する map オペレーター

    /
    public static function map(callable $fn): callable
    {
    return function (Observable $source) use ($fn) {
    return new Observable(function ($observer) use ($source, $fn) {
    $source->subscribe(
    onNext: fn($value) => $observer($fn($value)),
    onError: fn($e) => throw $e,
    onComplete: fn() => null
    );
    });
    };
    }

    /

    • 条件に合致するデータのみを通す filter オペレーター

    /
    public static function filter(callable $predicate): callable
    {
    return function (Observable $source) use ($predicate) {
    return new Observable(function ($observer) use ($source, $predicate) {
    $source->subscribe(
    onNext: function($value) use ($observer, $predicate) {
    // 条件に一致する場合のみ、下流へプッシュする
    if ($predicate($value)) {
    $observer($value);
    }
    },
    onError: fn($e) => throw $e,
    onComplete: fn() => null
    );
    });
    };
    }
    }

    —

    4. 実践:構築したフレームワークを動かしてみる

    では、私たちが今作ったばかりのFiberベース・リアクティブフレームワークを実際に動かしてみましょう。
    ここでは、擬似的な非同期イベントソース(例えば、WebSocketのメッセージ受信やDBからのストリーミングデータ)を想定します。

    pipe(
    // 偶数だけを抽出するフィルター
    Operators::filter(fn($val) => $val % 2 === 0),

    // 抽出された値を2倍に変換するマップ
    Operators::map(fn($val) => $val 10)
    )->subscribe(
    onNext: function ($result) {
    echo “受信データ: {$result}\n”;
    },
    onError: fn($e) => echo “エラー発生: ” . $e->getMessage() . “\n”,
    onComplete: fn() => echo “すべての処理が完了しました。\n”
    );

    実行結果

    — ストリーム処理開始 —
    受信データ: 20
    受信データ: 40
    — ストリーム処理終了 —
    すべての処理が完了しました。

    奇数(1, 3, 5)は見事にフィルターされ、偶数(2, 4)のみが `10` 倍されて綺麗にコンソールに出力されましたよね。しかも、この一連の処理は複雑なコールバックの入れ子ではなく、Fiberのコンテキストスイッチによって美しく制御されています。

    —

    5. アーキテクトからのメッセージ:裏側のメモリ管理と注意点

    最後に、PHPでFiberを扱う上での重要なアーキテクチャ上の注意点をお伝えします。

    Zend VMにおいて、通常のリクエストは単一のコールスタック(CスタックおよびZendの実行スタック)上で直線的に処理されます。しかし、`new Fiber()` を生成すると、Zendエンジンはヒープメモリ上に独立した実行コンテキスト(スタックフレームの集合)を割り当てます。

    つまり、Fiberを無制限に生成・放置すると、PHPのメモリ消費量が跳ね上がる原因になります。Node.jsの軽量スレッド感覚で数万個のFiberを同時に立ち上げるような設計は、現在のPHP(Zend VM)のメモリ管理機構においては避けるべきです。
    あくまで「I/Oバウンドな特定の非同期処理のブロックを避けるため」「複雑な非同期ストリームを読みやすくするため」の特効薬として、適切な粒度で使い分けることが、プロフェッショナルなPHPアーキテクトの腕の見せ所となります。

    PHPの裏側で何が起きているのかをイメージできるようになると、コードを書く手が止まらなくなるはずです。ぜひ、今日の知見をご自身のプロダクトの設計やフレームワークの拡張に応用してみてください。

    あなたのPHPライフが、もっとエキサイティングで美しいものになりますように。

    タイトルとURLをコピーしました