【実務・中級編】Fiberを用いた高頻度ポーリング処理の最適化とCPU使用率の抑制 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

序章:CPUを焼き尽くす「ビジーウェイト」の悪夢

コードレビューをしていて、最も背筋が凍る瞬間の一つが、外部APIの非同期完了やタスクのキュー消化を待つループ処理で、何も考えずに `usleep(1000)` や `while(true)` を回しているコードを見たときだ。

// 【絶対にやってはいけないアンチパターン】
while ($task->isNotFinished()) {
usleep(100); // 1ミリ秒のウェイト
$task->refresh();
}

このコードが本番環境のPHP-FPMやCLIワーカーで走った瞬間何が起きるか。ZendエンジンはC10K問題どころか、数個のプロセスがこのループに入っただけでCPU使用率を100%に張り付かせる。なぜなら、OSのスケジューラとユーザーランドのPHPスクリプトの間で無駄なコンテキストスイッチとシステムコール(`nanosleep`)が無限に発生し、Zend VMの実行サイクルが文字通り空回りするからだ。

特に高頻度ポーリングにおいて、CPUリソースの浪費はインフラコストの直撃にとどまらず、同一サーバー上で稼働する他のWebリクエストのレイテンシを致命的に悪化させる。

PHP 8.1で導入された Fiber(ファイバー) は、この「CPUの浪費」という呪縛から私たちを解放する強力な武器だ。今回は、単に「Fiberが使える」というレベルを超え、Zend VMのコールスタックの挙動とイベントループを完全に調停し、CPU使用率を極限まで抑え込んだ「高頻度ポーリング処理の最適化設計」を伝授する。

—

1. Fiberの内部挙動:なぜFiberは効率的なのか

Zend VMにおいて、通常の関数呼び出しはコールスタック(Call Stack)上にフレームを積み上げていく。しかし、一度関数が深くなると、その実行状態を途中でサスペンドして外側のコンテキストに戻すことは、従来のPHPでは例外機構を除いて不可能だった。

Fiberは、PHPのランタイム上に「ユーザーランドの独立したコールスタック」を構築する仕組みである。

  • `Fiber::suspend()` が呼ばれると、現在のZend VMの実行コンテキスト(スタックフレーム、ローカル変数、シンボルテーブルの参照など)がヒープ上に退避され、制御権が呼び出し元(イベントループ側)に完全に戻る。
  • `Fiber::resume()` が呼ばれると、退避されたコンテキストが復元され、サスペンドした行の直後から実行が再開される。

この特性を利用すると、「条件が満たされていない間は自分自身をサスペンドし、イベントループ側にCPUの制御権を明け渡す」という協調的マルチタスキング(Cooperative Multitasking)をPHP上で実現できる。ビジーウェイトのようにCPUコアを占有し続けることがなくなるため、ポーリング間隔を極限まで詰めてもCPU使用率をほぼゼロに維持できるのだ。

—

2. 実装設計:ノンブロッキング・イベントループとの統合

高頻度ポーリングを真に効率化するためには、Fiber単体では不十分だ。OSのI/O多重化(`stream_select` や `ext-ev` / `ext-uv` 等)と組み合わせ、「真に待つべき時だけイベントループがスリープする」 構造を作らなければならない。

今回は外部依存(PECL拡張)を排し、純粋なPHPのコア機能とFiberを組み合わせた、実務でそのまま使える堅牢な「非同期ポーリング・オーケストレータ」を実装する。

