ユーザー体験を一切殺さず、裏で重い処理を回す:Fiberによるバックグラウンドタスク制御の極意
コードレビューをしていて最も絶望的な瞬間の一つは、HTTPリクエストのライフサイクルの中で、外部APIへの同期リクエストや、巨大なキャッシュのパージ、Elasticsearchのインデックス再構築を平然と同期実行しているコードを見たときだ。
「ユーザーがページを開いた瞬間に、CDNのパージAPIを叩いて詰まっています」
「バッチ処理ほどでもないけれど、数秒かかるインプレッション集計とキャッシュ無効化をリクエストのついでにやったらタイムアウトしました」
――よくある話だ。そして大抵の開発者は「じゃあQueue(キュー)に突っ込みましょう」と返す。正解だ。だが、小規模なシステムや、インフラのオーバースペックを避けたいモダンなAPIサーバーにおいて、RedisやRabbitMQといった外部ワーカー層を挟むことが常に最適解とは限らない。
PHP 8.1で導入された Fiber(ファイバー) は、シングルスレッドの制約を抱えたPHPの実行モデルにおいて、協力型マルチタスク(Cooperative Multitasking)を完全に制御するための劇薬である。今回は、このFiberを駆使し、ユーザーリクエストの応答性を1ミリ秒たりとも犠牲にすることなく、CDNパージやキャッシュ無効化などのバックグラウンドタスクを優先度制御付きで非同期実行する設計論を、Zend VMの挙動まで見据えながら解き明かしていく。
—
なぜ「Fiber」なのか?:Zend VMのコールスタックと非同期の幻想
まず大前提として理解しておかなければならないのは、PHPのFiberは「プリエンプティブ(占有型)なスレッド」ではないということだ。OSのスレッドのように、CPUが勝手にコンテキストスイッチをしてくれるわけではない。Fiberはあくまで「コールスタックの切り替えをユーザーランド(PHPコード側)で明示的に制御する機構」に過ぎない。
Zend VMの内部において、通常の関数呼び出しは `zend_execute_ex` ポインタのスタック積算によって行われる。Fiberを使用すると、このコールスタック(`zend_fiber_context`)がヒープ上に独立して確保される。つまり、メモリ空間を適切に管理さえすれば、処理を途中でサスペンド(一時停止)し、別のタスクに実行権を譲り、任意のタイミングで元の位置からレジューム(再開)させることが可能になる。
これをWebのライフサイクルにどう応用するか?
答えはシンプルだ。「レスポンスの送信(`fastcgi_finish_request()` 等)」を境界線とし、ユーザーへの返却後に残ったバックグラウンドタスクのFiberをイベントループで回すのである。
—
実装:プライオリティ制御付きFiberマネージャー
それでは、実務でそのまま投入できるプロダクションクオリティのコードを示そう。ここでは、CDNパージやキャッシュクリアといったタスクを「高・中・低」の優先度に分類し、CPUやI/Oを枯渇させずに協調動作させるスケジューラを構築する。
/
readonly class Task
{
public function __construct(
public string $name,
public Closure $callback,
public int $priority = self::PRIORITY_NORMAL
) {}
public const int PRIORITY_HIGH = 100; // CDNパージ、即時キャッシュ無効化
public const int PRIORITY_NORMAL = 50; // ログ集計、メタデータ更新
public const int PRIORITY_LOW = 10; // 非同期アナリティクス送信
}
/
- 協調型バックグラウンドタスク・スケジューラ
/
class FiberScheduler
{
private SplPriorityQueue $queue;
private int $activeFibers = 0;
public function __construct(private readonly int $maxConcurrent = 3)
{
// SplPriorityQueueはデフォルトで最大値が上に来る(PHPの仕様に注意)
$this->queue = new SplPriorityQueue();
}
public function addTask(Task $task): void
{
// 優先度をキーとしてタスクをエンキュー
$this->queue->insert($task, $task->priority);
}
public function run(): void
{
$fibers = [];
// キューからタスクを取り出し、Fiberインスタンスにラップする
while (!$this->queue->isEmpty() && count($fibers) < $this->maxConcurrent) {
/ @var Task $task /
$task = $this->queue->extract();
$fibers[] = $this->createFiber($task);
}
// イベントループのイミテーション(協調マルチタスクの実行)
while (!empty($fibers)) {
foreach ($fibers as $index => $fiber) {
try {
if (!$fiber->isStarted()) {
$fiber->start();
} elseif (!$fiber->isTerminated()) {
// サスペンド状態から再開
$fiber->resume();
}
// ファイバーが終了またはサスペンドしたら次の処理へ
if ($fiber->isTerminated()) {
unset($fibers[$index]);
// まだキューにタスクが残っていれば新しいFiberを補充
if (!$this->queue->isEmpty()) {
/ @var Task $nextTask /
$nextTask = $this->queue->extract();
$fibers[] = $this->createFiber($nextTask);
}
}
} catch (Throwable $e) {
// 本番環境ではLoggerへ流すこと
error_log(sprintf(“[Fiber Error] Task failed: %s”, $e->getMessage()));
unset($fibers[$index]);
}
}
// CPUのスパイクを防ぐためのマイクロ休止(I/O待ちのシミュレーション)
usleep(1000);
$fibers = array_values($fibers);
}
}
private function createFiber(Task $task): Fiber
{
return new Fiber(function () use ($task) {
echo “[-] Task Start: {$task->name} (Priority: {$task->priority})\n”;
// タスクの実処理を実行。
// 処理の中で非同期性を保つため、必要に応じて Fiber::suspend() を挟むことも可能
($$task->callback)();
echo “[+] Task Completed: {$task->name}\n”;
});
}
}
使用例:コントローラーやPSR-15ミドルウェアからの呼び出し
このスケジューラをWebリクエストのライフサイクルに組み込む場合のイディオムを見てほしい。
// — 実行スクリプトのシミュレーション —
$scheduler = new FiberScheduler(maxConcurrent: 2);
// 1. CDNパージ(高優先度)
$scheduler->addTask(new Task(
name: ‘CDN_Purge_TopPage’,
callback: function() {
// 実際のHTTPクライアントでCDNへDELETE/PURGEリクエストを飛ばす想定
usleep(500000); // 0.5秒のI/O待ちを模倣
echo ” -> CDN Cache Purged for /index.html\n”;
},
priority: Task::PRIORITY_HIGH
));
// 2. 検索インデックスの非同期更新(中優先度)
$scheduler->addTask(new Task(
name: ‘Search_Index_Update’,
callback: function() {
usleep(800000); // 0.8秒の処理
echo ” -> Elasticsearch Document Updated\n”;
},
priority: Task::PRIORITY_NORMAL
));
// 3. アクセスログの解析用ダンプ(低優先度)
$scheduler->addTask(new Task(
name: ‘Analytics_Dump’,
callback: function() {
usleep(200000); // 0.2秒の処理
echo ” -> Analytics payload sent\n”;
},
priority: Task::PRIORITY_LOW
));
// 【重要】ユーザーにはすでにレスポンスを返却したという前提
echo “=== HTTP Response sent to client (FastCGI finished) ===\n”;
// バックグラウンドでFiber群を駆動
$scheduler->run();
—
アーキテクトが教える:実務で踏みがちな「3つの地雷」
このコードおよびFiberを用いた非同期処理を導入するにあたり、コードレビューで絶対にチェックすべきポイントを挙げておく。
1. データベースコネクションの共有(Connection Leak / State Pollution)
PHP-FPM環境において、同一プロセス内で複数のFiberを並行・非同期実行する場合、PDOなどのデータベース接続インスタンスをそのまま共有してはならない。
あるFiberがトランザクションの途中で `Fiber::suspend()` を起こし、別のFiberが同じPDOインスタンスで別のクエリを発行すると、ドライバのステートが完全に破壊され、予期せぬデータ破損や `MySQL server has gone away` エラーを引き起こす。
- 対策: バックグラウンドタスク内でDBや外部リソースにアクセスする場合は、タスクごとに独立したコネクション(あるいはコネクションプールからのクローン)を強制すること。
2. メモリリークとGC(ガベージコレクション)の罠
Fiberのコールスタックはヒープ上にアロケートされるため、タスク内で巨大な配列やオブジェクトへの参照をクロージャ内に閉じ込めたまま終了し忘れると、スケジューラがそれを保持し続け、FPMプールのメモリ使用量が右肩上がりになる。
- 対策: タスク完了後は速やかに参照を切断し、必要に応じて `gc_collect_cycles()` を適切な粒度でコールする設計を入れること。
3. 無限ループとタイムアウトの制御
イベントループを自前で実装する場合、外部APIの応答遅延やデッドロックによって `while (!empty($fibers))` が無限ループに陥るリスクがある。PHP-FPMの `max_execution_time` は通常、`fastcgi_finish_request()` の呼び出しによってタイマーがリセットされるか、あるいは環境によってはそのまま働き続ける。
- 対策: スケジューラ側で絶対的なタイムアウト時間(例: 最大3秒まで)を設け、それを超過したFiberは強制的に例外をスローしてkillするガードロジックを必ず実装すること。
—
結びにかえて
Fiberは魔法の杖ではない。マルチスレッドのような並列処理(Parallel Processing)ではなく、あくまでI/O待ちの時間を有効活用するための並行処理(Concurrent Processing)のツールだ。
しかし、この「ユーザーリクエストの高速化」と「バックグラウンド処理の協調実行」の境界線を美しくデザインできるか否かで、あなたの構築するWebシステムのスケーラビリティは根本から変わる。外部の重厚なキューイングシステムを導入する前のファーストチョイスとして、このFiberによるプライオリティ制御付きタスクランナーを、ぜひ次のアーキテクチャに組み込んでみてほしい。Zend VMの息づかいを感じるような、ソリッドで無駄のないパフォーマンスが手に入るはずだ。