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

こんにちは。PHPの表層のフレームワークを使いこなし、さらにその先の「システム全体を極限までチューニングしたい」という領域に足を踏み入れているあなたなら、きっと一度はぶつかったことがあるはずです。

「PHPでデーモンや常駐型ワーカーを書いたはいいが、グレースフルシャットダウン(安全な停止)がどうも不安定だ」
「Ctrl+CやSIGTERMを送っても、いま処理中の重いジョブが中途半端に吹き飛ぶ、あるいは逆にいつまで経ってもプロセスが終了しない」

Webの1リクエストごとにプロセスが爆誕し、死んでいくシェアード・ナッシング(Shared-nothing)な世界観がPHPの美しさではありますが、非同期メッセージキューのワーカーやWebSocketサーバーを構築するとなると、話は一変します。Zend VMとOS、そしてイベントループが裏側でどうせめぎ合っているのか。ここを正確に把握していないと、本番環境で痛い目をみるエラーを引き起こしてしまいます。

今回は、PHPのシグナルハンドリングの裏側と、イベントループとの美しすぎる共存の作法について、Zend VMの息吹を感じながら紐解いていきましょう。ここを理解すれば、PHPの裏側が驚くほどクリアに見えてきますよ。

—

1. Zend VMとシグナルの「根本的なミスマッチ」

まず前提として知っておくべきなのは、PHPはもともと「1リクエストを短時間で完結させる」ために設計されたモンスターマシンだということです。OSからシグナル(`SIGTERM`や`SIGINT`など)が飛んできたとき、C言語などで書かれた常駐プログラムであれば即座に割り込み処理を行えますが、PHPのスクリプトはZend VMの仮想マシン上のバイトコード(オペコード)を1ステップずつ実行している最中です。

ここで問題が発生します。
「PHPスクリプトが重いループを回している最中や、外部APIの同期ブロッキングI/Oで固まっている時に、OSからシグナルが届いたらどうなるのか?」

答えは、「デフォルトのままでは、現在のオペコードの実行が完了するまでシグナルハンドラは発火しない(あるいは即座にプロセスが強制終了する)」です。

非同期シグナルの罠:`pcntl_signal()` の正体

`pcntl_signal()` を使ってシグナルハンドラを登録したことがあるでしょう。例えばこんな風に。

Zend VMが次のオペコード(opcode)に移行するタイミングでそのフラグをチェックし、「あ、シグナルが来てたから、あのPHP側のコールバック関数を挟み込むか」と割り込みを実行します。

つまり、もしあなたのPHPコードが「無限ループ」や「重いC拡張関数のブロッキング処理」の内部に閉じ込められていると、Zend VMがオペコードの切れ目を迎えないため、いつまで経ってもシグナルハンドラが呼ばれないという現象が起きます。これが、常駐プロセス開発者が最初にハマる「シグナルが無視される」の正体です。

—

2. イベントループの導入と `ticks` / `async` の制御

この問題を解決し、モダンな非同期・常駐プロセスを実現するためには、イベントループ(ReactPHPやAmp、あるいはネイティブの `stream_select` や `Ev` 拡張など)を組み合わせる必要があります。

イベントループの本質は、OSのI/O多重化(`epoll` や `kqueue`)とタイマー、そしてシグナルを一つのループで監視し続けることです。ここでPHP 7.1以降、あるいはPCNTL拡張を使う上で極めて重要なのが、「シグナルをどうやって即座に検知させるか」という点です。

かつては `declare(ticks = 1);` という構文が必須とされていましたが、これはすべてのオペコードごとにティック数をカウントしてシグナルをチェックするため、Zend VMのパフォーマンスに深刻なオーバーヘッドを叩き出していました。

現在のモダンなPHP(PCNTLシグナルdispatch)では、明示的にディスパッチを呼び出すか、イベントループの仕組みにシグナル監視を組み込むのが定石です。

実践:安全なグレースフルシャットダウンを持つワーカーの設計

それでは、実際のプロダクションコードをイメージした、堅牢な常駐プロセスの骨組みを見てみましょう。ここでは外部ライブラリに依存せず、PCNTLとストリームを使ったネイティブな設計思想を解説します。

registerSignals();

echo “[INFO] ワーカープロセスが起動しました (PID: ” . getmypid() . “)\n”;

// メインの常駐ループ
while (!$this->isTerminating) {
// 1. ジョブキューからのフェッチをシミュレート(例としてusleepを使用)
// 実際の現場では Redis の RPOPLPUSH や Kafka からメッセージを取る想定
$job = $this->fetchNextJob();

if ($job === null) {
// キューが空の場合はCPUを焼き尽くさないように少し休止
// ※ ここで長すぎる usleep を入れるとシグナルの反応が鈍るので注意
usleep(100000); // 0.1秒
continue;
}

// 2. ジョブの実行
$this->processJob($job);
$this->processedJobs++;

// 3. メモリリーク対策(長期常駐プロセスの鉄則)
// 一定数処理したら、プロセスを自発的にリタイアさせる設計も非常に有効
if ($this->processedJobs >= 1000) {
echo “[INFO] 1000件処理達成。メモリクリーンアップのため自発的シャットダウンします。\n”;
$this->isTerminating = true;
}
}

