【実務・中級編】PHPの『シグナルハンドリング』と常駐プロセスのライフサイクル:pcntl_signalとイベントループの競合 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

PHPで常駐プロセスを書くということ:シグナルとイベントループの深淵

コードレビューの場で、次のようなコードを見かけたら、私は即座にリジェクトする。

// 良くある「動くが破滅に向かう」常駐プロセスのスケルトン
while (true) {
$task = $queue->pop();
if ($task) {
process($task);
}
usleep(100000);
}

なぜか? このコードは、KubernetesやSystemdがコンテナやプロセスを「优雅に停止(Graceful Shutdown)」させようと `SIGTERM` を送ってきた瞬間、あえなく即死するか、あるいはシグナルを無視してゾンビ化する。運良く停止しても、実行中の重いジョブの途中で強制終了され、データベースのトランザクションは中途半端にコミットされ、Redis上のロックは解放されないまま残る。

PHPは「リクエストごとにすべてを忘れてリセットされる」というWebのステートレスな思想の元に最適化されてきた。しかし、APIの高速化、WebSocketサーバー、あるいはKafkaなどのメッセージキューからのリアルタイムコンシューマーとしてPHP(Swoole、ReactPHP、Ampなど)を常駐プロセス(Long-running process)として運用する現代において、私たちはZend VMのライフサイクルとOSのシグナル、そしてイベントループの関係性を完全に掌握しなければならない。

今回は、Zend VMの内部挙動と非同期シグナルハンドリングの衝突、そして実務で絶対に破綻しない常駐プロセスの設計論を解き明かす。

—

1. Zend VMとシグナルの「非同期性」という名の罠

PHPで `pcntl_signal()` を使ったことがあるエンジニアなら、一度は次のような疑問を持ったはずだ。「なぜシグナルハンドラーに登録したコールバック関数は、イベントが発生した瞬間に割り込んで実行されないのか?」

ここにPHP特有の、そしてC言語レベルのZend Engineの仕様に起因する罠がある。

信号(Signal)はいつ処理されるのか?

OSからプロセスにシグナル(`SIGTERM`や`SIGINT`など)が送られると、Linux kernelは現在のプロセスの実行を中断し、シグナルハンドラーを呼び出す。しかし、PHPはシングルスレッドでZend VMのオペコード(Opcodes)をインタプリタ実行している。

もしPHPのC言語レベルのシグナルハンドラー内で直接PHPのユーザーランド関数(コールバック)を呼び出すとどうなるか? VMのスタックフレームが完全に破壊され、セグメンテーション違反(Segmentation Fault)を引き起こしてコアダンプする。

これを防ぐため、PHP(拡張モジュール `pcntl`)は「ティック(Ticks)」という仕組み、あるいはPHP 7.1以降導入された「非同期シグナルハンドリング(Async Signal Handling)」を用いている。

  • 旧来のティック方式 (`declare(ticks = 1)`): すべての低レイヤオペコード数行ごとにシグナルが来ていないかフラグをチェックする。パフォーマンスへのオーバーヘッドが致命的。
  • 近代の非同期シグナル (`pcntl_async_signals(true)`): Cレベルのシグナルハンドラーがフラグ(`safe_mode`や内部フラグ)を立て、Zend VMが「安全にPHPのコードを実行できる境界(Statementの境界)」に到達した瞬間に、ユーザーランドのハンドラーを割り込ませて実行する。

つまり、重いループやブロッキングI/Oの最中には、PHPのシグナルハンドラーは発火しない。これが、イベントループ(ReactPHPやAmpなど)とネイティブの `pcntl_signal` を組み合わせる際の最大のボトルネックとなる。

—

2. イベントループとシグナルの競合を制する

実務でEvent Loop(例: `ReactPHP` や ` Revolt`)を使う場合、PHPの `pcntl_async_signals` をそのまま使うのは得策ではない。イベントループ自体がすでに `epoll` や `kqueue` を使って非同期I/Oを監視しており、シグナルもまたファイルディスクリプタ(Signal FD)としてイベントループに統合すべきだからだ。