実務仕様を満たすポーリング・エンジンコード

  • 高度な非同期ポーリングを制御するタスクオーケストレータ
  • /
    class PollingScheduler
    {
    / @var \SplQueue /
    private \SplQueue $queue;
    private bool $isRunning = false;

    public function __construct()
    {
    $this->queue = new \SplQueue();
    }

    /

    • ポーリングタスクをスケジューラーに登録する
    • @param Closure(): bool $isReady 完了条件判定クロージャ
    • @param Closure $onResolve 成功時のコールバック
    • @param int $intervalMs ポーリング間隔(ミリ秒)

    /
    public function addTask(Closure $isReady, Closure $onResolve, int $intervalMs = 50): void
    {
    $fiber = new Fiber(function () use ($isReady, $intervalMs) {
    while (true) {
    // 条件判定を実行
    try {
    if ($isReady()) {
    return true; // タスク完了
    }
    } catch (Throwable $e) {
    // 例外発生時は上位に伝播
    throw $e;
    }

    // 条件未達の場合、指定ミリ秒分だけ実行権を放棄(サスペンド)
    // ここでイベントループ側へ処理が戻る
    Fiber::suspend($intervalMs);
    }
    });

    $this->queue->enqueue([
    ‘fiber’ => $fiber,
    ‘intervalMs’ => $intervalMs,
    ‘onResolve’ => $onResolve,
    ]);
    }

    /

    • イベントループを回し、全タスクの完了を調停する

    /
    public function run(): void
    {
    $this->isRunning = true;

    // 各ファイバーの次回実行可能タイムスタンプ(マイクロ秒)を管理
    $waitingFibers = [];

    foreach ($this->queue as $task) {
    / @var Fiber $fiber /
    $fiber = $task[‘fiber’];
    try {
    // 初回起動
    $suspendValue = $fiber->start();
    if (!$fiber->isTerminated()) {
    $waitingFibers[] = [
    ‘task’ => $task,
    ‘resumeAt’ => microtime(true) + (($suspendValue ?? 50) / 1000),
    ];
    } else {
    ($task[‘onResolve’])();
    }
    } catch (Throwable $e) {
    // エラーハンドリング
    $this->handleError($e);
    }
    }

    // イベントループ本体
    while (!empty($waitingFibers) && $this->isRunning) {
    $now = microtime(true);
    $nextMinSleep = 0.05; // デフォルト50ms

    foreach ($waitingFibers as $index => &$waiting) {
    if ($now >= $waiting[‘resumeAt’]) {
    $fiber = $waiting[‘task’][‘fiber’];
    try {
    // ファイバーを再開
    $suspendValue = $fiber->resume();

    if ($fiber->isTerminated()) {
    // タスク完了
    ($waiting[‘task’][‘onResolve’])();
    unset($waitingFibers[$index]);
    } else {
    // 次回のウェイト時間を更新
    $interval = is_numeric($suspendValue) ? (int)$suspendValue : 50;
    $waiting[‘resumeAt’] = microtime(true) + ($interval / 1000);
    }
    } catch (Throwable $e) {
    $this->handleError($e);
    unset($waitingFibers[$index]);
    }
    } else {
    // 最も早く来る次回のウェイト時間を算出
    $diff = $waiting[‘resumeAt’] – $now;
    if ($diff < $nextMinSleep) { $nextMinSleep = max(0.001, $diff); } } } unset($waiting); // インデックスの再整理 $waitingFibers = array_values($waitingFibers); if (!empty($waitingFibers)) { // CPUを焼き尽くさないための正確なマイクロ秒スリープ // OSのコンテキストスイッチコストを最小限に抑えつつイベントを待ち受ける usleep((int)($nextMinSleep 1_000_000)); } } } private function handleError(Throwable $e): void { // 実務ではここでロガーに出力する error_log("[PollingScheduler Error] " . $e->getMessage());
    }
    }

    —

    3. この設計が実務において圧倒的に強靭である理由

    上記のコードは、単にFiberを使っているだけではない。プロフェッショナルなアーキテクチャとしての要件をいくつも満たしている。

    A. 動的なウェイト調整(Adaptive Polling)

    従来の固定スリープは「常に一定の間隔」で負荷をかけるが、このスケジューラはタスクの性質に応じて `Fiber::suspend($intervalMs)` を経由して動的にインターバルを変更できる。例えば、最初は100msおきにポーリングし、特定の兆候が見えたら10msに高頻度化するといった制御が、コードの改修なしにクロージャの返り値だけで実現できる。

    B. メモリリーク(Garbage Collection)の回避

    Fiber内部で巨大なクロージャや外部変数をキャプチャし続けると、Zendエンジンのメモリ管理(Zend Memory Manager)に多大なプレッシャーがかかる。
    上記の設計では、`Fiber::isTerminated()` が真になった瞬間にタスクが配列からパージされ、変数スコープが破棄されるため、長期間稼働するデーモンプロセスであってもメモリリークが起きない構造になっている。

    C. 例外の安全なカプセル化

    非同期処理やマルチタスクにおいて、ひとつのサブタスクで未キャッチの例外(`Throwable`)が発生したためにプロセス全体がクラッシュするのは絶対に避けなければならない。このスケジューラーでは `try-catch` で各Fiberのライフサイクルを完全に保護し、問題のあるタスクのみを安全に切り捨てるフォールトトレラントな設計にしている。

    —

    4. 実践:高頻度ポーリングの適用例(外部APIステータス監視)

    実際にこのスケジューラーをどう使うのか。非同期処理を模した擬似的なステータス確認タスクを複数走らせてみよう。

    start();

    // タスク1:State Aが100に達するのを監視(高頻度:20msおき)
    $scheduler->addTask(
    isReady: function () use (&$externalStateA) {
    return $externalStateA >= 100;
    },
    onResolve: function () {
    echo “–> 【タスク1完了】外部リソース A が目標値に到達しました。\n”;
    },
    intervalMs: 20
    );

    // タスク2:State Bが100に達するのを監視(通常頻度:50msおき)
    $scheduler->addTask(
    isReady: function () use (&$externalStateB) {
    return $externalStateB >= 100;
    },
    onResolve: function () {
    echo “–> 【タスク2完了】外部リソース B が目標値に到達しました。\n”;
    },
    intervalMs: 50
    );

    // スケジューラー起動(全タスクが完了するまでブロック)
    echo “=== ポーリング・セッション開始 ===\n”;
    $start = microtime(true);

    // シミュレータもイベントループ内で一緒に進めるためのラップ
    $scheduler->addTask(
    isReady: function () use ($backgroundSimulator) {
    if (!$backgroundSimulator->isTerminated()) {
    $backgroundSimulator->resume();
    }
    return $backgroundSimulator->isTerminated();
    },
    onResolve: function () {
    echo “–> 【シミュレータ終了】\n”;
    },
    intervalMs: 50
    );

    $scheduler->run();

    $end = microtime(true);
    printf(“=== すべての処理が完了しました (実行時間: %.4f 秒) ===\n”, $end – $start);

    実行結果のイメージ

    === ポーリング・セッション開始 ===
    [Simulator] 状態進行: A=20, B=25
    [Simulator] 状態進行: A=40, B=50
    [Simulator] 状態進行: A=60, B=75
    [Simulator] 状態進行: A=80, B=100
    –> 【タスク2完了】外部リソース B が目標値に到達しました。
    [Simulator] 状態進行: A=100, B=125
    –> 【タスク1完了】外部リソース A が目標値に到達しました。
    –> 【シミュレータ終了】
    === すべての処理が完了しました (実行時間: 1.0245 秒) ===

    このコードを実行すると、複数の異なるポーリング間隔を持つ処理が、CPUに無駄な負荷を一切かけることなく、見事に調停されて並行処理されるのが確認できるはずだ。

    —

    結び:PHPはもはや「単なるリクエスト・レスポンスのスクリプト言語」ではない

    PHP 8以降、JITコンパイラの導入、そしてFiberによる非同期プリミティブの獲得により、PHPのパフォーマンスと表現力は劇的な進化を遂げた。

    「PHPでポーリングや非同期処理を書くべきではない。PythonやGoに任せるべきだ」というのは、古い時代の常識になりつつある。Zend VMのメモリ管理とスタック退避の仕組みを正しく理解し、Fiberとイベントループを適切に設計に組み込めば、PHPは極めて堅牢で高効率なバックエンド・ワーーカーとしてもその真価を発揮する。

    コードレビューの現場で「なぜそのウェイトが必要なのか」「CPUを無駄に回していないか」を論理的に見抜き、美しく洗練された非同期アーキテクチャを組むこと。それこそが、現代のPHPシニアエンジニアに求められる極上のスキルなのだ。

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