【入門編】Fiberを用いた高負荷な外部API呼び出しの並行処理とタイムアウト・リトライ戦略 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

こんにちは。PHPの裏側を覗く旅へようこそ。

他の言語、例えばNode.jsの非同期処理やGoのGoroutineなどを触った経験がある方なら、PHPの「1リクエスト=1プロセス(またはスレッド)で同期的に上から下に流れるモデル」に、どこかもどかしさを覚えたことがあるかもしれませんよね。「なぜPHPは、外部APIを複数叩くときにこうも簡単にブロッキングされてしまうのか」と。

C10K問題やマイクロサービス全盛の現代において、PHPで外部APIの呼び出しがボトルネックになるのはよくある話です。特に「複数のサードパーティAPIを並行して叩き、それぞれにタイムアウトと指数バックオフ(リトライ)を仕込みたい」となったとき、従来の`curl_multi`を素朴に使おうとすると、コードはコールバックのスパゲッティと化し、精神衛生上非常によろしくない状態になります。

しかし、PHP 8.1で導入された Fiber(ファイバー) を正しく理解し、イベントループと組み合わせれば、PHPのコードは驚くほど美しく、そして強靭に生まれ変わります。

今回は、Fiberの内部挙動とメモリの仕組みにまで踏み込みながら、高負荷な外部APIを優雅にさばく非同期並行処理の極意を、一緒に紐解いていきましょう。ここを理解すると、PHPの裏側が本当に綺麗に見えるようになりますよ。

—

1. PHPのFiberとは何か? 〜Zend VMのスタック退避と復元のメカニズム〜

まず、Fiberの基本概念を確認しておきましょう。よくある誤解として「Fiberはマルチスレッドである」というものがありますが、それは違います。PHPの実行モデルは基本的にシングルスレッド(正確にはZend VMのメインループ)です。

Fiberの本質は 「ユーザースペースにおける協調的マルチタスク(Cooperative Multitasking)」 です。

内部で何が起きているのか?

通常の関数呼び出しは、コールスタックが積まれ、returnされるまで途中で止めることはできません。しかし、Fiberを使うと、PHPの実行コンテキスト(コールスタック、変数、実行位置)をオブジェクトとして独立させることができます。

1. `Fiber::suspend()` が呼ばれると、Zend VMはその時点の実行状態をヒープ上に退避し、制御を呼び出し元(イベントループなど)に返します。
2. 外部イベント(ソケットの読み込み準備完了など)が検知されると、イベントループが `Fiber::resume()` を呼び出します。
3. Zend VMは退避していたスタックを復元し、中断した行の続きから実行を再開します。

OSのスレッドを切り替える際の高コストなコンテキストスイッチ(カーネルモードへの移行など)が一切発生しないため、数千、数万のFiberをメモリの許す限り軽量に並行実行できるのが、この仕組みの圧倒的な強みです。

—

2. 実践:Fiberとイベントループによる非同期APIクライアント

言葉だけではイメージしにくいと思いますので、実際に「タイムアウト」と「指数バックオフ(Exponential Backoff)によるリトライ」を備えた非同期HTTPクライアントのコアエンジンを実装してみましょう。

