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

Zend VMの深淵:シグナルハンドリングと常駐プロセスのライフサイクルデザイン

PHPは「1リクエストごとにプロセスが破棄される言語」というかつての前提は、現代のプロダクション環境においてはもはや過去のものだ。Swoole、RoadRunner、あるいは原生の `pcntl` とイベントループを組み合わせたWorkerプロセスによる常駐型アーキテクチャは、Webシステムのスループットを極限まで高めるための標準的な手法となっている。

しかし、この「常駐化」は、Zend VMとOSカーネルの境界線上で重大な矛盾を引き起こす。
リクエストの寿命から解放されたプロセスは、メモリリークの温床となるだけでなく、OSからの非同期シグナル(`SIGTERM`, `SIGINT`, `SIGHUP` など)とPHPのイベントループがいかに協調動作すべきかという、低レイヤの設計課題に直面する。

本稿では、Zend VMの実行モデル、OSのシグナル配送メカニズム、そして非同期イベントループが交差する点における致命的な競合(Race Condition)を完全に制御し、プロダクションで破綻しない常駐プロセスのライフサイクルを構築するための極限の知見を明かす。

—

1. OSシグナルとZend VMの非同期性の矛盾

Linuxカーネルにおいて、プロセスに対するシグナルは完全な非同期割り込みである。プロセスがCPUコア上でどのようなオペコード(Opcode)を実行中であれ、カーネルがシグナルを配送した瞬間、通常の制御フローは中断され、シグナルハンドラへと強制的にコンテキストがスイッチされる。

しかし、PHPのランタイム(Zend Engine)はシングルスレッドで動作し、Zend VMの仮想マシンスタックやメモリ管理機構(Zend Allocator / Emalloc)は非同期シグナルセーフ(Async-signal-safe)ではない。

もし、PHPのシグナルハンドラ(`pcntl_signal()` で登録されるユーザースペースのコールバック)の実行中に、ちょうどVMがヒープメモリの再割り込み(`emalloc()` や `efree()`)の最中であった場合どうなるか?
ヒープのメタデータ(`zend_mm_heap`)が中途半端に書き換えられた状態で別のメモリ操作が走り、Segmentation Fault (SIGSEGV) やヒープ破損による即座のプロセス崩壊を引き起こす。これが、PHPにおけるシグナルハンドリングの最大の罠である。

シグナル・チート・デス(Signal Deferred Execution)の仕組み

現代のPHP(ext-pcntl)では、シグナルが配送された際、直ちにユーザースペースのPHPコード(コールバック関数)を実行するわけではない。カーネルからのシグナルを検知すると、Zend Engineは「シグナルフラグ」を立てるに留め、実際のコールバックの実行はVMのオペコードディスパッチループの安全な境界(Tick、または特定のオプコード間)まで遅延(Deferred)させる。

しかし、この仕組みは「イベントループ(Stream Select / Event Extension等)」と組み合わせた際に、新たな競合を生む。

—

2. イベントループと `pcntl_signal` の致命的な競合

常駐プロセスは通常、`stream_select()` や `epoll` ベースのイベントループ(ReactPHPやAmpなど)でI/Oを待機している。

// 典型的だが脆弱性を孕んだイベントループの構造
while ($is_running) {
$read = [$server_socket];
$write = $except = [];

// カーネルの epoll_wait / select システムコールでブロック
$num_changed_sockets = stream_select($read, $write, $except, null);

if ($num_changed_sockets === false) {
// EINTR(シグナル割り込み)のハンドリングが漏れているとループが崩壊する
continue;
}

// イベントの処理…
pcntl_signal_dispatch(); // ここで明示的にシグナルをディスパッチ
}

ここで `SIGTERM` が飛んできた瞬間、何が起きるか?
`stream_select()` はシステムコールであり、カーネル空間で実行中にシグナルを受信すると、システムコールは即座に中断され、`-1` を返して `errno` に `EINTR`(Interrupted system call)を設定する。

