| 機制 | 作用 | 觸發時機 |
| PID 文件 | 進程唯一標識,文件存在 = 進程運行中 | 啟動時創建、退出時刪除 |
| PCNTL 信號處理 | 捕獲系統信號,執行清理邏輯 | SIGTERM/SIGINT/SIGQUIT (kill、Ctrl+C) |
| register_shutdown_function | 腳本正常/異常結束的最終兜底 | 腳本返回、exit、致命錯誤 |
| 析構函數 __destruct | 對象銷毀時清理(補充保障) | 對象超出作用域、unset |
二、完整類實現
<?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());
}
}
六、最佳實踐清單
- ✅ 入口文件首行
declare(ticks=1)
- ✅ PID 目錄獨立(
/var/run/app),權限 755,屬於運行用戶
- ✅ 業務主循環內定期
pcntl_signal_dispatch()(PHP 7.1+ 可選,ticks 機制已自動分發)
- ✅ 關鍵資源(DB、Redis、文件句柄)在
safeQuit / shutdownCleanup 中顯式關閉
- ✅ 日誌記錄啟動、信號接收、清理動作,便於排查
- ✅ 系統服務化:配合 Systemd
Type=simple、Restart=on-failure、PIDFile= 實現生產級守護進程管理
將 DaemonSingle 納入項目基礎設施庫,所有 CLI 長運行腳本統一繼承/調用,即可從「靠運氣不重複啟動」進化為「工程化單例保障」,消除因重複啟動導致的數據重複消費、端口衝突、鎖競爭等線上故障。
- 上一篇文章: PHP MySQLi 預處理語句實戰:DQL 查詢綁定參數與結果集完整指南
- 下一篇文章: PHPExcel 導出大數據量 Excel 實戰:分塊寫入、樣式優化、內存控制與下載響應
Copyright ©
程式碼之家 All Rights Reserved