ここでは、外部ライブラリに頼らず、PHP標準の `stream_socket_client` と `stream_select` を用いたミニマムなイベントループとFiberを組み合わせます。

  • 非同期HTTPリクエストを表すタスク(Fiberベース)
  • /
    class AsyncHttpTask
    {
    private Fiber $fiber;
    private string $host;
    private string $path;
    private int $port;
    private int $maxRetries;
    private int $retryCount = 0;
    private float $timeout;
    private $stream = null;
    private string $buffer = ”;
    private ?string $result = null;
    private ?\Exception $exception = null;

    public function __construct(string $url, float $timeout = 2.0, int $maxRetries = 3)
    {
    $parsed = parse_url($url);
    $this->host = $parsed[‘host’];
    $this->path = $parsed[‘path’] ?? ‘/’;
    $this->port = $parsed[‘port’] ?? ($parsed[‘scheme’] === ‘https’ ? 443 : 80);
    $this->timeout = $timeout;
    $this->maxRetries = $maxRetries;

    // Fiberの内部でネットワークI/Oの非同期ブロッキングをカプセル化する
    $this->fiber = new Fiber(function () {
    while ($this->retryCount <= $this->maxRetries) {
    try {
    $this->connectAndRequest();
    return; // 成功したら終了
    } catch (\Throwable $e) {
    $this->retryCount++;
    if ($this->retryCount > $this->maxRetries) {
    $this->exception = $e;
    return;
    }

    // 指数バックオフの計算: 2^retryCount 100ms (例: 200ms, 400ms, 800ms…)
    $backoffMs = (2 $this->retryCount) 100;
    // Fiber内で一時停止し、イベントループに制御を返す(スリープ中の非同期待機)
    $this->sleepAsync($backoffMs / 1000.0);
    }
    }
    });
    }

    private function connectAndRequest(): void
    {
    $errno = 0;
    $errstr = ”;

    // ノンブロッキングモードでソケットを作成
    $remote = “tcp://{$this->host}:{$this->port}”;
    $this->stream = stream_socket_client($remote, $errno, $errstr, $this->timeout, STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT);

    if (!$this->stream) {
    throw new \RuntimeException(“Connection failed: {$errstr}”);
    }

    stream_set_blocking($this->stream, false);

    // 書き込み可能になるまで(接続完了まで)Fiberをサスペンドして待つ
    $this->awaitStream(true);

    // HTTPリクエストの送信
    $out = “GET {$this->path} HTTP/1.1\r\n”;
    $out .= “Host: {$this->host}\r\n”;
    $out .= “Connection: close\r\n\r\n”;
    fwrite($this->stream, $out);

    // レスポンス読み込みの準備
    $this->buffer = ”;
    while (!feof($this->stream)) {
    $this->awaitStream(false); // 読み込み可能になるまで待つ
    $chunk = fread($this->stream, 8192);
    if ($chunk === false) {
    break;
    }
    $this->buffer .= $chunk;
    }

    fclose($this->stream);
    $this->result = $this->buffer;
    }

    /

    • ストリームの状態変化を検知してFiberをサスペンドする肝の部分

    /
    private function awaitStream(bool $isWriting): void
    {
    $read = $isWriting ? null : [$this->stream];
    $write = $isWriting ? [$this->stream] : null;
    $except = null;

    // イベントループの代わりとして、このタスクのソケットを監視対象に提供する
    // 実際のプロダクションでは Event 拡張や ReactPHP などのイベントループに登録する
    while (true) {
    $r = $read;
    $w = $write;
    // stream_selectでイベントをポーリング (タイムアウトは0.01秒刻みでノンブロックに近く扱う)
    $selected = @stream_select($r, $w, $except, 0, 10000);

    if ($selected === false) {
    throw new \RuntimeException(“Stream select error.”);
    }

    if ($selected > 0) {
    // 準備完了したので、ループを抜けてFiberの処理を進める
    break;
    }

    // まだ準備できていないので、一度Fiberの実行権を手放す(イベントループへ制御を戻す)
    Fiber::suspend();
    }
    }

    private function sleepAsync(float $seconds): void
    {
    $start = microtime(true);
    while ((microtime(true) – $start) < $seconds) { Fiber::suspend(); // 指定時間が経過するまで親へ制御を返し続ける } } public function start(): void { $this->fiber->start();
    }

    public function resume(): void
    {
    if (!$this->fiber->isTerminated()) {
    $this->fiber->resume();
    }
    }

    public function isFinished(): bool
    {
    return $this->fiber->isTerminated();
    }

    public function getResult(): ?string
    {
    if ($this->exception) {
    throw $this->exception;
    }
    return $this->result;
    }
    }

    —

    3. イベントループ(スケジューラー)による複数タスクの調停

    上記のタスクを同時にいくつも走らせ、全体を統括するのが「イベントループ(スケジューラー)」の役割です。

    class AsyncScheduler
    {
    / @var AsyncHttpTask[] /
    private array $tasks = [];

    public function addTask(AsyncHttpTask $task): void
    {
    $this->tasks[] = $task;
    // 初回起動
    $task->start();
    }

    public function run(): void
    {
    // すべてのタスクが完了するまでループを回す
    while (!empty($this->tasks)) {
    foreach ($this->tasks as $index => $task) {
    if ($task->isFinished()) {
    // 終了したタスクは監視対象から外す
    unset($this->tasks[$index]);
    continue;
    }

    // Fiberが suspend() した場所から再開を試みる
    try {
    $task->resume();
    } else {
    // 例外処理など
    }
    }
    // CPUを完全に占有しないように少しだけマイクロ秒単位でCPUを譲る
    usleep(1000);
    }

    $this->tasks = array_values($this->tasks);
    }
    }

    実際の利用イメージ

    これらを組み合わせて、3つの異なる外部APIへ同時にリクエストを飛ばしてみましょう。

    // スケジューラーの初期化
    $scheduler = new AsyncScheduler();

    // タスクの登録(タイムアウト2秒、最大リトライ3回)
    $scheduler->addTask(new AsyncHttpTask(‘https://api.example.com/v1/users’, 2.0, 3));
    $scheduler->addTask(new AsyncHttpTask(‘https://api.example.com/v1/orders’, 2.0, 3));
    $scheduler->addTask(new AsyncHttpTask(‘https://api.example.com/v1/payments’, 2.0, 3));

    $startTime = microtime(true);

    // 並行実行の開始!
    $scheduler->run();

    $elapsed = microtime(true) – $startTime;
    echo “すべてのリクエストが並行処理で完了しました。所要時間: {$elapsed} 秒\n”;

    もしこれらを同期的(従来の順次処理)に実行した場合、それぞれのAPI応答が `200ms + 150ms + 300ms` だとしたら単純合計で `650ms` かかります。しかし、Fiberによる非同期並行処理であれば、最も遅いリクエストの完了時間(約 `300ms` 前後)に収束します。さらに、途中で一時的なネットワークエラーやタイムアウトが発生しても、他のAPIの処理を止めずに裏で指数バックオフによるリトライが自律的に行われます。

    —

    4. エンタープライズ開発における知見と注意点

    ここまで読まれた賢明なあなたならお気づきかもしれませんが、実務の現場でFiberや非同期処理を導入する際には、いくつかの重要な設計上の注意点があります。

    1. サードパーティ製ライブラリのブロッキング問題

    PHPの歴史の長さを考えると、世の中にある多くのデータベースドライバ(古いPDO設定など)やHTTPクライアント(一部のGuzzle設定など)は、依然として「完全なブロッキングI/O」を前提として作られています。
    ブロッキング関数をFiberの内部で直接呼んでしまうと、その瞬間からZend VMの実行スレッド自体が止まってしまい、イベントループ全体がフリーズします。必ず ノンブロッキング対応のストリーム操作 や、Fiberに対応したドライバ(AmpやReactPHPエコシステム、あるいはSwoole/OpenSwooleのフック機能)を選ぶ必要があります。

    2. メモリ管理(HashTableの肥大化に注意)

    Fiberは非常に軽量ですが、実行コンテキストごとにコールスタックのフレーム情報を保持するため、数万・数十万という単位で雑に生成すると、PHPプロセスのメモリ(Zend Memory Manager)を圧迫します。並行処理の同時実行数(Concurreny Limit)には適切にセマフォやプール機構を設け、バッチ処理として区切る配慮がプロフェッショナルには求められます。

    —

    まとめ

    PHPにおけるFiberの登場は、私たちに「同期的な書きやすさ」と「非同期の圧倒的なパフォーマンス」の二者択一を迫るものではなくなったことを意味します。

    見慣れた上から下の制御フローの美しさを保ったまま、裏側ではZend VMがスタックを優雅に退避・復元し、ネットワークの待ち時間を極限まで効率化する。この低レイヤのメカニズムを頭に入れた上でコードを書くことで、あなたの書くPHPアプリケーションは、高負荷なエンタープライズ環境であっても、涼しい顔をして軽快に動作し続けるはずです。

    「PHPの裏側、なかなか美しいでしょう?」

    ぜひ、次回のアーキテクチャ設計の引き出しに、このFiberによる非同期並行処理を加えてみてください。あなたの開発ライフがより一層エキサイティングなものになることを応援しています。

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