もしこの `EINTR` を適切にハンドリングせず、かつ `pcntl_signal_dispatch()` のタイミングがズレた場合、プロセスはシャット信号を受け取っているにもかかわらず、次のI/Oブロックに入り込んでしまい、「ゾンビ化」あるいは「シャットダウン要求の完全な無視」が発生する。

—

3. 【実装】極限まで安全な常駐プロセスのライフサイクル設計

この競合を完全に排除し、安全にグレースフル・シャットダウン(Graceful Shutdown)を実現するためには、以下の3点を満たす実装が必要である。

1. `pcntl_async_signals(true)` による即時ディスパッチの制御(または厳密な `pcntl_signal_dispatch()` の配置)。
2. システムコールの `EINTR` 割り込みに対する堅牢なリトライ/脱出ロジック。
3. シャットダウンフラグをアトミックに保持し、進行中のトランザクション(イベント)を安全に完了させるステートマシン。

以下に、Zend VMの挙動を熟知したアーキテクトが設計する、堅牢な常駐プロセスのコードを示す。

  • 堅牢な常駐プロセス・ライフサイクルコントローラー
  • /
    class DaemonProcess
    {
    private bool $isRunning = true;
    private bool $isShuttingDown = false;
    private int $activeWorkers = 0;

    public function __construct()
    {
    // 非同期シグナルのハンドリングを有効化(Zend VMの安全な境界でディスパッチ)
    if (function_exists(‘pcntl_async_signals’)) {
    pcntl_async_signals(true);
    }
    }

    /

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

    /
    public function registerSignals(): void
    {
    $shutdownHandler = function (int $signo): void {
    $this->handleShutdownSignal($signo);
    };

    pcntl_signal(SIGTERM, $shutdownHandler);
    pcntl_signal(SIGINT, $shutdownHandler);
    pcntl_signal(SIGHUP, function (int $signo): void {
    // SIGHUPによる設定のリロード(Graceful Reload)などのフック
    echo “[INFO] SIGHUP received. Reloading configuration…\n”;
    });
    }

    private function handleShutdownSignal(int $signo): void
    {
    $signalName = $signo === SIGTERM ? ‘SIGTERM’ : ‘SIGINT’;
    echo “[INFO] Received {$signalName}. Initiating graceful shutdown…\n”;

    if ($this->isShuttingDown) {
    echo “[WARN] Force shutdown requested again. Terminating immediately.\n;
    exit(1); // 2回目のシグナルは強制終了(ハードキル)
    }

    $this->isShuttingDown = true;
    $this->isRunning = false;
    }

    /

    • メインのイベント・メッセージループ

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

    echo “[INFO] Daemon started with PID: ” . getmypid() . “\n”;

    while ($this->isRunning) {
    $read = [$serverSocket];
    $write = $except = null;

    // stream_select はシステムコールのため、シグナル割り込み(EINTR)が発生し得る
    $numChanged = @stream_select($read, $write, $except, 0, 200000); // 200ms timeout

    if ($numChanged === false) {
    // 割り込みシステムコール(EINTR)の検知
    $errorCode = pcntl_get_last_error();
    if ($errorCode === SOCKET_EINTR || $errorCode == 4) { // 4 is EINTR
    // シグナルによって中断された場合はループを継続し、フラグの状態を確認
    continue;
    }

    // その他の致命的なストリームエラー
    echo “[ERROR] stream_select failed with error code: {$errorCode}\n”;
    break;
    }

    if ($numChanged > 0) {
    // 新規接続の受け入れ
    $clientSocket = @stream_socket_accept($serverSocket, 0);
    if ($clientSocket !== false) {
    $this->processClient($clientSocket);
    }
    }

    // 定期的なメンテナンスや非同期タスクの処理
    $this->ticker();
    }

    // グレースフル・シャットダウンの最終クリーンアップ
    $this->terminate();
    }

    private function processClient(\Socket $client): void
    {
    if ($this->isShuttingDown) {
    // シャットダウン中は新規接続を拒否して即座に切断
    @fwrite($client, “HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\nServer is shutting down.”);
    @fclose($client);
    return;
    }

    $this->activeWorkers++;

    try {
    // リクエストの読み込みと処理(Zend VM上でのビジネスロジック実行)
    $request = @fread($client, 1024);

    // 処理のシミュレーション(重い処理を想定)
    usleep(50000);

    $response = “HTTP/1.1 200 OK\r\nContent-Length: 13\r\n\r\nHello, World!”;
    @fwrite($client, $response);
    } finally {
    @fclose($client);
    $this->activeWorkers–;
    }
    }

    private function ticker(): void
    {
    // メンテナンス処理やメモリリーク監視などをここに記述
    // 例: memory_get_usage(true) が閾値を超えたら安全に自死(Self-restart)する
    if (memory_get_usage(true) > 128 1024 1024) {
    echo “[WARN] Memory limit exceeded. Scheduling graceful restart…\n”;
    $this->isShuttingDown = true;
    $this->isRunning = false;
    }
    }

    private function terminate(): void
    {
    echo “[INFO] Waiting for active workers ({$this->activeWorkers}) to finish…\n”;

    // アクティブなワーカーが完了するまでブロック(タイムアウト付き)
    $timeout = 50; // 5秒
    while ($this->activeWorkers > 0 && $timeout > 0) {
    usleep(100000);
    $timeout–;
    }

    if ($this->activeWorkers > 0) {
    echo “[WARN] Forcefully exiting with {$this->activeWorkers} active workers remaining.\n”;
    } else {
    echo “[INFO] Graceful shutdown completed successfully.\n”;
    }
    }
    }

    —

    4. OPcacheプリローディングと常駐プロセスのメモリ共有

    常駐プロセスを語る上で欠かせないのが、OPcache Preloading の物理構造である。

    PHP 7.4以降で導入されたプレロードは、システム起動時に指定したスクリプト群を読み込み、Zend VMのオペコード(`zend_op_array`)にコンパイルした上で、共有メモリ(Shared Memory: SHM / Opcode Cache)に常駐させる。

    内部メモリ構造の真実

    プレロードされたオペコードは、親プロセス(FPM MasterやCLIの起動プロセス)のメモリ空間に展開され、その後 `fork()` によって生成される子ワーカープロセス群からCopy-On-Write (COW) によって共有される。

    しかし、ここに大きな罠がある。
    `pcntl_signal` やイベントループを持つ常駐プロセスにおいて、「グローバル変数や静的プロパティにリクエスト固有の状態やデータベース接続インスタンスを保持したままプレロードする」と、すべてのフォーク先ワーカー間でそのステートが共有され、深刻なデータ汚染(Cross-contamination)を引き起こす。

    • NGな設計: プレロード対象のクラスのプロパティに `PDO` インスタンスなどを保持させる。親プロセスで開かれたソケットやDBコネクションは `fork()` 後に子プロセス間で共有され、パケットの混線やハングアップの原因となる。
    • 正解の設計: プレロードするのは純粋なドメインロジック、サービスクラス、フレームワークのコア構造体(`zend_op_array` のみ)に限定し、リソース(DB接続、ファイルハンドル)の初期化は必ず `fork()` 後、あるいは各リクエスト/セッションのライフサイクル内で遅延初期化(Lazy Initialization)させること。

    —

    5. チーフアーキテクトからの最終提言

    PHPの常駐プロセス化は、フレームワークのパフォーマンスを限界突破させるための強力な武器である。しかしそれは、PHPが長年隠蔽してきた「メモリ管理」「シグナル」「OSのプリミティブ」という低レイヤの現実を、開発者が直視しなければならないことを意味する。

    Zend VMの挙動、オペコードのライフサイクル、そしてカーネルからのシグナル配送のタイミング。これらを完全に脳内でトレースし、コードの隅々にまで「非同期の割り込み」に対する防壁を築いたときはじめて、数ヶ月間再起動なしで秒間数万リクエストを捌き続ける真に堅牢なWebシステムが完成する。

    妥協なきコードのみが、プロダクションの荒波を生き残る。

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