$this->gracefulShutdown();
}

private function registerSignals(): void
{
$signalHandler = function (int $signal): void {
switch ($signal) {
case SIGTERM:
case SIGINT:
echo “\n[INFO] 終了シグナル ({$signal}) を検知しました。現在のジョブ完了後に安全に停止します…\n”;
$this->isTerminating = true;
break;
case SIGHUP:
echo “[INFO] 設定リロードシグナル (SIGHUP) を検知しました。\n”;
// 設定ファイルの再読み込み処理などをここに挟む
break;
}
};

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

private function fetchNextJob(): ?array
{
// シミュレーション用
// 本番ではここで非同期I/Oやノンブロッキングなキューアクセスを行う
return $this->isTerminating ? null : [‘id’ => rand(1, 100), ‘data’ => ‘sample’];
}

private function processJob(array $job): void
{
echo “[PROCESSING] ジョブ ID: {$job[‘id’]} を実行中…\n”;

// 重い処理のシミュレーション
sleep(1);

echo “[COMPLETED] ジョブ ID: {$job[‘id’]} 完了。\n”;
}

private function gracefulShutdown(): void
{
echo “[INFO] 未処理のリソースを解放しています…\n”;
// DBコネクションの切断、オープンしているファイルハンドルのクローズなど
// …

echo “[INFO] ワーカープロセスが正常に終了しました。\n”;
exit(0);
}
}

// デーモンの実行
$daemon = new WorkerDaemon();
$daemon.run(); // 実際は $daemon->run(); ですね

このコードの肝は、`pcntl_async_signals(true);` を利用している点です。これにより、Zend VMは非同期的にシグナルをキャッチし、割り込みが発生した瞬間にPHP側のクロージャ(ハンドラ)を実行できるようになります。

—

3. Webアーキテクチャの視点:FPMと常駐プロセスの違い

ここで一歩引いて、普段私たちが触れている PHP-FPM と、今回解説している CLI常駐プロセス の違いをメモリとライフサイクルの観点から整理してみましょう。

| 軸 | PHP-FPM (Webリクエスト) | CLI常駐プロセス (Worker/Daemon) |
| :— | :— | :— |
| ライフサイクル | リクエストごとに初期化・実行・破棄の繰り返し | 起動し続け、無限ループでジョブを処理し続ける |
| メモリ管理 | リクエスト終了時にOSへ返却 (シェアード・ナッシング) | プロセス生存中はメモリに乗り続ける (メモリリークの温床) |
| シグナル処理 | マスタープロセスが管理 (Graceful Reloadなど) | ワーカー自身がシグナルを受け取り、身の振りを決める |
| OPcache | 共有メモリ(SHM)からバイトコードを即座にロード | 同様だが、長期稼働によるキャッシュ最適化の恩恵を受けやすい |

常駐プロセスにおける最大の敵は、Zend VMのメモリ管理の特性上発生する「メモリリーク」です。例えば、グローバル変数や静的プロパティ(`static`)に蓄積されていく配列、あるいはORM(Doctrineなど)のエンティティマネージャーがキャッシュし続けるエンティティの山は、リクエストの概念がない常駐プロセスでは永遠に解放されません。

だからこそ、先ほどのコード例でも触れたように、
1. シグナルを受信したら、「今やっている現在のジョブだけは最後までやり切り、次のループの頭で抜ける」(グレースフルシャットダウン)
2. 一定数のジョブを処理したら、プロセス自体をあえて死なせ、Supervisorやsystemdなどのプロセス監視ツールに新しいクリーンなプロセスを再起動させる(プロセスローテーション)

この2つを設計の基本思想に組み込むことが、プロフェッショナルなPHPアーキテクトの作法なのです。

—

4. アーキテクトからのメッセージ

PHPは「Webのための簡易的な言語」から、堅牢な非同期処理や常駐システムを支える「エンタープライズ・ランタイム」へと進化を遂げました。その心臓部であるZend VMと、OSのシグナル機構がどのように対話しているのかを解きほぐしていくと、コードを書くときの視野が劇的に広がります。

「なぜここでプロセスが詰まるのか」
「どうすれば安全にリソースを解放して終了できるのか」

その答えは常に、VMの実行モデルとOSのプリミティブな仕組みの間にあります。ぜひ、ご自身のシステム設計にこの極意を取り入れてみてください。PHPの裏側が綺麗に見えたとき、あなたのコードは一段と美しく、強靭なものになっているはずです。

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