首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
您现在的位置: 程式碼之家 >> 編程語言 >  >> 網頁程式設計 >> PHP 程式設計 >> PHP 相關知識

PHP 進程單例運行模式:PID 文件鎖、信號處理與優雅退出完整實現

     編輯:PHP 相關知識

PHP 進程單例運行模式:PID 文件鎖、信號處理與優雅退出完整實現

在 CLI 長運行腳本(守護進程、定時任務、隊列消費者)中,防止同一腳本重複啟動導致資源競爭、數據衝突、日誌混亂,是基礎可靠性需求。本文實現一個生產級 DaemonSingle 類,基於 PID 文件鎖 + PCNTL 信號處理 + 註冊關閉函數 三重保障,實現「啟動即鎖定、異常自動釋放、正常退出清理、強殺也能恢復」的健壯單例模式。

一、核心設計原理

二、完整類實現

<?php
/**
 * 守護進程單例運行器
 * 使用方式:
 * declare(ticks = 1); // 必須在入口文件最頂部聲明,啟用信號分發
 * $single = new DaemonSingle(__FILE__);
 * $single->single();
 * // 業務邏輯...
 */
class DaemonSingle
{
    private string $pidDir;
    private string $pidFile;
    private bool $pidCreated = false;

    public function __construct(string $scriptPath, string $pidDir = '/tmp')
    {
        if (empty($scriptPath)) {
            throw new InvalidArgumentException('Script path cannot be empty');
        }
        $this->pidDir = rtrim($pidDir, '/');
        $baseName = basename($scriptPath, '.php');
        $this->pidFile = "{$this->pidDir}/{$baseName}.pid";
    }

    /**
     * 單例啟動入口:檢查鎖、創建鎖、註冊清理
     */
    public function single(): void
    {
        $this->checkEnvironment();
        
        if (file_exists($this->pidFile)) {
            $existingPid = (int)@file_get_contents($this->pidFile);
            if ($this->isProcessAlive($existingPid)) {
                throw new RuntimeException("進程已在運行 (PID: $existingPid),拒絕重複啟動");
            }
            // 僵屍 PID 文件,強制清理
            @unlink($this->pidFile);
        }

        $this->createPidFile();
        $this->registerCleanupHandlers();
    }

    private function checkEnvironment(): void
    {
        if (!function_exists('pcntl_signal')) {
            throw new RuntimeException('PCNTL 擴展未安裝,無法使用信號處理。請安裝 php-pcntl 或編譯時啟用 --enable-pcntl');
        }
        if (!function_exists('posix_getpid')) {
            throw new RuntimeException('POSIX 擴展未安裝,無法獲取 PID。請安裝 php-posix');
        }
    }

    private function createPidFile(): void
    {
        if (!is_dir($this->pidDir)) {
            @mkdir($this->pidDir, 0755, true);
        }
        $fp = @fopen($this->pidFile, 'c'); // 'c' 模式:文件存在不截斷,不存在創建
        if (!$fp) {
            throw new RuntimeException("無法創建 PID 文件:{$this->pidFile}");
        }
        // 獨佔鎖防止併發寫入(雙重保險)
        if (!flock($fp, LOCK_EX | LOCK_NB)) {
            fclose($fp);
            throw new RuntimeException("無法獲取 PID 文件鎖,可能有併發啟動");
        }
        ftruncate($fp, 0);
        fwrite($fp, (string)posix_getpid());
        fflush($fp);
        fclose($fp);
        $this->pidCreated = true;
    }

    private function registerCleanupHandlers(): void
    {
        // 1. 信號處理器(異常終止)
        $signals = [SIGTERM, SIGINT, SIGQUIT];
        foreach ($signals as $sig) {
            pcntl_signal($sig, [$this, 'signalHandler']);
        }

        // 2. 關閉函數(正常/異常退出兜底)
        register_shutdown_function([$this, 'shutdownCleanup']);

        // 3. 啟用 GC(PHP 5.3+)
        if (function_exists('gc_enable')) gc_enable();
    }

    public function signalHandler(int $signal): void
    {
        $names = [SIGTERM => 'SIGTERM', SIGINT => 'SIGINT', SIGQUIT => 'SIGQUIT'];
        error_log("[DaemonSingle] 接收到信號 {$names[$signal] ?? $signal},開始優雅退出");
        $this->safeQuit();
        exit(0);
    }

    public function shutdownCleanup(): void
    {
        // 僅在非信號觸發的正常退出時執行(信號已調用 safeQuit)
        if ($this->pidCreated) {
            $this->safeQuit();
        }
    }

    private function safeQuit(): void
    {
        if (!$this->pidCreated || !file_exists($this->pidFile)) return;

        $currentPid = posix_getpid();
        $filePid = (int)@file_get_contents($this->pidFile);

        // 僅清理自己的 PID 文件,防止誤刪新進程的鎖
        if ($currentPid === $filePid) {
            @unlink($this->pidFile);
            $this->pidCreated = false;
            error_log("[DaemonSingle] PID 文件已清理:{$this->pidFile}");
        }
    }