イベントループにシグナルを統合することで、以下のメリットが生まれる。
1. 割り込みの安全性: VMのステートを破壊せず、イベントループの次のtickのタイミングで安全にクリーンアップ処理を実行できる。
2. I/Oとの調停: ソケットの読み書きやタイマー処理と、シグナル処理を同一のイベントループ上でシームレスに同期できる。

—

3. 【実践】実務に耐えうるグレースフル・シャットダウン実装

では、純粋な `pcntl` と簡易的なループ構造をベースに、メモリリーク(Zend VMのメモリ管理の特性)とシグナルを完全にコントロールした、プロダクション品質の常駐プロセスのリファレンスコードを提示する。

  • 堅牢なPHP常駐プロセス・基底ワーカー
  • 依存関係なし(拡張モジュール: pcntl, posix のみ使用)
  • /
    class RobustWorker
    {
    private bool $isRunning = true;
    private bool $isShuttingDown = false;
    private int $processedJobsCount = 0;

    // メモリリーク対策としての最大処理件数(これを超えたら安全に自殺し、OS/Process Managerに再起動させる)
    private const MAX_JOBS_BEFORE_RECYCLE = 1000;

    public function __construct()
    {
    // 1. シグナル処理の非同期化を有効化
    if (!function_exists(‘pcntl_async_signals’)) {
    throw new \RuntimeException(‘PCNTL async signals are not available.’);
    }
    pcntl_async_signals(true);
    }

    /

    • ワーカーのメインループを開始

    /
    public function run(): void
    {
    $this->registerSignals();

    echo “[” . date(‘Y-m-d H:i:s’) . “] Worker started with PID: ” . getmypid() . “\n”;

    while ($this->isRunning) {
    try {
    // シャットダウンシーケンスに入っている場合は新しいジョブを取らない
    if ($this->isShuttingDown) {
    break;
    }

    $job = $this->fetchNextJob();

    if ($job === null) {
    // ジョブがない場合はCPUを焼き尽くさないようスリープ(またはブロッキングキューからのポップ)
    usleep(50000); // 50ms
    continue;
    }

    $this->processJob($job);
    $this->processedJobsCount++;

    // PHPの宿命であるメモリ肥大化(循環参照や断片化)を防ぐため、一定数処理したらプロセスをリフレッシュする
    if ($this->processedJobsCount >= self::MAX_JOBS_BEFORE_RECYCLE) {
    echo “[” . date(‘Y-m-d H:i:s’) . “] Reached max jobs limit. Initiating graceful recycle…\n”;
    $this->isShuttingDown = true;
    }

    } catch (Throwable $e) {
    // 予期せぬ例外でワーカーをクラッシュさせない
    // ここでSentryやログ出力基盤に飛ばす
    error_log(“Critical error in worker loop: ” . $e->getMessage());

    // 致命的なエラーの時はスリープを入れて無限ループ暴走を防ぐ
    sleep(1);
    }
    }

    $this->shutdown();
    }

    /

    • OSシグナルのハンドラーを登録

    /
    private function registerSignals(): void
    {
    $signalHandler = function (int $signo): void {
    switch ($signo) {
    case SIGTERM:
    case SIGINT:
    echo “[” . date(‘Y-m-d H:i:s’) . “] Received termination signal (” . $signo . “). Graceful shutdown initiated…\n”;
    $this->isShuttingDown = true;
    // 二度目のシグナルが来たら強制終了(Hard Kill)
    pcntl_signal(SIGTERM, SIG_DFL);
    pcntl_signal(SIGINT, SIG_DFL);
    break;

    case SIGHUP:
    echo “[” . date(‘Y-m-d H:i:s’) . “] Received SIGHUP. Reloading configuration…\n”;
    $this->reloadConfiguration();
    break;
    }
    };

    pcntl_signal(SIGTERM, $signalHandler);
    pcntl_signal(SIGINT, $signalHandler);
    pcntl_signal(SIGHUP, $signalHandler);
    }

    private function fetchNextJob(): ?array
    {
    // 実際の実装では Redis (RPOPLPUSH / XREAD) や SQS から取得
    // ここではモックとしてダミーを返す
    return [‘id’ => uniqid(), ‘payload’ => ‘sample_data’];
    }

    private function processJob(array $job): void
    {
    echo “Processing job ID: {$job[‘id’]}\n”;
    // 重いビジネスロジックやDBトランザクションの処理
    usleep(100000); // 100msの処理をシミュレート
    }

    private function reloadConfiguration(): void
    {
    // 設定ファイルの再読み込み処理(DB接続の再確立など)
    }

    /

    • 安全な終了処理(リソースの解放)

    /
    private function shutdown(): void
    {
    echo “[” . date(‘Y-m-d H:i:s’) . “] Cleaning up resources…\n”;

    // 1. データベースコネクションの切断
    // DB::disconnect();

    // 2. 外部APIクライアントや一時ファイルのクローズ

    echo “[” . date(‘Y-m-d H:i:s’) . “] Worker gracefully shutdown completed.\n”;
    exit(0);
    }
    }

    // — 実行エントリポイント —
    // 実行方法: php worker.php
    (new RobustWorker())->run();

    —

    4. コードレビューの視点:なぜこの設計が「絶対に壊れない」のか

    上記のコードが実務レベルで堅牢である理由は、Zend VMの挙動とOSのプロセス管理の境界線を正確に引いているからに他ならない。コードレビューの文脈で、以下のポイントを後輩やチームメンバーに叩き込んでほしい。

    ① `pcntl_async_signals(true)` の有無

    これを忘れると、PHPはループ内で `SIGTERM` を受け取っても、ループがブロックしている間(あるいは内部関数を回っている間)はシグナルを処理せず、外の世界から見ると「フリーズしたプロセス」に見えてしまう。非同期シグナルを有効にすることで、ステートメントの境界で安全に割り込みが走る。

    ② メモリリーク(Memory Leak)と「プロセス使い捨て」の思想

    PHPの開発者は「リクエストが終わればメモリは全解放される」という前提に慣れきっている。しかし、常駐プロセスでは `array` やオブジェクトがグローバルスコープや静的プロパティ、あるいは長期生存するコンテナに溜まり続け、Zend Memory Managerの限界を超える。
    コード内の `MAX_JOBS_BEFORE_RECYCLE` のように、「一定数処理したら自ら綺麗に死ぬ」設計こそが、PHP常駐プロセスにおける最大のメモリリーク対策となる。 Supervisorやsystemd等のプロセスモニタリングツールと組み合わせ、死んだら即座に新品のプロセスが起動するアーキテクチャ(Process Pool)を構築すること。

    ③ 二重シグナル(Hard Kill)への備え

    「グレースフル・シャットダウン中」にデータベースの応答がなくなり、プロセスがハングすることがある。オペレーターは2回 `Ctrl+C` を押すか、強制終了(`SIGKILL`)を検討する。
    ハンドラー内で `pcntl_signal(SIGTERM, SIG_DFL);` に再設定しているのは、「1回目のシグナルで終了処理に入った後、それでも終了しない場合は2回目のシグナルで即座に強制終了させる」という、オペレータビリティにおける鉄則である。この保険がないと、デプロイ時にデプロイメントツールが永久にハングアップする地獄を見る。

    —

    結びにかえて

    PHPは、もはや単なる「Webページのテンプレートエンジン」ではない。Zend VMのプリミティブな挙動、OSのシグナル、そしてメモリ管理のライフサイクルを正しく理解したエンジニアが操れば、極めて堅牢で高パフォーマンスなバックエンドシステムを構築できる。

    コードを雑に書き捨てて「動いたからヨシ」とする時代は終わった。
    1リクエスト、1オペコードの重みを背負い、ハードウェアとOSに敬意を払った美しいコードを書き続けよう。

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