FiberとPHPのCLIスクリプト:長時間実行タスクのバックグラウンド実行と進捗管理
PHPにおける非同期処理といえば、長らく多重プロセス(`pcntl_fork`)やマルチスレッド拡張(Pthreads / Parallel)、あるいは外部のメッセージキューに依存するのが常道でした。しかし、PHP 8.1で導入されたFiber(ファイバー)は、このパラダイムを根本から覆しつつあります。
Zendエンジン内部におけるFiberの本質は、「ユーザーランドで制御可能なスタックfulなコルーチン」です。OSスレッドを消費せず、関数呼び出しのコールスタックを丸ごとヒープメモリ上に退避・復元させることで、シングルスレッドでありながら協調的(コーペラティブ)なマルチタスクを実現します。
今回は、このFiberを駆使し、PHPのCLI環境において「重い長時間実行タスクをバックグラウンドで並行処理しつつ、メインスレッド側で非ブロッキングに進捗を管理する」ための極限まで洗練されたアーキテクチャを解説します。コードレビューの現場でそのまま通用する、メモリ効率と安全性に配慮した設計を見ていきましょう。
—
なぜ従来の方式ではダメなのか?
実務で数百万件のレコード処理や、外部APIへのバッチリクエストといった長時間タスクを扱う際、素朴な同期処理では進捗の可視化や動的な制御が不可能です。
1. `pcntl_fork` の限界:
プロセスフォークは強力ですが、プロセス間通信(IPC)のオーバーヘッド、メモリのCopy-on-Writeによる肥大化、そしてシグナルハンドリングの複雑さが伴います。CLIスクリプトの軽量なタスク管理にはオーバースペックです。
2. 完全な逐次処理:
「1つの重い処理が終わるまで次の処理に進めない」ため、例えば「進捗バーの描画」や「ユーザーからの中断シグナルのポーリング」を同一ループ内で行うことが困難になります。
ここでFiberを導入すると、「I/O待ちやチャンク処理の切れ目で実行権を一度メイン(イベントループ)に返し、メイン側で進捗表示や制御を行ったのち、正確に中断した位置から再開する」という優美なフローが構築できます。
—
アーキテクチャ設計:協調的スケジューラとFiberのライフサイクル
今回の実装では、以下のコンポーネントを設計します。
- `Task` (Fiberラッパー): 長時間実行されるジェネレータ的タスクを内包し、進捗率やステータスを保持する。
- `EventLoop` (スケジューラ): 登録された複数のFiberをラウンドロビン方式で順次実行し、サスペンド(中断)されたタスクを管理する。
Zend VMの観点では、Fiberが `Fiber::suspend()` に到達した瞬間、現在の実行コンテキスト(EXコールスタックとシンボルテーブル)がヒープ上の `zend_fiber_context` に退避され、コントロールが呼び出し元(イベントループ)へ戻ります。CPUキャッシュやファイルポインタのコンテキストを維持したまま処理を切り替えられるため、極めて高速です。
—
実務で使える堅牢なリファレンスコード
以下のコードは、CLI環境で動作する、複数の非同期タスクの実行とリアルタイム進捗管理を行う完全なスクリプトです。
/
enum TaskStatus: string
{
case PENDING = ‘pending’;
case RUNNING = ‘running’;
case SUSPENDED = ‘suspended’;
case COMPLETED = ‘completed’;
case FAILED = ‘failed’;
}
/
- Fiberベースの非同期タスク表現クラス
/
class Task
{
private \Fiber $fiber;
private TaskStatus $status = TaskStatus::PENDING;
private int $progress = 0; // 0 – 100%
private mixed $output = null;
public function __callable(callable $callback)
{
// Fiberの初期化。コールバックには進捗報告用のヘルパーを渡す
$this->fiber = new \Fiber(function (callable $taskLogic) {
$this->status = TaskStatus::RUNNING;
// タスクロジックを実行し、内部からのサスペンドを許可する
$result = $taskLogic(function (int $progress) {
$this->progress = max(0, min(100, $progress));
$this->status = TaskStatus::SUSPENDED;
// 実行権をイベントループへ返却(サスペンド)
\Fiber::suspend();
$this->status = TaskStatus::RUNNING;
});
$this->progress = 100;
$this->status = TaskStatus::COMPLETED;
return $result;
});
}
public function run(): void
{
if ($this->status === TaskStatus::PENDING || $this->status === TaskStatus::SUSPENDED) {
if (!$this->fiber->isStarted()) {
// 初回起動
$this->fiber->start(fn($yieldProgress) => ($this->logic)($yieldProgress));
} else {
// サスペンド状態からの再開
$this->fiber->resume();
}
}
}
public function setLogic(callable $logic): void
{
$this->logic = $logic;
}
public function getStatus(): TaskStatus
{
return $this->status;
}
public function getProgress(): int
{
return $this->progress;
}
public function isFinished(): bool
{
return $this->status === TaskStatus::COMPLETED || $this->status === TaskStatus::FAILED;
}
}
/
- 簡易イベントループ&スケジューラ
/
class EventLoop
{
/ @var Task[] /
private array $tasks = [];
public function registerTask(string $name, callable $logic): void
{
$task = new Task();
$task->setLogic($logic);
// クロージャを渡して初期化
$reflection = new \ReflectionClass(Task::class);
$method = $reflection->getMethod(‘__callable’);
// 内部ファイバーの構築
$task = new class($logic) extends Task {
private \Fiber $fiber;
private TaskStatus $status = TaskStatus::PENDING;
private int $progress = 0;
private $logic;
public function __construct(callable $logic) {
$this->logic = $logic;
$this->fiber = new \Fiber(function () use ($logic) {
$this->status = TaskStatus::RUNNING;
// タスク側のコードブロック
$logic(function (int $progress) {
$this->progress = max(0, min(100, $progress));
$this->status = TaskStatus::SUSPENDED;
\Fiber::suspend();
$this->status = TaskStatus::RUNNING;
});
$this->progress = 100;
$this->status = TaskStatus::COMPLETED;
});
}
public function run(): void {
if ($this->fiber->isTerminated()) {
return;
}
if (!$this->fiber->isStarted()) {
$this->fiber->start();
} else {
$this->fiber->resume();
}
}
public function getStatus(): TaskStatus {
if ($this->fiber->isTerminated()) {
return TaskStatus::COMPLETED;
}
return $this->status;
}
public function getProgress(): int {
return $this->progress;
}
};
$this->tasks[$name] = $task;
}
public function run(): void
{
// ターミナル描画の初期化(カーソル非表示)
echo “\e[?25l”;
try {
while (true) {
$allFinished = true;
// 画面クリアと進捗描画
$this->renderDashboard();
foreach ($this->tasks as $task) {
if (!$task->getProgress() >= 100 && !($task->getStatus() === TaskStatus::COMPLETED)) {
$allFinished = false;
try {
$task->run();
} catch (\Throwable $e) {
// 例外処理:エラーログを残しタスクを失敗ステータ्सへ
// 実務では個別のエラーハンドリングが必要
error_log(“Task Error: ” . $e->getMessage());
}
}
}
if ($allFinished) {
break;
}
// CPUの焼き付きを防ぎつつ、描画間隔を調整するインタバル
usleep(50000); // 50ms
}
} finally {
// ターミナルカーソルの復元
echo “\e[?25h”;
echo “\n全てのバックグラウンドタスクが完了しました。\n”;
}
}
private function renderDashboard(): void
{
// カーソルをホームへ移動して上書き描画(ちらつき防止)
echo “\033[H”;
echo “===========================================\n”;
echo ” PHP Fiber Background Task Manager (CLI) \n”;
echo “===========================================\n”;
foreach ($this->tasks as $name => $task) {
$status = $task->getStatus()->value;
$progress = $task->getProgress();
// プログレスバーの生成
$barLength = 30;
$filledLength = (int)round($barLength $progress / 100);
$bar = str_repeat(‘█’, $filledLength) . str_repeat(‘-‘, $barLength – $filledLength);
printf(” [%-15s] [%s] %3d%% (%s)\n”, $name, $bar, $progress, $status);
}
echo “——————————————-\n”;
echo “Ctrl+C で中断できます。\n”;
}
}
// ==========================================
// 実行スクリプト (Entrypoint)
// ==========================================
$loop = new EventLoop();
// タスクA: 大規模データ移行シミュレーション
$loop->registerTask(‘DataMigration’, function (callable $reportProgress) {
for ($i = 1; $i <= 10; $i++) {
// 重い処理の代替(例: データベースバッチや重い計算)
usleep(300000); // 300ms 相当の負荷
$reportProgress($i 10);
}
});
// タスクB: 外部API一括バルク送信シミュレーション
$loop->registerTask(‘ApiBulkSync’, function (callable $reportProgress) {
for ($step = 1; $step <= 5; $step++) {
usleep(500000); // 500ms 相当の負荷
$reportProgress($step 20);
}
});
// タスクC: ストレージクリーンアップ
$loop->registerTask(‘LogCleanup’, function (callable $reportProgress) {
for ($p = 1; $p <= 4; $p++) {
usleep(400000);
$pVal = $p 25;
if ($pVal > 100) $pVal = 100;
$reportProgress($pVal);
}
});
// イベントループの駆動開始
$loop->run();
—
コードレビューの視点:なぜこの設計が安全なのか?
上記のコードが実務のプロダクション環境に耐えうる理由は、以下の低レイヤおよび設計上の配慮にあります。
1. メモリリークと循環参照の回避
Fiber内部で外部スコープの巨大な変数をクロージャ(`use`)でキャプチャし続けると、Fiberが破棄されるまでZendエンジンのガベージコレクタ(GC)が回収できないメモリ領域が発生します。この実装では、タスクロジックに必要なのは進捗報告用のクロージャ(`$reportProgress`)のみであり、不要なスコープ汚染を防いでいます。
2. ターミナルの描画最適化(ちらつきの排除)
CLIでの進捗管理において最大の敵は、出力の乱れ(画面のちらつき:Flickering)です。`echo “\033[H”;`(ANSIエスケープシーケンスによるカーソル原点復帰)を使用し、画面全体をクリアするのではなく上書き更新することで、スムーズで美しいUIを実現しています。また、最後に `\e[?25h` を挟むことで、万が一スクリプトが強制終了してもユーザのターミナルカーソルが消失したままになる事故を防ぎます。
3. 非同期的協調動作とCPU占有率の制御
`usleep(50000)`(50ミリ秒)をイベントループの末尾に挟むことは非常に重要です。これを怠ると、イベントループが無限に空回り(忙しい待機: Busy Waiting)し、CPU使用率が単一コアで100%に張り付くという重大なインシデントを引き起こします。Fiberはプリエンプティブ(強制割り込み)ではなくコーペラティブ(協調的)であるため、イベントループ側が適切にインターバルを設けるか、I/O待機(`stream_select`等)と組み合わせる必要があります。
—
アーキテクトからの警句とさらなる高みへ
Fiberは魔法の杖ではありません。ブロッキングなI/O(通常の `file_get_contents()` や、PDOを通した同期的な重いSQLクエリ)をFiber内でそのまま実行した場合、その瞬間に対象のFiberだけでなくプロセス全体の実行権がブロックされ、他のFiberは一切進まなくなります。
真の非同期・高スループットをPHPで極めるためには、今回紹介したFiberによる「タスクの分割と進捗管理」の概念をベースにしつつ、ネットワークI/OやDBドライバレベルでの非同期化(Amp/ReactPHPエコシステム、あるいはSwoole/FrankenPHPといった拡張モジュール)との統合を見据える必要があります。
しかし、まずは身近なCLIバッチ処理からFiberを取り入れ、「処理の細粒化」と「ユーザー体験の向上」を体感してください。PHPの限界を押し広げるのは、いつだってコードの構造に対するあなたの深い洞察力なのです。