    private function isProcessAlive(int $pid): bool
    {
        if ($pid <= 1) return false; // 排除 init/systemd
        // kill(0, $pid) 不發送信號,僅檢查進程是否存在且可發送信號
        return @posix_kill($pid, 0) === true;
    }

    public function __destruct()
    {
        $this->safeQuit();
    }
}

三、入口文件正確使用姿勢

<?php
// ===== 必須放在文件最頂部,任何輸出之前 =====
declare(ticks = 1); // 關鍵:啟用 PHP 信號分發機制,否則 pcntl_signal 不生效

require_once 'DaemonSingle.php';

// 業務邏輯
$single = new DaemonSingle(__FILE__, '/var/run/myapp');
try {
    $single->single();
    echo "進程啟動成功 (PID: " . posix_getpid() . ")\n";
    
    // 模擬長運行任務
    while (true) {
        // 你的業務代碼:隊列消費、定時檢查、Socket 監聽...
        sleep(10);
    }
} catch (Throwable $e) {
    echo "啟動失敗:" . $e->getMessage() . "\n";
    exit(1);
}

四、關鍵技術點深度解析

1. declare(ticks = 1) 為何必須在頂部?

PHP 的信號處理基於 ticks 機制:每執行 N 條低級指令檢查一次信號隊列。declare(ticks=1) 必須在**編譯階段**生效,若放在函數內或 require 後,當前文件的主腳本代碼不會被插桩,導致信號處理器永不調用。

2. PID 文件競態條件如何解決?

  • fopen(..., 'c') + flock(LOCK_EX | LOCK_NB):文件級獨佔鎖,確保併發啟動時只有一個進程能寫入 PID。
  • 檢查進程存活 posix_kill($pid, 0):不發送信號,僅探測進程是否存在。
  • 僵屍 PID 文件自動清理:啟動時檢測到 PID 文件但進程已死,自動 unlink 繼續啟動。

3. 為什麼需要 register_shutdown_function?

PCNTL 信號處理器無法捕獲:正常 return/exit、致命錯誤、SIGKILL (kill -9)。register_shutdown_function 是 PHP 提供的**唯一**在上述場景下仍能執行的清理鉤子(除 SIGKILL 外)。

4. 如何區分「信號退出」與「正常退出」避免雙重清理?

類中使用 $this->pidCreated 標記,safeQuit() 內冪等檢查 posix_getpid() === filePid,確保:

  • 信號觸發 → signalHandler → safeQuit → exit(0) → shutdownCleanup 再次調用但 pidCreated=false 跳過
  • 正常退出 → shutdownCleanup → safeQuit 執行清理

五、進階擴展:分佈式鎖替代方案(多機併發)

單機 PID 文件鎖無法解決多服務器併發啟動。生產環境建議升級為 Redis 分佈式鎖:

<?php
class DistributedSingle
{
    private Redis $redis;
    private string $lockKey;
    private int $ttl = 30; // 鎖過期時間(秒),需小於心跳間隔

    public function __construct(Redis $redis, string $lockName)
    {
        $this->redis = $redis;
        $this->lockKey = "lock:daemon:{$lockName}";
    }

    public function acquire(): bool
    {
        // SET NX EX 原子獲取鎖
        return $this->redis->set($this->lockKey, posix_getpid(), ['nx', 'ex' => $this->ttl]);
    }

    public function heartbeat(): void
    {
        // 定期續期(僅持有鎖時)
        $this->redis->expire($this->lockKey, $this->ttl);
    }

    public function release(): void
    {
        // Lua 腳本:僅刪除自己的鎖(防止誤刪他人續期後的鎖)
        $script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        $this->redis->eval($script, 1, $this->lockKey, (string)posix_getpid());
    }
}

六、最佳實踐清單

  1. ✅ 入口文件首行 declare(ticks=1)
  2. ✅ PID 目錄獨立(/var/run/app),權限 755,屬於運行用戶
  3. ✅ 業務主循環內定期 pcntl_signal_dispatch()(PHP 7.1+ 可選,ticks 機制已自動分發)
  4. ✅ 關鍵資源(DB、Redis、文件句柄)在 safeQuit / shutdownCleanup 中顯式關閉
  5. ✅ 日誌記錄啟動、信號接收、清理動作,便於排查
  6. ✅ 系統服務化:配合 Systemd Type=simple、Restart=on-failure、PIDFile= 實現生產級守護進程管理

將 DaemonSingle 納入項目基礎設施庫,所有 CLI 長運行腳本統一繼承/調用,即可從「靠運氣不重複啟動」進化為「工程化單例保障」,消除因重複啟動導致的數據重複消費、端口衝突、鎖競爭等線上故障。

  1. 上一篇文章:
  2. 下一篇文章:
Copyright © 程式碼之家 All Rights Reserved
機制作用觸發時機
PID 文件進程唯一標識,文件存在 = 進程運行中啟動時創建、退出時刪除
PCNTL 信號處理捕獲系統信號,執行清理邏輯SIGTERM/SIGINT/SIGQUIT (kill、Ctrl+C)
register_shutdown_function腳本正常/異常結束的最終兜底腳本返回、exit、致命錯誤
析構函數 __destruct對象銷毀時清理(補充保障)對象超出作用域、unset